Skip to content

fix(daemon): move supervisor authority records out of $TMPDIR - #1449

Open
snimu wants to merge 6 commits into
mainfrom
feat/daemon-ownership-renewal
Open

fix(daemon): move supervisor authority records out of $TMPDIR#1449
snimu wants to merge 6 commits into
mainfrom
feat/daemon-ownership-renewal

Conversation

@snimu

@snimu snimu commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What this fixes

Any daemon supervisor alive for more than three days wedged permanently: macOS's com.apple.bsd.dirhelper deletes files older than 3 days under $TMPDIR daily, and the supervisor's ownership registry (owner.json/scope.json, plus startup fences and the shutdown admission) lived exactly there. Once reaped, every command against that daemon failed with "no longer owns its registry entry" and the only recovery was killing it. This is a clock, not a race — it hit every long-lived daemon on macOS.

The fix: move the records, don't outrun the cleaner

The registry only lived in $TMPDIR by proximity: the daemon socket needs a short path (the ~104-byte sun_path limit), and the registry rode along in the socket dir. But these are JSON files — no path-length constraint applies, and they are durable authority records sitting in the one directory the OS is explicitly allowed to prune.

The registry now lives at ~/.prime/supervisor-owners (still one global per-user location, which cross-agent-dir conflict detection requires; the internal env override still wins). All registry tenants move together: owner records, startup fences, the shutdown admission. Nothing deletes files behind a live daemon anymore, so the failure mode is simply gone — no renewal machinery, no self-heal, no timer.

Version-overlap and migration

No record migration — ownership records are per-process-lifetime — but owner-record READS fall back to the legacy $TMPDIR location for one release (writes never do). This keeps the standard upgrade path working while a pre-move daemon is still running: the self-update coordinator's fence persist and worker auth both need to read the old daemon's record, and without the fallback a routine upgrade would drain and fence the old daemon, then abort — wedging it until manual restart. The fallback is read-only, exists only when no explicit registry dir or env override is set, and is marked for removal next release. Real socket contention is still resolved by the version-independent socket-path lease at bind. Stale records in the old location are inert and the OS cleaner removes them within 3 days.

Also in this PR

  • Shutdown-admission renew loop hardened. Extracting it surfaced a real latent race on main: package-manager-cli and daemon-ps call assertOrRenew directly, bypassing the timer's single-flight promise — so release() could complete while a direct renew sat queued on the registry guard and then resurrect shutdown-admission.json for up to ~5s. The extracted lease-renew loop funnels every caller through one single-flight slot that release genuinely awaits, and re-checks stopped-state first inside the guarded section. Covered by a deterministic race test (guard held, release overtakes, admission provably not resurrected).
  • The two identical "no longer owns its registry entry" messages are disambiguated (never-acquired/released vs record missing/replaced on disk) and carry the socket path, registry dir, and remedy ("restart the daemon — sessions are preserved").

Known follow-up (not this PR)

Acquire only reclaims conflicting dead owners, and nothing prunes the durable dir, so crashed daemons on never-reused socket paths leave inert .owner dirs behind — bounded growth, a natural doctor --fix candidate.

Testing

Registry derivation + env-override precedence; missing-or-mismatched record stays a coded loss with no overwrite; the admission release-race (deterministic, no sleeps); both disambiguated messages. Supervisor lifecycle/regression suites green (4600 singleton, 4603 worker recovery, package self-update, monitor, 4606 update coordinator); npm run check clean.

Net src +71 (+118/−47): the relocation is a one-line derivation change; the rest is the admission-race hardening and the error messages.

Note

Move daemon supervisor registry from $TMPDIR to ~/.prime/supervisor-owners

  • Changes defaultDaemonSupervisorRegistryDir to use ~/.prime/supervisor-owners instead of a path derived from the socket directory, making ownership records durable across temp directory cleans.
  • Adds legacy registry fallback in assertDaemonSupervisorOwnerCurrent and persistDaemonStartupFenceFromOwner so existing records written under the old location are still readable during transition.
  • Introduces RenewableRegistryRecord to manage interval-based lease renewal in DaemonShutdownAdmission with single-flight deduplication and stop fencing.
  • Enriches DaemonSupervisorOwnershipLostError with socket and registry path context to aid recovery.
  • Behavioral Change: the default registry directory changes on upgrade; daemons started before the upgrade will be found via the legacy fallback (read-only) until they restart.

Macroscope summarized 15c7470.


Note

Medium Risk
Changes durable daemon authority state location and cross-version ownership/fence behavior; incorrect legacy fallback could mis-validate owners, though changes are guarded and heavily tested.

Overview
Fixes long-lived macOS daemons wedging when supervisor authority records (ownership, startup fences, shutdown admission) were stored under $TMPDIR and reaped after ~3 days. The default registry path is now ~/.prime/supervisor-owners (env override unchanged); sockets stay short-path under temp.

