Skip to content

daemon stability wave 2: health you can see, someone else's Mac - #130

Merged
m4ttheweric merged 154 commits into
mainfrom
job/integration-2
Aug 29, 2026
Merged

m4ttheweric merged 154 commits into
mainfrom
job/integration-2

Conversation

@m4ttheweric

Copy link
Copy Markdown
Collaborator

Daemon stability wave 2: health you can see, someone else's Mac

Stacked on #127. Executes phases 2 and 6 of docs/daemon-stability-audit-2026-08.md: each phase ran as a design lane (brainstorm, spec, plan, subagent-driven TDD) with an independent Opus review of both the spec and the plan before execution.

What changed

Phase 2, health you can see (RT-79; spec docs/superpowers/specs/2026-08-28-p2-health-design.md)

  • Server-computed health verdict (ok / degraded / unhealthy, naming the failing subsystem) plus memory, uptime, watcher and client metrics on status, tray:status, and ping (R011, R012)
  • 250ms unref'd event-loop monitor with a decaying-window lag signal, and a db-free atomic-rename heartbeat file so a stalled daemon reads as "stalled Ns ago" instead of "not running" (R003)
  • rt.logLevel setting plus a live rt daemon log-level verb; log size cap, per-(cmd,error) suppression, pino stream error listener, stderr demotion, deduped resolver warn sink (R004, S031, S032, S033, R005)
  • Request id plus caller tag on every command, echoed in error envelopes; unknown-command replies carry a code and the daemon version (R008, R021)

Phase 6, someone else's Mac (RT-83; spec docs/superpowers/specs/2026-08-28-p6-portability-design.md)

  • PATH resolution rebuilt: async, login non-interactive -lc base plus a bounded best-effort -ilc overlay in a killable process group, rt.daemonPath override, missing-tool warning; the sync-exec gate loses its user-path.ts allowlist entry (S013, S014, S062)
  • Stable machine key pinned at rt home init via hardware UUID, data-preserving for existing machines (S071)
  • Dev-wrapper marker detection reads a bounded prefix, never the whole binary (S020, S067)
  • Unsupported-arch warning at setup, honest "not provisioned" and git-identity diagnostics, sops spawn timeout (R051, S090, R043, S070)
  • branch_cache keyed identity:branch with every consumer repo-scoped and bare-branch lookups preserved (S069)

Follow-up

  • Swift tray reads for the new health level are documented, not implemented (Swift-owning change)
  • summaryCache eviction and the two-layer restart wait in daemon-client.ts remain parked minors from wave 1

Checklist

  • Anything that should be behind a feature flag is behind a feature flag
    • N/A. New settings (rt.logLevel, rt.daemonPath) default to prior behavior.
  • Appropriate tests have been created or updated
    • Unit 4783 pass / 0 fail; e2e 114 pass / 0 fail; tsc 0; picker, docs, purity gates green; rt-client dist fresh.

🤖 Generated with Claude Code

m4ttheweric and others added 30 commits August 28, 2026 09:29
… and report truncation at 100 files (S053, S086)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/gitq/deck) on a same-named PATH collision (S066)
…ry retire, not just createTree's path (S078)
… a configured root that is an ancestor of the repo (S079)
… (S054)

api-server captured the token once at boot while the secrets handler
called loadOrCreateApiToken() fresh on every request; an external
rotation or an unwritable token dir left the two permanently
disagreeing about the current token. getApiToken/reloadApiToken share
one in-memory cache between both consumers, and a persist failure now
logs a warning instead of failing silently.
…pid after binds

Boot failures on the prod path used to leave a live-pid zombie: a stale
rt.pid could get written before the socket/API binds even attempted, so a
failed boot exited without ever removing it. runDaemon() now wraps its body
in try/catch (log.fatal + flush + exit 1 on any failure), and rt.pid is only
written once both servers.socket and servers.api are assigned.

installCrashHandlers gains an opts.booting predicate: unhandledRejection is
fatal + exit 1 while true (boot phase, nothing worth staying up for), and
logs only (today's behavior) once bootPhase flips to "ready" right before
"daemon ready".
…rly instead of racing a retry into a second pane (S087)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fix (round 1): runDaemon() now catches and exit(1)s internally on
boot failure, so startDaemon()'s outer try/catch never runs, and its JSDoc
claiming otherwise was misleading. Replaced with a one-line comment stating
the real constraint.
…per-chunk, so a split multibyte char survives (S095)
…scope side effect

Hoists redirectNativeStderr() to the first executable statement and
installCrashHandlers() to right after the logger resolves, both before
createEventsBus, cron, sweep timers, and home-snapshot construction.
A pre-startDaemon throw (e.g. a corrupt events.db) now lands in
daemon-stderr.log instead of vanishing down a discarded fd 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…linked or case-variant root matches lsof (S097)
m4ttheweric and others added 19 commits August 28, 2026 18:11
…es (R043)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…069 part 1)

