feat(daemon): let a host own a runtime and answer its stop

TH
4m ago
+4063-687

Summary

An embedder that keeps the runtime in its own process must say so where every other process looks: the registration. This adds ownership to the daemon record, makes every client path that could signal or replace a runtime respect it, and gives both stop paths one admission seam. Part of #3747. 43 files.

  • A host-owned runtime stops only by an authenticated, instance-fenced shutdown request, which it answers with its own close() — so host.close() and daemon stop produce identical teardown. Nothing outside signals it: not a managed client, timeout recovery, a forced stop, or --clean (refused with details.reason: daemon_clean_not_detached).
  • Ownership reads three states — detached, embedded, unknown — and every decision that could signal a pid requires a proven detached. A record that is unreadable, or states an owner this build cannot recognize, answers unknown and is refused: retirement, takeover, timeout recovery, --clean, and both lifecycle requests.
  • daemon quiesce (stop-if-idle) and daemon_shutdown (drain) share one request builder, fence and admission check, so one owner cannot grant what the other refuses. The shipped quiesce wire shape is unchanged.
  • A closing runtime publishes one completion record, fenced to its instance (pid plus process start time), so every stop addressed to that instance reads one answer. daemon stop waits on that record plus the instance's lifetime, and reports what the close could not finish as warnings.
  • Scope expansion: a client can pin one instance with daemonBinding: { token, pid, processStartTime } and then dispatches only to it, with its token. That is the issue's requirement 4; it lands here because a host hands the pin back.
const client = createAgentDeviceClient({ stateDir, daemonBinding: hostDaemon });
// every request goes to that pid with its token; a state directory holding any other
// instance, or a record that cannot be read as an address, is refused instead of being
// started or replaced.

daemon status names such a record host-owned, or owner-not-recognized; daemon stop stops it without signaling the host.

Size: 1546 net production lines over 25 files, over the 700 threshold in docs/agents/review.md. Two independent review rounds read this branch; this push answers round two (33 findings), all resolved on the threads.

Layout: the admission seam sits in daemon-idle-reap.ts (same leave judgement, one census), the completion envelope in daemon-shutdown-report.ts (the on-disk shutdown record pair). The eager-closure gate's remedy is a home in an already-evaluated module; renaming the first file is a follow-up (TODO(#3757)).

Reconciled with #3795, which merged onto the same region. One owner per gap: its settleWithinBudget is the only bounded-shutdown helper here (the local copy this branch carried is gone), its openDaemonServers and startup rollback keep the listener-ownership rule, and its pending-platform-owner rule now also decides the completion record — a close that cannot release the holds because a platform owner is still running reports retained, not released, because the registration really is still held. hasUnfinishedWork() covers abandoned requests and pending owners on both exits.

Validation

Base 11de319aad, head 4f68cd7607. pnpm check:affected --run passed there with exactCommit=true: format, lint, typecheck, layering, fallow, build, vitest-related, command-docs, docs-links, docs-prose. Replay and web-smoke lanes are GitHub-authoritative or parked — CI owns them.

Risks: no live device run, only CI lanes. agent-device/host lands next, so embedded is unreachable from a published entrypoint yet.