Skip to content

fix: health reports that were the broken thing — readers with no writer, zero-with-success, name drift, state conflation - #2208

Open
pai-scaffolde wants to merge 7 commits into
danielmiessler:mainfrom
pai-scaffolde:fix/health-report-honesty
Open

pai-scaffolde wants to merge 7 commits into
danielmiessler:mainfrom
pai-scaffolde:fix/health-report-honesty

Conversation

@pai-scaffolde

Copy link
Copy Markdown

Summary

Seven commits, 21 files under LifeOS/install/LIFEOS/. None of these defects throws. Each one lets a check, a tab, a status board or a monitor report health, and the health report is the thing that is broken. They fall into four shapes:

  1. A reader with no writer. Code reads a file or field that nothing ever produces, and renders the default as health.
  2. Zero-with-success. A collector enumerates nothing and reports healthy.
  3. Name drift between writer and reader. The writer moved or renamed its output; the reader kept the old name. No error is ever thrown.
  4. State conflation. Two semantically different states share one predicate — "crashed" and "nothing to report", "unconfigured" and "no meetings", "timed out this poll" and "disabled".

Every fix has been running on a live install. That install's bun LIFEOS/TOOLS/IntegrityCheck.ts record (MEMORY/STATE/integrity/last-run.json) reads exitCode 0, blocking 0, info 0 at 2026-09-21T09:35Z against install commit 692d0c3; the later DA-tool commits in this port were dogfooded after that run and are not covered by it.

Fixes by shape

1. Reader with no writer

  • modules/ledger.ts read SYSTEMUPDATES/index.json; CreateUpdate.ts writes INDEX.md and nothing writes index.json, so the ledger registry was permanently "unreadable". (e6f3ee5)
  • modules/tab-freshness.ts watched USER/LOCAL/ for the LOCAL tab, a directory that has never existed; the digest is served from USER/CUSTOMIZATIONS/SKILLS/LocalIntelligence/latest.json. (e6f3ee5)
  • SystemHealthVitals.tsx read summary.successRate / summary.recent24h from /api/observability/voice-events and /tool-failures; those routes return a bare array (observability.ts handleVoiceEventsApi), so voice was always 100% and failures 0/hr. The documentation vital was a hardcoded "Fresh". (e6f3ee5)
  • checks/poller-meta-monitor.ts watched a hardcoded list of twelve job names, none of which appears in any PULSE.toml, and indexed the state file at the top level when it is shaped { version, jobs }. Every iteration hit "not configured yet" and reported NO_ACTION without examining anything. (6f4b25a)
  • Services.ts registry said com.lifeos.synthesis was "installed with the Pulse/Conduit stack"; nothing installs it and no plist template existed, so the weekly synthesis never ran while the doc table implied it did. An installer now ships. (0e85141)
  • DAInterview.ts read presets and the registry from USER/LIFEOS_WORKERS/, which the scaffold does not ship; both live in USER/DIGITAL_ASSISTANT/. It also wrote the identity into a <slug>/ subdirectory while every other reader (PULSE/setup.ts, hooks/lib/identity.ts) uses the flat dir. (53b24dd)

2. Zero-with-success

  • checks/health.ts checked zero sites and reported NO_ACTION every five minutes on every install that never set LIFEOS_PULSE_HEALTH_SITES — the airgradient-poll shape from Fresh install: several subsystems reference private skills/files not present in the public release #1504. It now exits 78 (EX_CONFIG) with no sites, reads the variable from ~/.claude/.env when the daemon's env lacks it, and no longer ships scheduled in PULSE.toml. (3d2c11a)
  • Services.ts status and the doc it generates covered only launchd; Pulse's in-process cron roster was invisible, so a job latched behind the circuit breaker showed as a green board. pulseCronJobs() enumerates the configured roster (PULSE.toml overridden by PULSE.user.toml, as lib.ts loadConfig merges them) and overlays state; config is the roster, because a job that never came due writes no state entry. (6f4b25a)

3. Name drift between writer and reader

  • index.jsonINDEX.md, USER/LOCAL/ → the digest path, summary.* → the array the route actually returns (all e6f3ee5).
  • DAInterview.ts --update matched frontmatter scalars only in quoted form; the growth engine emits bare scalars, so an existing DA read as nameless. (53b24dd)

