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
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:Every fix has been running on a live install. That install's
bun LIFEOS/TOOLS/IntegrityCheck.tsrecord (MEMORY/STATE/integrity/last-run.json) readsexitCode 0, blocking 0, info 0at 2026-09-21T09:35Z against install commit692d0c3; 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.tsreadSYSTEMUPDATES/index.json;CreateUpdate.tswritesINDEX.mdand nothing writesindex.json, so the ledger registry was permanently "unreadable". (e6f3ee5)modules/tab-freshness.tswatchedUSER/LOCAL/for the LOCAL tab, a directory that has never existed; the digest is served fromUSER/CUSTOMIZATIONS/SKILLS/LocalIntelligence/latest.json. (e6f3ee5)SystemHealthVitals.tsxreadsummary.successRate/summary.recent24hfrom/api/observability/voice-eventsand/tool-failures; those routes return a bare array (observability.tshandleVoiceEventsApi), so voice was always 100% and failures 0/hr. The documentation vital was a hardcoded"Fresh". (e6f3ee5)checks/poller-meta-monitor.tswatched a hardcoded list of twelve job names, none of which appears in anyPULSE.toml, and indexed the state file at the top level when it is shaped{ version, jobs }. Every iteration hit "not configured yet" and reportedNO_ACTIONwithout examining anything. (6f4b25a)Services.tsregistry saidcom.lifeos.synthesiswas "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.tsread presets and the registry fromUSER/LIFEOS_WORKERS/, which the scaffold does not ship; both live inUSER/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.tschecked zero sites and reportedNO_ACTIONevery five minutes on every install that never setLIFEOS_PULSE_HEALTH_SITES— theairgradient-pollshape 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/.envwhen the daemon's env lacks it, and no longer ships scheduled inPULSE.toml. (3d2c11a)Services.ts statusand 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.tomloverridden byPULSE.user.toml, aslib.ts loadConfigmerges 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.json→INDEX.md,USER/LOCAL/→ the digest path,summary.*→ the array the route actually returns (alle6f3ee5).DAInterview.ts --updatematched frontmatter scalars only in quoted form; the growth engine emits bare scalars, so an existing DA read as nameless. (53b24dd)4. State conflation
airgradient-poll,calendar,github,github-work,health) caught its own crash, printed a success sentinel and exited 0.lib.ts spawnScriptfails a job only on a nonzero exit, so a dead token or unreachable API was a quiet healthy check and theMAX_FAILURESbreaker could never trip.calendar.tsalso saidNO_EVENTS("no meetings") when the refresh token was unset; it now exits 78.github-work.tssaidNO_ACTIONwhen it could not obtain a token. (3d2c11a)Conduit/adapters/git.tsused 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.tslogged "disabled" for any osascript failure; the common cause is darkwake and the next poll retries. (423cc94)DAInterview.ts --updateregeneratedDA_IDENTITY.mdfrom the interview template, destroying every key another writer owns. It now patches only its own keys via theyamlpackage already inpackage.json. Required prompts also re-asked forever on stdin EOF. (53b24dd)Also carried (not one of the four shapes)
InstallConduit,InstallConduitInsight,InstallPegWatchbakedprocess.execPathinto plists — a version-pinned Homebrew Cellar path that dies on the next bun upgrade. They resolvebunby name on PATH now. (0d0a44f)lib/SystemdUser.tsgains aweekdayfield so the Linux backend can render the weekly synthesis timer. (0e85141)A note for reviewers: Pulse's
spawnScripttreats 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, andServices.ts statussays why.Known pre-existing issues, left alone deliberately
print()atLIFEOS/TOOLS/DAInterview.ts:159is dead code that predates this work; untouched under a no-collateral-changes rule.LIFEOS/TOOLS/DAGrowth.tsreadsUSER/DA/<name>/whileDAInterview.tswritesUSER/DIGITAL_ASSISTANT/(shape 1 upstream). The live install's fix importsPULSE/Assistant/lib, which this repo does not ship, so it is not ported; a follow-up should pointDAGrowth.tsat the flat dir directly. Same forDASchedule.ts.skills/LocalIntelligence/Tools/ClaudeFill.ts:168marks model gap-filled sectionssource_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=nodeon all 17 changed.tsfiles: OK.tsc --noEmit --stricton the 17 non-UI files: exit 0, no errors. Full Observability projecttsc --noEmit: 0 errors.Services.ts docrun against this branch's tree reproduces the in-process table committed inBackgroundServices.mdbyte for byte.health.tswith no sites → exit 78;calendar.tswith no token → exit 78;poller-meta-monitor.ts→NO_ACTION, exit 0;renderTimerwithweekday: 1→OnCalendar=Mon *-*-* 03:05:00.USER/,settings.jsonor.envcontent touched.