Also updates the two other BranchCacheStore implementers (lib/daemon.ts's
delegating facade, fake-cache-store.ts's test double) and one exact-key-set
test assertion so tsc stays clean; store PK/upsert/delete/gc behavior is
unchanged.
…fence (S069 part 1)

get/getByBranch on the BranchCacheStore interface forced an edit to
lib/daemon.ts's cache facade, which is under a write fence owned by the
p2-health lane. Drop get entirely (Task 10 consumers will use
entries[composeKey(identity, branch)] directly); reshape getByBranch into a
free function over an entries map instead of a store method, so the
interface -- and daemon.ts's facade -- do not change. Reverts the daemon.ts
and fake-cache-store.ts edits from the prior commit.
…-now items

Applies the final whole-branch review's approved fix wave for the rt
daemon health feature: maxLagMs now decays as a windowed max instead
of a lifetime high-water mark (and currentlyStalled drops its
redundant maxLagMs OR-leg), the health degraded threshold for
event-loop lag is named instead of hardcoded, one branch-introduced
em dash is fixed, a process-citation is dropped from a comment, the
daemon log-level resolver validates against pino's level set, and the
heartbeat reader now shape-guards against a partial-but-valid JSON
object.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…consumers (S069)

Consumers updated: store put (keys off entry.repoName), enrich (cold-start sets
identity from remoteUrl), notifier (composite fired-state), worktree-reconciler
(branchOf/mrKey), freshness (composeKey lookups), handlers/cache (bare-branch
read contract + optional repoIdentity), handlers/system-processes and
handlers/worktree (repo-scoped lookups), status/data (branchOf display).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…or/identity check

Two earlier-committed behavior changes broke four consumer test fixtures:
the dev-mode wrapper detector now requires a real marker (# mattstack-dev-mode
or RT_LAUNCH_CWD) instead of treating any file's presence as dev mode, and
commitInitialUserRepo now checks git identity before committing. Production
code is unchanged; fixtures now plant a recognized wrapper and answer the
git config user.name/email probes, matching the pattern already used in
home-snapshot.test.ts's defaultResponders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… review #1/#2)

enrichBranches's daemon-first path now passes repoIdentity on cache:read,
so a branch name shared across two tracked repos (main/master) no longer
risks a suffix-match cross-repo hit. SecretsTimeoutError now sets its
name, mirroring AgeKeyTimeoutError.
@coderabbitai

coderabbitai Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 21 minutes.

View limit details

Limit details: You’ve used the included review currently available. Your 72 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c27992c2-55d6-4846-8b6e-ec6088b5b884

📥 Commits

Reviewing files that changed from the base of the PR and between c6afe65 and bb73645.

📒 Files selected for processing (93)
  • commands/__tests__/home.test.ts
  • commands/__tests__/log-level.test.ts
  • commands/__tests__/status-lines.test.ts
  • commands/daemon.ts
  • commands/home.ts
  • commands/settings.ts
  • commands/status/__tests__/status-fallback.test.ts
  • commands/status/data.ts
  • docs/superpowers/plans/2026-08-28-p2-health.md
  • docs/superpowers/plans/2026-08-28-p6-portability.md
  • docs/superpowers/specs/2026-08-28-p2-health-design.md
  • docs/superpowers/specs/2026-08-28-p6-portability-design.md
  • e2e/tests/daemon.test.ts
  • lib/__tests__/daemon-config.test.ts
  • lib/__tests__/daemon-logger-level.test.ts
  • lib/__tests__/daemon-logger-resilience.test.ts
  • lib/__tests__/daemon-status.test.ts
  • lib/__tests__/dev-mode.test.ts
  • lib/__tests__/enrich-cache-identity.test.ts
  • lib/__tests__/intended-mode.test.ts
  • lib/__tests__/log-janitor.test.ts
  • lib/__tests__/notifier-fired-hygiene.test.ts
  • lib/command-tree-def.ts
  • lib/daemon-client.ts
  • lib/daemon-logger.ts
  • lib/daemon-status.ts
  • lib/daemon.ts
  • lib/daemon/__tests__/cache-read-bare-branch.test.ts
  • lib/daemon/__tests__/cache-refresh-gc.test.ts
  • lib/daemon/__tests__/caller-tag.test.ts
  • lib/daemon/__tests__/command-attribution.test.ts
  • lib/daemon/__tests__/discussions-semantics.test.ts
  • lib/daemon/__tests__/fake-cache-store.ts
  • lib/daemon/__tests__/freshness-mapping.test.ts
  • lib/daemon/__tests__/freshness-provider-rotation.test.ts
  • lib/daemon/__tests__/health-sampler.test.ts
  • lib/daemon/__tests__/health.test.ts
  • lib/daemon/__tests__/heartbeat-file.test.ts
  • lib/daemon/__tests__/home-snapshot.test.ts
  • lib/daemon/__tests__/loop-monitor.test.ts
  • lib/daemon/__tests__/refresh-status-ref.test.ts
  • lib/daemon/__tests__/status-identity.test.ts
  • lib/daemon/__tests__/system-processes-handlers.test.ts
  • lib/daemon/__tests__/unknown-command.test.ts
  • lib/daemon/__tests__/user-path.test.ts
  • lib/daemon/__tests__/worktree-reconciler.test.ts
  • lib/daemon/api-server.ts
  • lib/daemon/cache-refresh.ts
  • lib/daemon/command-attribution.ts
  • lib/daemon/freshness.ts
  • lib/daemon/handlers/cache.ts
  • lib/daemon/handlers/status.ts
  • lib/daemon/handlers/system-processes.ts
  • lib/daemon/handlers/types.ts
  • lib/daemon/handlers/worktree.ts
  • lib/daemon/health-sampler.ts
  • lib/daemon/health.ts
  • lib/daemon/heartbeat-file.ts
  • lib/daemon/home-snapshot.ts
  • lib/daemon/loop-monitor.ts
  • lib/daemon/socket-server.ts
  • lib/daemon/unknown-command.ts
  • lib/daemon/user-path.ts
  • lib/daemon/worktree-reconciler.ts
  • lib/deps/__tests__/links.test.ts
  • lib/deps/links.ts
  • lib/dev-mode.ts
  • lib/enrich.ts
  • lib/home/__tests__/init-exec.test.ts
  • lib/home/__tests__/machine-id.test.ts
  • lib/home/init-exec.ts
  • lib/home/machine-id.ts
  • lib/log-janitor.ts
  • lib/notifier.ts
  • lib/secrets/__tests__/store.test.ts
  • lib/secrets/store.ts
  • lib/setup/__tests__/fakes.ts
  • lib/setup/__tests__/steps-a.test.ts
  • lib/setup/__tests__/validators-mac.test.ts
  • lib/setup/__tests__/validators-rt-health.test.ts
  • lib/setup/probes.ts
  • lib/setup/validators/mac.ts
  • lib/state/__tests__/branch-cache.test.ts
  • lib/state/branch-cache.ts
  • lib/worktree/__tests__/dispose.test.ts
  • packages/rt-client/src/index.ts
  • packages/rt-client/src/settings/__tests__/registry.test.ts
  • packages/rt-client/src/settings/registry-defs.ts
  • packages/rt-client/src/settings/resolve.ts
  • packages/rt-client/src/transport.ts
  • packages/rt-client/test/settings-warn-sink.test.ts
  • website/docs/reference/daemon/index.mdx
  • website/docs/reference/daemon/log-level.mdx

Comment @coderabbitai help to get the list of available commands.

@m4ttheweric
m4ttheweric changed the base branch from job/integration to main August 29, 2026 04:50
@m4ttheweric
m4ttheweric merged commit a39fbea into main Aug 29, 2026
4 checks passed
@m4ttheweric
m4ttheweric deleted the job/integration-2 branch August 30, 2026 14:18
m4ttheweric added a commit that referenced this pull request Sep 17, 2026
* agent-herdr: resolve herdr via PATH probe instead of hardcoded ~/.local/bin (S002)

* notifier: retry the post-push queue removal so a busy-swallowed delete can't cause a duplicate delivery (S096)

* cron: pass the daemon's resolved process.env to spawn instead of the launchd-frozen snapshot (S046)

* agent handler: return ok:false when herdr dedups the tab label instead of a phantom record (S051)

* chat handlers: guard emit/notify after the message commit so a throw can't surface as a failed post (S052)

* discussions:diffs: bound the GitLab fetch with a timeout+abort signal and report truncation at 100 files (S053, S086)

* plan: Phase 0 honest-supervision implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* hooks-guard: handle fs.watch error events and reconcile stale watchers on refresh (S057)

* plan: Phase 3 trust-boundary implementation plan (S005/S006/S010/S040-043/S050/S054/S083-085/S092)

* deps links: never auto-unlink a DEFAULT_EXPOSED tool (rt/fast-browser/gitq/deck) on a same-named PATH collision (S066)

* age-key: give the keychain spawn a bounded, distinguishable timeout (S070)

* presence-store: honor the tail heartbeat in the offline rule and prune predicate (S075)

* chat:sign-in: reject a missing/empty sessionId instead of storing a NULL-keyed presence row (S076)

* worktree trash: exclude .worktrees/ from the repo's git status on every retire, not just createTree's path (S078)

* docs: sketch daemon supervision verdicts + exit-code semantics

* docs: retire stale daemon-runner-health.md, point at the current audit + supervision design

* daemon: cap request body size at 1 MiB on both servers (S092)

* worktree trash reap: require a plausible rt-written epoch, and refuse a configured root that is an ancestor of the repo (S079)

* daemon: one shared api-token cache for api-server and secrets handler (S054)

api-server captured the token once at boot while the secrets handler
called loadOrCreateApiToken() fresh on every request; an external
rotation or an unwritable token dir left the two permanently
disagreeing about the current token. getApiToken/reloadApiToken share
one in-memory cache between both consumers, and a persist failure now
logs a warning instead of failing silently.

* daemon: boot failure is fatal (exit 1), gated by boot-phase flag; rt.pid after binds

Boot failures on the prod path used to leave a live-pid zombie: a stale
rt.pid could get written before the socket/API binds even attempted, so a
failed boot exited without ever removing it. runDaemon() now wraps its body
in try/catch (log.fatal + flush + exit 1 on any failure), and rt.pid is only
written once both servers.socket and servers.api are assigned.

installCrashHandlers gains an opts.booting predicate: unhandledRejection is
fatal + exit 1 while true (boot phase, nothing worth staying up for), and
logs only (today's behavior) once bootPhase flips to "ready" right before
"daemon ready".

* pane:spawn: check the caller's abort signal between steps and stop early instead of racing a retry into a second pane (S087)

* mr:by-branch: apply the same demand-scope gate to the forge write-back that the sync path enforces (S088)

* plan: Phase 1 event-loop implementation plan (RT-78, items 1.1-1.5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon: drop startDaemon's dead-code catch and its now-stale JSDoc

Review fix (round 1): runDaemon() now catches and exit(1)s internally on
boot failure, so startDaemon()'s outer try/catch never runs, and its JSDoc
claiming otherwise was misleading. Replaced with a one-line comment stating
the real constraint.

* worktree create: serialize createTree per repoPath so provision and replenish don't race git ref locks (S089)

* daemon: rt.trustedBrowserOrigins allowlist + needsToken invert-default (S005/S006/S040/S041/S084)

* herdr client: accumulate raw socket bytes and decode once instead of per-chunk, so a split multibyte char survives (S095)

* daemon: install stderr redirect + crash handlers before every module-scope side effect

Hoists redirectNativeStderr() to the first executable statement and
installCrashHandlers() to right after the logger resolves, both before
createEventsBus, cron, sweep timers, and home-snapshot construction.
A pre-startDaemon throw (e.g. a corrupt events.db) now lands in
daemon-stderr.log instead of vanishing down a discarded fd 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* port-scanner: canonicalize repo/worktree paths once per scan so a symlinked or case-variant root matches lsof (S097)

* rt-paths: only migrate a legacy ~/.rt that carries an actual rt signature (S099)

* daemon: fix needsToken to never gate OPTIONS preflight (review fix)

Splitting OPTIONS out of the GET/HEAD bucket restores the pre-existing
guarantee that a CORS preflight is never gated, regardless of path,
matching this file's own docblock and existing test coverage.

* daemon-client: return timed-out/refused attribution per query instead of shared module flags (S081)

* daemon: default-deny CORS and gate /ws on origin/token (S005/S006)

* runCapture: race reads against the deadline so a pipe-holding grandchild can't wedge it (S023, S024)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon-client: bounded-poll rt.sock after a restart instead of one 300ms retry (S082)

* runs prune: reap expired run dirs with a detached rm -rf, never a sync recursive rmSync (S100)

* daemon: broadcast() drops dead/backpressured ws clients instead of silently dropping frames (S042)

* worktree reconciler: queue a follow-up pass for a kick arriving after the loop has started, instead of dropping it (S065)

* events.db: corruption quarantine + busy_timeout/synchronous pragmas; guard sweep timers

createEventsBus now mirrors state/db.ts's corruption quarantine (rename to
events.db.corrupt-<ISO> + -wal/-shm sidecars, warn, recreate empty) and sets
busy_timeout=250 / synchronous=NORMAL on open. events.db is a
bounded-retention journal, so total loss on quarantine is harmless.

Added safeInterval/safeTimeout (lib/daemon/safe-timers.ts) and wrapped the
two eventsBus.sweep() timers in lib/daemon.ts with them, so a synchronous
sqlite throw mid-tick (e.g. SQLITE_FULL) warns instead of becoming an
uncaughtException that exits the daemon.

Also fixes the near-vacuous e2e assertion carried from Task 3: the
corrupt-events.db test now asserts the quarantine file exists AND the
daemon actually boots and serves (a live rt events emit round trip),
instead of existsSync(stderrLog) || quarantined, which was always true.

* runCapture: stop clearing the SIGKILL timer before it fires, strengthen SIGKILL test

Review found the finally block cleared killTimer in the same tick the
deadline promise resolved, so a SIGTERM-ignoring child was never actually
SIGKILLed even though runCapture's own promise settled on time. Leave
killTimer running past the finally; it is already try/catch guarded so it
is a no-op once the child has exited. Also capture and clear the deadline
promise's own timer handle so it doesn't outlive the fast path.

Strengthened the SIGKILL escalation test to assert the child process is
actually gone (via its recorded pid) rather than only asserting runCapture's
promise resolved on schedule, since the old assertion passed regardless of
whether the kill worked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chat:post/chat:dm: validate room/handle/body shape and refuse an unknown room instead of a silent black hole (R010)

* daemon: pathParam() helper -- malformed %-encoding is a 400, not a logged 500 (S083)

Adds a shared pathParam() decode helper and wires it into the three
parameterized routes (/api/cache/:branch, /api/hooks/:repo/repair,
/api/runs/:repo/:runId), replacing each route's hand-rolled
decodeURIComponent. A malformed path segment now returns a clean 400
instead of falling through to the outer catch's logged 500 (cache and
hooks routes) or the generic 404 (runs route).

* validate chat:join wakeOn and agent:start/resume surface against their enums (R033)

* source-guards: update the boot-failure-exits guard for runDaemon owning the catch

Review fix (round 2): the guard tested startDaemon's body for catch +
process.exit(1), which fix-round-1 made stale by moving the catch-and-exit
into runDaemon itself (startDaemon is now a thin await runDaemon()). Rewrote
the guard to assert the invariant at its real location: runDaemon's body
still owns catch + process.exit(1), and startDaemon just awaits it. Fixed
the stale "startDaemon is a thin catch-and-exit wrapper" comment in the
neighboring test to match.

* git-async: 5-min timeout for checkout/merge/stash/status so a large-repo checkout isn't killed half-applied (S104)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chat:read/chat:messages: clamp limit into [1,500] instead of reaching SQLite's unlimited negative LIMIT (R034)

* daemon: coerce REST GET query params to number/boolean at the seam (S085)

* git-async: bump the 3 inline runGit(status/stash pop) call sites to MUTATING_TIMEOUT_MS

Closes the S104 gap left by fd935d7: freshenOne's stash-pop reapply and
autoReturnMain's two status --porcelain checks called runGit directly
rather than through the helpers, so they were still on the 60s default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* discussions-diffs test: fix fetch cast to satisfy tsc

* daemon: log the EADDRINUSE port holder and throw a typed ApiPortInUseError

* cache-refresh: async git + grant-gated doppler loop; add listWorktreeRootsAsync (S008, S045, S021)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon: bind API before socket; register rt.apiPort setting + resolveApiPort()

Binding the API server first means a fatal API-bind failure never strands
a socket-bound zombie behind it. rt.apiPort is the escape-hatch setting
the api-server sibling consumes at bind time; resolveApiPort() resolves
it lazily (env > setting > 9401) without disturbing the existing API_PORT
const api-server.ts already imports.

* daemon: standalone git-ref validator for option-injection guard (worktree.ts wiring documented, not wired here)

* daemon: standalone credential-redaction utility (freshness.ts wiring documented, not wired here)

* freshness: async, cached getRemoteUrl via runCapture (R032)

getRemoteUrl used execSync on the daemon thread inside every forge
handler and the freshness reconcile loop. Switch to runCapture (5s
timeout) and cache per repoPath for the process lifetime, since
remotes rarely change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* worktree-process-kill: async lsof/ps via runCapture (S015, S016)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* home-snapshot: lazy daemon-flavored state.db; getStateDb re-applies a stronger flavor timeout

startHomeSnapshot no longer resolves getStateDb() eagerly at construction with the default cli flavor (5000ms busy_timeout). db access is a thunk (resolveDb) defaulting to getStateDb("daemon"), first invoked inside init() after its await (past module-scope construction, so the daemon's own openBranchCacheStore() opens the singleton daemon-flavored first).

getStateDb() also now re-applies PRAGMA busy_timeout when a caller requests a stronger (shorter) flavor than the already-open singleton holds, hardening against any other future eager-cli-then-daemon ordering bug.

* docs: the :9401 trust boundary model and the S010/S050/S043 sibling wiring notes

* repo-index: async observed-main-path on the endpoint:claim resolve path (S098)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix rt-paths lint and registry test regressions on job/p3-trust-boundary

redact-credentials.ts: reword the doc comment so it no longer spells out
a literal .rt-prefixed path (rt-paths.test.ts bans that outside
lib/rt-paths.ts); the rationale is unchanged.

registry.test.ts: add the rt.trustedBrowserOrigins key (introduced by an
earlier task on this branch) to the expected suiteKeys list and bump the
length assertion from 42 to 43.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* state: isBusyError matches SQLITE_BUSY_*; read-then-write daemon txns use BEGIN IMMEDIATE

joinRoom/archiveRoom/readUnread (chat-store.ts), dmRoomFor (dm-store.ts), and
drainNotificationQueue (notifier-store.ts) read before they write inside a
db.transaction; bun:sqlite's plain transaction() defers BEGIN, so a
concurrent writer can produce SQLITE_BUSY_SNAPSHOT that busy_timeout cannot
absorb. .immediate() takes the write lock up front instead.

presence-store.ts:signIn has the same shape but is a sibling-owned
write-fence file; left untouched as a documented follow-up.

* repo-index: fix write-back regression in resolveIndexPathForIdentity

setIndexPath is a bare KV write; it skipped the moved-repo guard, the
repos.json compat mirror, and the unopenable-db degrade that
writeIndexRow (and thus updateRepoIndex) provides. Swap to
writeIndexRow, which takes the already-resolved async main path and
preserves all three, with no sync git reintroduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* gate: fail on sync-exec anywhere in the daemon import graph (1.3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* state.db: isolate each legacy importer in a SAVEPOINT so one bad file cannot wedge migration

A throwing legacy importer previously rolled back the whole v0->v1 migration,
leaving user_version at 0 and repeating the identical throw on every later
openStateDb call (daemon boot + every CLI command) with no self-heal. Wrap
each LEGACY_IMPORTS entry in its own SAVEPOINT so one importer's throw only
rolls back its own writes; warn with the file and error, still push it to
consumed so it gets renamed .migrated, and let the schema DDL and every other
importer land and reach SCHEMA_VERSION.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon: add supervision-state (boot attempts, failures, last-exit) kv + breadcrumb

* refresh: whole-cycle deadline clears the coalesce latch; cap RepoWatch.pending (S007)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon: wire supervision-state breadcrumbs + kv records into boot/shutdown

* fix(refresh): clear the deadline timer on every settle path, not just on wedge

Promise.race never cancels the losing branch. When impl won (every
successful refresh), the deadline setTimeout still fired ~4 min later
and called onTimeout, logging a spurious "cache refresh timed out"
warn and leaking a ref'd timer per cycle. Capture the timer handle and
clearTimeout it in the shared .finally, which runs on every settle
path; clearing an already-fired timer is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* freshness: rebuild the provider cache when gitlabToken rotates (S048, S049)

Key the providers map on a token fingerprint so a rotated gitlabToken
rebuilds the GitLabProvider on the next ensureProvider/getRepoContext
call instead of waiting for a daemon restart. A token mismatch drops
the stale watch and resets the userIdResolved latch so the next
reconcile re-authenticates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon: lazy-resolve trusted-origin allowlist, drop finding-ID comments

Fix A: getTrustedBrowserOrigins() did a synchronous settings-store read
on every single :9401 request, even the ~100% with no Origin header
(CLI, tray, rt-client from Bun/Node) that never needed it. Added
resolveOriginTrust() in api-auth.ts, which only resolves the allowlist
when an Origin header is present, and wired both api-server.ts call
sites (the /ws gate and the CORS/token gate) through it.

Fix B: stripped leftover audit finding-ID citations (S005/S006/S040/
S041/S042/S054/S083/S085) from production comments in api-auth.ts and
api-server.ts, keeping the underlying technical rationale.

Fix C: reworded two stale "CORS is *" comments to describe the actual
current default-deny CORS model (a trusted Origin gets its response
echoed back; the local token, not CORS, is what stops a page from
firing a mutating request in the first place).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: name the S084 GET-route known-gap ruling in the trust-boundary note

* freshness: close the two remaining stale-token paths (S048/S049 review fix)

getRepoContext served a cached provider without checking its token, so
poll-mode repos and already-cached forge-handler providers kept a
rotated token forever. reconcileFreshnessImpl skipped ensureProvider
entirely for repos with a live watch, so a running watcher never
rebuilt after rotation either. Both now compare the cached token
against loadSecrets() before reuse and drop the stale watch/provider on
a mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* system-process-scanner: a failed lsof preserves runaway windows (S061)

gather() returned [] on both a real empty scan and an lsof/ps
failure, so scan() pruned every tracked pid on a transient failure,
resetting firstSeen and the runaway sample window. getAllRepoPids and
gather now return null on scan failure (distinct from an empty
result), and scan/refresh early-return the prior lastResult before
touching tracked state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* runs: mtime-memoize finished-run summaries; back off herdr probe (S101, S038, S039)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon status: alive-not-serving / parked / boot-failed / crash-looping verdicts

rt daemon status and the ping handler now read Task 9's breadcrumb file and
supervision kv to distinguish a live-but-not-serving daemon (booting/wedged/
quarantined), a flavor standoff (parked), a single boot failure, and a
crash loop, on top of the existing not-installed/running/degraded/not-running
verdicts. pidAlive uses lsof scoped to RT_DIR instead of a system-wide pgrep,
since the brief's suggested pgrep pattern false-positives against any real rt
daemon running under a different HOME on the same machine.

* runs: scope summary-cache key by runsRoot; poller: test the backoff recovery path

Coordinator review follow-up on task 11: the mtime cache key could
theoretically collide across different RT_RUNS_ROOT values sharing a
process, and the backoff's consecutiveFailures reset on a successful
probe had no test proving it re-enables per-tick probing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pollers: gate the 10s/30s scans on recent consumer demand (S058, S093)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon status: exclude the calling process from probePidAlive's lsof fallback

readSupervisionState() opens a bun:sqlite handle on state.db (inside RT_DIR)
right before probePidAlive runs, so lsof +D RT_DIR was reporting the calling
CLI process itself as a live holder of the directory -- a dead daemon with
no rt.pid and no live breadcrumb pid could self-match and misclassify as
alive-not-serving/parked. Filter process.pid out of the lsof result and add
a regression test that opens state.db and asserts the fallback returns
false with no daemon-related pids present.

* demand-tracker: document command-only demand-stamp contract; subprocess: unref SIGKILL timer

Notes the reviewer-flagged gap that WS/SSE topic subscriptions don't stamp
demand, only wrapped command handlers do. Unrefs the belt-and-suspenders
SIGKILL timer in runCapture so a timed-out call can't hold a short-lived
rt-client CLI process open for up to 2s; the daemon still delivers the
SIGKILL since it stays alive. Applied identically to lib/subprocess.ts and
its rt-client mirror, and rebuilt rt-client's dist/ to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* logs: rotate daemon-stderr.log on open; hide stale crash block by mtime

daemon-stderr.log grew unbounded and rt daemon logs always showed its
stale contents as "most recent crash". redirectNativeStderr now renames
a non-empty file to a dated daemon-stderr.<yyyy-MM-dd>.log (deduped with
a .N suffix) before reopening, matching log-janitor's LOG_FILE_PATTERN
so pruneLogs sweeps it for free. showLogs now gates the native-stderr
block on mtime vs. the running daemon's startedAt (from ping), via a new
pure nativeStderrDisplay helper, and stamps the mtime in the header.

* daemon: bare-signal exit is non-zero (launchd respawns); shutdown verb stays exit 0

launchd's KeepAlive.SuccessfulExit:false only respawns on a non-zero exit.
Reserve exit 0 for the intentional shutdown verb; a bare SIGTERM/SIGINT/SIGHUP
(external kill, memory pressure) now exits 1 so launchd relaunches. The
sanctioned stop path (SMAppService.unregister) never goes through this signal
path, so this does not affect intended stops.

Refactors installSignalHandlers' inline gracefulExit into a testable
makeGracefulExit(deps) that injects exit/recordCleanExit/wasVerbShutdown.

* daemon: ownership-aware socket/pid unlink; eviction waits for pid death then SIGKILL

* daemon CLI: uninstall guards on liveness; start escalates to kickstart; attemptRestart re-probes

uninstall() no longer deletes rt.sock/rt.pid/daemon.json when a failed or
absent tray /daemon/stop leaves the daemon actually still alive
(isDaemonProcessRunning() or probeSocketHolder() says so) — it now prints
the real remedy (launchctl bootout) and leaves the files in place instead
of orphaning a live daemon.

start() now escalates to the /daemon/restart (kickstart) route when the
tray acks /daemon/start but the socket never comes up through the existing
poll, since SMAppService can register a job that never actually launches.

attemptRestart() in lib/daemon-client.ts re-probes isDaemonRunning() after
the tray ack instead of trusting the POST response alone, so daemonQuery's
retry logic stops treating a merely-accepted request as a real restart.

Audited commands/settings.ts's dev-mode toggle for the same gap: it never
calls cleanupDaemonFiles()/markDaemonUninstalled() at all (it goes through
the tray's /flavor/retire route instead), so there's nothing to fix there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon: redirect stderr after legacy-dir migration; set shuttingDownViaVerb at verb receipt

redirectNativeStderr() ran before migrateLegacyRtDir(), so its mkdirSync
of the new rt dir made every real ~/.rt migration report a false
"conflict". Move it after the migration check, still ahead of every
other module-scope side effect.

shuttingDownViaVerb was only set inside the shutdown verb's 100ms delayed
cleanup, so a bare SIGTERM arriving in that window read it as unset and
exited 1, causing launchd to respawn a daemon that was told to stop. Set
it at verb receipt instead.

* daemon: drop unused clearBreadcrumb

Production only ever writes or reads the boot breadcrumb (it gets
overwritten with "ready" on successful boot); clearBreadcrumb had no
callers outside its own test. Removed the export and the now-unused
unlinkSync import; the test that only covered clearBreadcrumb is gone,
and the remaining "no breadcrumb written" test does its own file
cleanup instead of relying on the removed API.

* rt-client: soften the rt.apiPort description to match reality

resolveApiPort() has no callers yet -- api-server.ts still binds the
API_PORT const -- so the setting currently does nothing when set. The
old description promised it as an "escape hatch when 9401 is held",
which is not true until the sibling wiring lands. Rebuilt dist/ (not
committed, gitignored).

* docs: scrub em/en dashes, fix daemon-supervision-design phase-order drift

Owner rule forbids em/en dashes in committed text. Rephrased them out of
daemon-supervision-design.md and the runner-health redirect title;
mechanical ellipsis substitution for the large historical plan doc,
where careful rephrase wasn't warranted.

Also fixed two places daemon-supervision-design.md had drifted from the
implementation: the breadcrumb BootPhase order is actually
start -> events-db -> state-db -> api -> socket -> ready (no
"crash-handlers" phase, API binds before socket), and the
alive-not-serving liveness fallback is `lsof +D RT_DIR` scoped to this
HOME and filtered to exclude the caller's own pid, not a system-wide
`pgrep -f`.

* chore: scrub em/en dashes from branch-added comments and strings (owner rule)

* I5: wire the seams the four lanes documented but could not cross

(a) daemon.ts catches ApiPortInUseError from startApiServer and parks
    with exponential backoff (withApiPortParkRetry); api-server.ts binds
    via resolveApiPort() instead of the hardcoded API_PORT constant.
(b) S055: handlers/status.ts's "repos" command uses listWorktreesAsync
    instead of the sync execSync-based listWorktrees; git-worktrees.ts
    and handlers/status.ts drop out of the no-daemon-sync-exec allowlist.
(c) S010: worktree:provision validates the resolved branch with
    validateGitRef before any runGit call (covers divergence() too).
(d) S050: freshness.ts's three remote-URL log/error interpolations run
    through redactCredentials.
(e) S022: resolveUserIdAcrossTracking resolves userId for any
    branches/project-mrs tracked repo regardless of mode, called from
    cache-refresh.ts before checkAndNotify so poll-only users get
    self-authored-transition notifications from cycle 1.
(f) S073: presence-store.ts's signIn transaction uses .immediate(),
    matching the chat-store/dm-store/notifier-store siblings.
(g) rt.apiPort's settings description now reflects that it's wired.

* e2e: rewrite the 3 API-port-squat daemon tests for I5(a)'s park-retry

They asserted the pre-fix crash-on-EADDRINUSE behavior (fatal exit,
boot-failed/crash-looping). I5(a) makes this recoverable instead:
withApiPortParkRetry parks and retries with backoff rather than
crashing, so the daemon now boots successfully once the squatted
port frees. Rewritten to assert the new contract: alive (not exited)
while parked, no rt.sock/rt.pid until bind succeeds, status never
falsely reports "running" while parked, and both recover once the
port frees.

* spec: p2-health daemon health model design

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* spec: p6-portability design (Phase 6 portability)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* spec: fix degraded-vs-alive-not-serving branch, pin thresholds, scope R012 watcher-close out

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* spec: apply reviewer edits (S069 read contract + 3 sites, bounded-prefix wrapper read, interactive PATH overlay, S071/doppler notes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* plan: p2-health implementation plan (16 TDD tasks)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* plan: p6-portability implementation plan (10 tasks)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* plan: apply reviewer fixes (loop-monitor test/alloc, CacheRefresherDeps widen, seq in ping, second fetch tag)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* add lib/daemon/health.ts: pure computeHealth + thresholds

* add lib/daemon/heartbeat-file.ts: atomic-rename heartbeat

* plan: apply reviewer fixes (bounded read in links.ts, widen disabledReason, overlay garbage warn, sops spawn-injection test)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* add lib/daemon/loop-monitor.ts: drift monitor + heartbeat cadence

* rt-client: register rt.daemonPath machine setting (6.1)

* daemon-status: heartbeat-stale 'stalled' detail + degraded eventLoop

* daemon status: render health/stall lines; add non-restarting pingDaemon

* user-path: async fish-aware killable PATH probe + rt.daemonPath override (S013/S014/S062)

* daemon-logger: stream error listener + loggerDegraded + crash-handler raw-write fallback

* daemon-logger: drop em dashes from new comments (style)

* user-path: reword overlay comment to drop process-artifact reference

* daemon: await async resolveUserPath; drop user-path sync-exec allowlist (6.1)

* daemon-logger: rt.logLevel resolution, stderr->warn demotion, 50m size cap, recovered-error counter

* home: stable machine-key at init, data-preserving freeze of existing stores (S071)

* log-janitor: onError callback; daemon logs prune failures at warn

* plan: Task 14 imports setSettingsWarnSink from local ./settings/resolve.ts barrel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rt-client: rt.logLevel registry row + injectable deduped settings warn sink (dist rebuilt, no bump)

* dev-mode: marker-based wrapper detection, bounded read, legacy fallback (S020/S067)

* handleCommand: reqId + caller tag + per-(cmd,error) suppression + slow-command info + currentCmd

* setup: arm64/unsupported-arch row at setup (R051)

* unknown-command envelope (code+version); transports send X-RT-Client (dist rebuilt, no bump)

* servers: thread X-RT-Client into payload._client; advertise it in CORS

* plan: split HandlerContext type additions into the tasks that provide their values (13/14/15) to avoid a tsc-red window

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ctx: extend refreshStatusRef with cycle outcome; export apiWsClientCount

* cache-refresh: remove em dash from applyRefreshOutcome docstring

* dev-mode: fix rt-health/steps-a consumer tests for the new wrapper detector (S020/S067)

* home-snapshot: diagnose not-provisioned and missing git identity (S090/R043)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* daemon: wire loop monitor + heartbeat + health sampler; surface health/metrics/eventLoop in status/tray:status/ping

status-identity.test.ts's fakeCtx() gained getHealth/heartbeatSeq stubs to match the widened HandlerContext.

* add rt daemon log-level: live level set/show via daemon:log-level verb

* fix: remove em dashes from log-level comments

* home-snapshot: gate janitor-zone commit on git identity; drop em dashes (R043)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* secrets: timeout + SecretsTimeoutError on the sops spawn (S070 sops half)

* e2e: assert additive health/metrics/eventLoop + heartbeat file

* branch-cache: add composeKey/branchOf/identityOf + get/getByBranch (S069 part 1)

Also updates the two other BranchCacheStore implementers (lib/daemon.ts's
delegating facade, fake-cache-store.ts's test double) and one exact-key-set
test assertion so tsc stays clean; store PK/upsert/delete/gc behavior is
unchanged.

* registry.test: add rt.logLevel to migrated-key fixtures (24 keys)

* branch-cache: make get/getByBranch free functions; restore daemon.ts fence (S069 part 1)

get/getByBranch on the BranchCacheStore interface forced an edit to
lib/daemon.ts's cache facade, which is under a write fence owned by the
p2-health lane. Drop get entirely (Task 10 consumers will use
entries[composeKey(identity, branch)] directly); reshape getByBranch into a
free function over an entries map instead of a store method, so the
interface -- and daemon.ts's facade -- do not change. Reverts the daemon.ts
and fake-cache-store.ts edits from the prior commit.

* fix: windowed maxLag recovery + named loop-lag threshold + review fix-now items

Applies the final whole-branch review's approved fix wave for the rt
daemon health feature: maxLagMs now decays as a windowed max instead
of a lifetime high-water mark (and currentlyStalled drops its
redundant maxLagMs OR-leg), the health degraded threshold for
event-loop lag is named instead of hardcoded, one branch-introduced
em dash is fixed, a process-citation is dropped from a comment, the
daemon log-level resolver validates against pino's level set, and the
heartbeat reader now shape-guards against a partial-but-valid JSON
object.

* fix: drop em dashes in arch-row comment and test describe (no-em-dashes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* branch-cache: flip to composite ${identity}:${branch} key; scope all consumers (S069)

Consumers updated: store put (keys off entry.repoName), enrich (cold-start sets
identity from remoteUrl), notifier (composite fired-state), worktree-reconciler
(branchOf/mrKey), freshness (composeKey lookups), handlers/cache (bare-branch
read contract + optional repoIdentity), handlers/system-processes and
handlers/worktree (repo-scoped lookups), status/data (branchOf display).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: adapt dev-mode + home-init consumer fixtures to stricter detector/identity check

Two earlier-committed behavior changes broke four consumer test fixtures:
the dev-mode wrapper detector now requires a real marker (# mattstack-dev-mode
or RT_LAUNCH_CWD) instead of treating any file's presence as dev mode, and
commitInitialUserRepo now checks git identity before committing. Production
code is unchanged; fixtures now plant a recognized wrapper and answer the
git config user.name/email probes, matching the pattern already used in
home-snapshot.test.ts's defaultResponders.

* docs: regenerate command reference for wave-1 daemon flags and verbs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* enrich: repo-scope daemon cache:read; SecretsTimeoutError.name (final review #1/#2)

enrichBranches's daemon-first path now passes repoIdentity on cache:read,
so a branch name shared across two tracked repos (main/master) no longer
risks a suffix-match cross-repo hit. SecretsTimeoutError now sets its
name, mirroring AgeKeyTimeoutError.

* ci: retrigger checks after docs regeneration

* docs: regenerate command reference after wave-2 merge (J5)

* chat-handlers.test: drop test for chat:unread-waking, a verb main's delivery-v2 removed

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
m4ttheweric added a commit that referenced this pull request Sep 26, 2026
…cussion check (#130)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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