4. State conflation

  • Every check script (airgradient-poll, calendar, github, github-work, health) caught its own crash, printed a success sentinel and exited 0. lib.ts spawnScript fails a job only on a nonzero exit, so a dead token or unreachable API was a quiet healthy check and the MAX_FAILURES breaker could never trip. calendar.ts also said NO_EVENTS ("no meetings") when the refresh token was unset; it now exits 78. github-work.ts said NO_ACTION when it could not obtain a token. (3d2c11a)
  • Conduit/adapters/git.ts used a wall-clock cursor; commits arrive by pull long after their author date, so nearly every pulled commit fell outside the window while the scan reported clean. The cursor is now per-repo HEAD. (423cc94)
  • Conduit/adapters/appFocus.ts logged "disabled" for any osascript failure; the common cause is darkwake and the next poll retries. (423cc94)
  • DAInterview.ts --update regenerated DA_IDENTITY.md from the interview template, destroying every key another writer owns. It now patches only its own keys via the yaml package already in package.json. Required prompts also re-asked forever on stdin EOF. (53b24dd)

Also carried (not one of the four shapes)

  • InstallConduit, InstallConduitInsight, InstallPegWatch baked process.execPath into plists — a version-pinned Homebrew Cellar path that dies on the next bun upgrade. They resolve bun by name on PATH now. (0d0a44f)
  • lib/SystemdUser.ts gains a weekday field so the Linux backend can render the weekly synthesis timer. (0e85141)

A note for reviewers: Pulse's spawnScript treats exit 78 like any nonzero, so an unconfigured check that is still scheduled trips the breaker after three runs. That is deliberate — the breaker is now seeing the truth, and Services.ts status says why.

Known pre-existing issues, left alone deliberately

  • print() at LIFEOS/TOOLS/DAInterview.ts:159 is dead code that predates this work; untouched under a no-collateral-changes rule.
  • LIFEOS/TOOLS/DAGrowth.ts reads USER/DA/<name>/ while DAInterview.ts writes USER/DIGITAL_ASSISTANT/ (shape 1 upstream). The live install's fix imports PULSE/Assistant/lib, which this repo does not ship, so it is not ported; a follow-up should point DAGrowth.ts at the flat dir directly. Same for DASchedule.ts.
  • skills/LocalIntelligence/Tools/ClaudeFill.ts:168 marks model gap-filled sections source_status: "ok"; the LOCAL tab therefore renders model output as sourced. The fix belongs in that writer and is out of this PR's scope.

Verification

  • bun build --target=node on all 17 changed .ts files: OK.
  • tsc --noEmit --strict on the 17 non-UI files: exit 0, no errors. Full Observability project tsc --noEmit: 0 errors.
  • Services.ts doc run against this branch's tree reproduces the in-process table committed in BackgroundServices.md byte for byte.
  • health.ts with no sites → exit 78; calendar.ts with no token → exit 78; poller-meta-monitor.tsNO_ACTION, exit 0; renderTimer with weekday: 1OnCalendar=Mon *-*-* 03:05:00.
  • Diff grepped for machine-specific paths, principal names and token shapes: clean. No USER/, settings.json or .env content touched.

…execPath

process.execPath resolves through the symlink to Homebrew's
Cellar/bun/<version>/bin/bun. Every launchd plist these installers wrote
baked that path in, so the next bun upgrade removed the binary the job
pointed at and the job could not launch — with no error anywhere but
launchd's own log. Prefer the stable name on PATH; fall back to execPath.

Applies to InstallConduit, InstallConduitInsight and InstallPegWatch.
…ng to report"

Every Pulse check script caught its own crash, printed a success sentinel
(NO_ACTION / NO_EVENTS) and exited 0. lib.ts spawnScript fails a job only
on a nonzero exit, so a dead token, an unreachable API or an unwritable
cache dir all read as a quiet, healthy check and the MAX_FAILURES breaker
could never trip. Each catch now exits 1.

Two of them also conflated "not configured" with "nothing happening":

- calendar.ts said NO_EVENTS ("no meetings") when
  GOOGLE_CALENDAR_REFRESH_TOKEN was unset; it now exits 78 (EX_CONFIG).
- health.ts checked zero sites and reported ok every 5 minutes on every
  install that never set LIFEOS_PULSE_HEALTH_SITES — the airgradient-poll
  shape from danielmiessler#1504. It now exits 78 with no sites, reads the variable from
  ~/.claude/.env when the daemon's env lacks it (bun only auto-loads .env
  from CWD, and Pulse cron runs from LIFEOS/PULSE/), and the shipped
  PULSE.toml no longer schedules it; installs that name sites add the row
  to PULSE.user.toml.

github-work.ts additionally exited 0 with NO_ACTION when it could not get
an installation token, so a worker that never reached GitHub was "ok".
Three readers with no writer — nothing ever produced what they read, so
they rendered a default that looked like health:

- modules/ledger.ts read SYSTEMUPDATES/index.json. TOOLS/CreateUpdate.ts
  writes INDEX.md and nothing writes index.json, so the ledger registry was
  permanently "unreadable". Parse INDEX.md's rows and total instead; an
  absent file is "nothing recorded yet", not an error. tab-freshness.ts
  watched the same phantom file for the ledger tab.