Version overlap: read-only legacy registry fallback under the old socket-relative dir for owner checks and startup-fence persistence while pre-move daemons are still running—no migration of records; explicit registry args do not fall back.

Shutdown admission: lease renewal is centralized in RenewableRegistryRecord (single-flight renew, stop() awaits in-flight work, no rewrite after release) so release() cannot resurrect shutdown-admission.json when a direct assertOrRenew was queued on the registry lock.

Errors: ownership-loss messages include socket/registry paths and recovery hint; never acquired vs record missing/replaced on disk are distinguished in the supervisor.

Reviewed by Cursor Bugbot for commit 15c7470. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts Outdated
snimu added 4 commits August 16, 2026 18:01
…mission

RenewableRegistryRecord owns the shutdown-admission lease-renew loop: the
unref()'d interval, a single-flight refresh shared by timer-fired and
direct assertOrRenew calls (whose rejection reaches direct awaiters), a
stopped/lost re-check inside the guarded section so a renew that loses a
race with stop() can never write, and disposal. This replaces
DaemonShutdownAdmission's hand-rolled loop, which had a latent race:
direct assertOrRenew calls bypassed the single-flight slot, so release()
could return while a renew was still queued on the registry guard and the
record could be rewritten after removal. Public semantics (assertOrRenew,
release awaiting in-flight refresh, lease timings) are unchanged.
macOS com.apple.bsd.dirhelper deletes files older than three days under
$TMPDIR daily at 03:35. The supervisor ownership registry (owner.json/
scope.json, startup fences, the shutdown admission) lived there only
because it rode along with the socket directory, whose location is forced
by the 104-byte sun_path limit — a constraint JSON records do not share.
Any supervisor alive past three days lost its record and wedged
permanently with supervisor_generation_stale on every command.

The registry now defaults to ~/.prime/supervisor-owners: durable, global
per user (ownerConflicts must see every daemon on the box, so it must not
shard per agent dir), with the PRIME_AGENT_INTERNAL_DAEMON_SUPERVISOR_
REGISTRY_DIR override unchanged. All registry tenants move together.

No record migration: records are per-process-lifetime. A running old-build
daemon keeps its tmpdir records and works until restarted; its leftover
records self-clean via dirhelper within three days. During the overlap a
new-build daemon sees an empty registry and fails at socket bind instead,
which surfaces like any bind conflict and unwinds acquire cleanly.
Both daemon-supervisor.ts (this.ownership undefined: never acquired or
already released) and DaemonSupervisorOwnershipLostError (record on disk
missing or replaced) emitted the identical 'no longer owns its registry
entry' string, making the failure mode impossible to tell apart from the
message alone. Each message is now distinct and appends the socket path,
the registry dir where available, and the remedy (restart the daemon;
sessions are preserved). Both keep code: supervisor_generation_stale.
Pin the RenewableRegistryRecord hardening at its consumer: a direct
assertOrRenew queued behind a held registry guard while release() runs
must reject with the admission-lost error and must not rewrite
shutdown-admission.json after removal.
@snimu
snimu force-pushed the feat/daemon-ownership-renewal branch from fe42f56 to 4c28e22 Compare August 16, 2026 16:19
@snimu snimu changed the title fix(daemon): renew supervisor ownership records and survive the $TMPDIR reaper fix(daemon): move supervisor authority records out of $TMPDIR Aug 16, 2026
snimu added 2 commits August 16, 2026 18:49
A new-build CLI could not see a still-running pre-move daemon's owner
record: persistDaemonStartupFenceFromOwner scanned only the new (empty)
registry and threw AFTER prepare_update_restart had already drained and
fenced the old supervisor, whose updateRestartPhase never leaves
'prepared' without a shutdown — wedging the standard upgrade path.
The worker-auth validation (assertDaemonSupervisorOwnerCurrent) had the
same blind spot for a new-build worker under an old-build supervisor.

Owner-record READS now fall back to the legacy tmpdir location
(read-only, no abandoned-dir reclaim — old-build daemons own that
location's lifecycle; unlocked relative to old-build writers, acceptable
because records are rename-atomic). Writes, including fences, go only to
the new registry. The fallback is resolved structurally where registryDir
is resolved: it exists iff no env override and no explicit registryDir,
so tests with explicit registries can never leak reads to the machine's
real tmpdir. Remove after one release.

acquire's conflict scan keeps no fallback: real socket contention is
still caught at bind by the socket lease.
…nce matches

Add the missing test for assertDaemonSupervisorOwnerCurrent's legacy
read (a pre-move owner claim validates through an injected legacy dir and
never falls back for an explicit registry), replace the silent
processStartId early-out in the fence-via-legacy test with a visible
assertion, and filter legacy fence matches by the caller-held token/pid
so stale legacy leftovers cannot produce a spurious multiple-owners
failure.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 15c7470. Configure here.

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