- tab-freshness.ts watched USER/LOCAL/ for the LOCAL tab, a directory that
  never existed; the digest modules/local-intelligence.ts serves lives at
  USER/CUSTOMIZATIONS/SKILLS/LocalIntelligence/latest.json.
- SystemHealthVitals.tsx read `summary.successRate` and `summary.recent24h`
  from /api/observability/voice-events and /tool-failures. Those routes
  return a bare array of the newest 100 JSONL rows (observability.ts
  handleVoiceEventsApi / handleToolFailuresApi) — there is no summary
  envelope — so voice was always 100% and failures always 0/hr. The
  documentation vital was a hardcoded "Fresh". The strip now derives the
  24h windows from the arrays it gets, reads doc freshness from the same
  /api/tab-freshness feed the tab pill uses, renders "unknown" when a feed
  is missing rather than healthy, and labels the failures badge for what it
  measures (tool failures, not hooks).
… is not "disabled"

git adapter: the poll cursor was wall-clock (`--since=<last poll>`), but
commits arrive by pull minutes to hours after their author date, so nearly
every pulled commit fell outside the window and was never captured — while
the adapter reported a clean scan. The cursor is now the per-repo HEAD SHA
(`git log <lastHead>..HEAD`); `--since` remains only the fallback for a
repo with no recorded head or a rewritten one.

appFocus adapter: any osascript failure logged "disabled — ... No app-focus
events will be captured". The commonest cause is darkwake, where System
Events has no frontmost app and the query hangs to its timeout; each poll
is its own process and the next one retries. Say "no app-focus this poll"
and keep the "disabled" wording for the real platform gap (non-macOS).
…d preserves other writers' keys

- Presets and registry were read from USER/LIFEOS_WORKERS/, a directory
  the install scaffold does not ship; both files live in
  USER/DIGITAL_ASSISTANT/ (see that directory's README). The identity file
  was also written to and read from a <slug>/ subdirectory while every
  other reader (PULSE/setup.ts, the identity hook) uses the flat dir, so
  --update could not find the DA it had just created.
- --update regenerated DA_IDENTITY.md from the interview template, wiping
  every key another writer owns (growth engine, freshness header, body
  sections). It now patches only the interview's own keys into the
  existing document via the `yaml` package already in package.json.
- Frontmatter scalars were matched only in quoted form; the growth engine
  emits bare scalars, so --update read an existing DA as nameless.
- prompt() returns null on stdin EOF; required prompts re-asked forever on
  a pipe. Exit 1 on a closed non-TTY stdin instead, having written nothing.
…claiming one exists

The registry row said the weekly synthesis pass was "installed with the
Pulse/Conduit stack — see PULSE/". Nothing under PULSE/ installs it and no
plist template existed, so the weekly LearningPatternSynthesis.ts --week
run never happened on any install while the doc table implied it did.

Add InstallSynthesis.ts and its plist template (weekly, Monday 03:05, plus
once at load — --week is a trailing 7-day window, so a daily run would
write the same week seven times), point the registry row's install and
uninstall at it, and teach lib/SystemdUser.ts a `weekday` field so the
Linux backend renders `OnCalendar=Mon *-*-* 03:05:00`.
…the jobs Pulse actually runs

Pulse runs a second roster of scheduled jobs inside its own process.
launchd knows nothing about them, so `Services.ts status` and the doc it
generates covered half the scheduled surface, and a job latched behind
Pulse's circuit breaker could sit skipped for days while every launchd
view said "running".

- Services.ts gains pulseCronJobs(): the configured roster (PULSE.toml,
  overridden by PULSE.user.toml by name, the way lib.ts loadConfig merges
  them) with the state file overlaid. Config is the roster and state the
  scoreboard: a job that has never come due writes no state entry, so
  state alone hides exactly the job most worth seeing. `status` renders
  each as disabled / never ran / ok / error / latched with an alarm for
  enabled-but-never-ran and latched; `doc` emits the shipped roster with
  its schedules (per-instance rows withheld, same containment rule as the
  launchd table).
- checks/poller-meta-monitor.ts watched a hardcoded WATCHED_JOBS list of
  twelve names, none of which appears in any PULSE.toml, so every
  iteration hit the "not configured yet" branch and reported NO_ACTION
  without examining anything. It also indexed the state file at the top
  level when the file is shaped `{ version, jobs: {...} }`, so even a
  correct name would never have matched. It now iterates the same roster.
- BackgroundServices.md: add the in-process table the generator now emits
  and drop the stale "all 16" count.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant