fix(daemon): keep an active replay session's daemon alive over the CLI path - #1390
Conversation
…I path A `replay <script>.ad` with no terminal `close` reports its session as still active per ADR 0016's consumption contract, but the real CLI client tears down the daemon that ran it (and its owned ephemeral state dir) regardless — the request's own success response gets overwritten seconds later by an empty `session list`. This happens whenever the client started the daemon itself, independent of whether the state dir was randomly generated or passed explicitly via --state-dir/AGENT_DEVICE_STATE_DIR, matching #1384's live repro. Add `sessionActive` to `ReplayCommandResult`, computed from whether the session survives in the daemon's own store (never by re-parsing the script), and gate the client's one-shot teardown on it — mirroring the existing ADR-0012 repair-divergence keep-alive. A kept-alive owned daemon now also attaches a --state-dir address hint to the response so the caller can reach it. `test` is unaffected: its own per-file runner already closes each session before the suite summary is built. Fixes #1384
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Reviewed (I filed #1384 and proposed this exact direction there, so mostly checking the execution — which is faithful: keyed off the session surviving in the daemon's own store rather than script parsing, explicit 1. Side effect worth an explicit decision: a completed repair-armed replay's heal commit is now deferred. A 2. "Fixes #1384" closes three symptoms; this PR fixes one. The issue also records (a) the lingering 3. Hint completeness (minor): the hint says 4. Test gap (minor): nothing proves the kept daemon is actually addressable — a follow-up One corroborating live observation from the #1384 repro that supports the guard's placement (before any stop call, rather than inside the stop path): with an explicit state dir, the daemon process survived the cleanup while its sessions died — i.e. the graceful-stop path tears sessions down first and the process can outlive it when a new request lands mid-shutdown. Guarding before the stop, as done here, sidesteps that whole ordering; no action needed. 🤖 Addressed by Claude Code |
|
Reviewed exact head a7f2e92. Not ready: (1) ADR 0016 still says a close-less replay does not change successful response shape, but this PR adds required public ReplayCommandResult.sessionActive without amending that clause; (2) the MCP replay output schema omits required sessionActive; (3) all new lifecycle tests inject sessionActive through a fake HTTP response, so deleting both real daemon producer lines reintroduces #1384 while the tests still pass—add real native and Maestro replay-route assertions for true on close-less and false after terminal close; (4) the ADR-required live iOS and Android CLI/device evidence is absent; and (5) Lint & Format fails on daemon-client-lifecycle.test.ts. The cleanup guard itself is directionally correct, but these contract, regression, evidence, and CI blockers must be resolved. No ready label. |
- oxfmt --check flagged the new test file; reformatted (CI fix). - Address hint now names --session <name> too, using the response's session verbatim (already the fully-qualified cwd-scoped store key) — a bare --session default only resolves by coincidence from the same cwd, per resolveEffectiveSessionName's explicit-flag bypass. - Add a test closing the loop: a follow-up sendToDaemon using the hinted --state-dir/--session reaches the same kept-alive daemon without spawning a new one. - Pin that a completed (non-diverging) --save-script repair also keeps its daemon alive via the same guard, since its terminal source close is always skipped (ADR 0012 Fix 3) — document the resulting deferred heal-commit timing in ADR 0012.
|
Thanks for the thorough review — addressed in 07c1fdd (plus a7f2e92 fixed the CI format-check failure separately): 1. Deferred repair-commit timing. Confirmed: a completed 2. Scope of "Fixes #1384". Filed #1391 for the two unverified symptoms (lingering 3. Hint completeness. Went further than 4. Addressability test. Added Also re-ran the full affected test set (daemon-client-lifecycle, session-replay-repair-transaction, and the three provider-scenario repair/publication tests) plus |
Fallow's audit gate (new-only findings) flagged the two new active- session tests for exceeding the CRAP threshold. Extract the shared fixture wiring into replayLeavingSessionActive/parseAddressHint helpers (also cutting duplication between the two tests), and drop repeated optional chaining on response.data in favor of a single narrowing assert.ok(data) — same assertions, lower branch count.
…R fix Addresses the second review pass on #1390: - MCP replay output schema (src/mcp/command-output-schemas.ts) omitted the new required sessionActive field entirely; add it. - ADR 0016 still claimed "the absence of close changes ... nor the success response shape", contradicting the new required field this PR adds. Amend it to document the sessionActive contract and why the real CLI/IPC client needs it (issue #1384). - All prior lifecycle tests exercised sessionActive only through a fake HTTP response in the client-layer tests, so deleting either real producer line (session-replay-runtime.ts, session-replay- maestro-response.ts) would not have failed anything. Add tests against the real runReplayScriptFile producer (native .ad close-less -> true, terminal close -> false, Maestro close-less -> true) and strengthen the provider-scenario (real daemon route) test with the same assertion. Verified each new test fails when its corresponding producer line is reverted, then restored. Live-validated the fix on real backends (booted iOS 16 simulator and a running Android Pixel 9 Pro XL emulator), replaying issue #1384's exact repro end to end: the owning daemon and its session both survive a close-less replay and remain fully addressable via the hinted --state-dir/--session on both platforms. That validation surfaced a separate, pre-existing bug -- `session list` (no explicit --session) omits cwd-scoped sessions opened via a replay's internal `open` dispatch, because session-open.ts's resolveImplicitSessionScope(req) sees a different req than the top-level replay request and leaves session.sessionScope unset -- filed as #1394, out of scope here since it is a sessionScope-propagation gap unrelated to the client-side teardown this PR fixes; the session itself is never actually lost.
|
Addressed in 07c1fdd (already pushed) + c1bf116 (just pushed), plus live device validation: 1. ADR 0016 contradicted the new response shape. Fixed: amended the "on consumption" paragraph to document 2. MCP replay output schema omitted required 3. Tests only injected 4. Live iOS/Android evidence. Built the CLI and ran issue #1384's exact repro end-to-end against a real booted iOS 16 simulator and a running Android Pixel 9 Pro XL emulator: Confirmed on both platforms: the daemon process and the session both survive the close-less replay, and the session is fully addressable via the hinted 5. Lint & Format failure. Already fixed at head Re-ran the full affected suite (daemon-client-lifecycle, session-replay-runtime, session-replay-repair-transaction, mcp command-tools, the three provider-scenario repair/publication tests — 95 tests) plus 🤖 Addressed by Claude Code |
|
Re-reviewed exact head P2 — explicit P2 — quote the generated address command. The prior ADR/MCP/producer/lifecycle findings are otherwise resolved, live iOS/Android evidence is present, and all current checks are green. |
…he hint Addresses the third review pass on #1390 (P2 x2): - withActiveSessionAddressHint (renamed from ...IfOwned) no longer suppresses the active-session hint entirely for an explicit --state-dir/AGENT_DEVICE_STATE_DIR caller. The session name is cwd-qualified and, per #1394, `session list` can't rediscover it either, so --session is now hinted regardless of ownedStateDir; --state-dir is only included when the state dir is the client's own randomly-generated one the caller has no other way to learn. - attachActiveSessionAddressHint now shell-quotes (shellQuoteIfNeeded, the same helper session-recovery-hints.ts/request-lock-policy.ts already use) both the state dir and session name, so the hint stays literally copy-pasteable even if either contains spaces or shell metacharacters. Added tests pinning both the unsafe-value quoting and that quoting was actually exercised (not just coincidentally unchanged) -- verified they fail against a raw-interpolation reversion, then restored the fix.
|
Both P2s addressed in ef32057: P2-1: explicit P2-2: quote the generated address command. Fixed — Re-ran the full affected suite (daemon-client-lifecycle now 22 tests, plus session-replay-runtime, session-replay-repair-transaction, mcp command-tools, the three provider-scenario tests — 97 total) plus 🤖 Addressed by Claude Code |
|
Re-reviewed exact head |
|
* origin/main: docs(adr): rules-first ADR restructure + ADR 0017 proposal (unified event journal) (#1399) feat: add first-class Vega VVD TV support (#1396) fix(replay): preserve cwd scope for opened sessions (#1401) docs: restructure AGENTS.md and CONTEXT.md for progressive disclosure (#1402) fix(cli): compact stale device status (#1388) feat: add WebView accessibility lab (#1397) feat: parameterize sensitive recorded inputs (#1369) fix(daemon): keep an active replay session's daemon alive over the CLI path (#1390) # Conflicts: # docs/adr/0012-interactive-replay.md
Summary
replay <script>.adwith no terminalclosereports its session as still active per ADR 0016's consumption contract ("the named session stays active and the successfulReplayCommandResultreturns its session id"), but over the real CLI/IPC path the daemon that ran it is torn down (and its owned ephemeral state dir deleted) regardless — the session vanishes within ~120ms of the successful response, with no close/teardown event logged.cleanupDaemonAfterRequest(src/daemon/client/daemon-client-lifecycle.ts) unconditionally stops the daemon after every one-shotreplay/testrequest whenever the client itself started it (daemon.startedByClient), with no check for whether the response reported the session as still active. This fires regardless of whether the state dir was randomly generated (the common no-flag case) or passed explicitly via--state-dir/AGENT_DEVICE_STATE_DIR— matching the issue's literal repro, which used an explicit state dir.active-session-script-publication.test.ts) never exercises this client/IPC layer at all, which is why it passed while the live CLI path broke.This addresses the "session vanishes after a close-less replay" symptom of #1384 specifically. #1384 also recorded two other, unverified symptoms (a lingering
DEVICE_IN_USEclaim right afterclose, and a one-off published-artifact rewrite under interleaved stale-session closes) — those are split out to #1391 rather than riding along to auto-close here.Fix
Mirrors the existing ADR-0012 repair-divergence keep-alive pattern (same category of problem, same daemon-client layer, same shape of fix):
ReplayCommandResult(src/contracts/replay.ts) gainssessionActive: boolean, computed from whether the session still exists in the daemon's own store after the run — never by re-parsing the script forclose— set in both success-response builders (session-replay-runtime.ts,session-replay-maestro-response.ts).daemon-client-lifecycle.tsaddsisActiveReplaySessionResponse(req, response)and ORs it intocleanupDaemonAfterRequest's early-bailout, so a still-active replay session's daemon/state-dir is never stopped/removed. Restricted toreplayby explicit command check —testalways tears down as before, since its own per-file runner already closes each session before the suite summary is built.daemon-client.tsattaches a--state-dir <dir> --session <name>address hint to a kept-alive owned-ephemeral-daemon's response (structuredhintfield + appended tomessage, sincemessageis the only field the default text renderer surfaces). The session name is the response'ssessionvalue used verbatim — that's already the fully-qualified, cwd-scoped store key, and an explicit--session <value>is used as-is byresolveEffectiveSessionName(skipping cwd-scoping), so a bare--session defaultwould only resolve by coincidence from the same cwd.--save-scriptrepair also falls under this same guard, since its terminal sourcecloseis always skipped by design (ADR 0012 Fix 3) — the healed-script commit (gated on teardown, not on the response) is now deferred to an explicitclose/idle-reap instead of firing immediately after the response. Documented as a consequence in ADR 0012, alongside the plain-replay consequence already documented in ADR 0016.replay's CLI help: an unattended close-less replay (e.g. in CI) now leaks a live daemon+session exactly like an interactively opened one — bounded by ordinary idle-reap or an explicitclose, not by the one-shot command's process lifetime. This is the intended shape of "the caller owns close," not an accident.Test plan
src/utils/__tests__/daemon-client-lifecycle.test.tsat the exact layer the bug evaded (real client/IPC lifecycle, not the in-process harness): active session kept alive + hinted (owned ephemeral dir), closed session still torn down, the issue's literal explicit---state-dirrepro no longer stops the daemon,teststill tears down unconditionally even if its response hypothetically carriedsessionActive, a completed--save-scriptrepair keeps its daemon alive under the same guard, and a follow-upsendToDaemonusing the hinted--state-dir/--sessionreaches the same kept-alive daemon without spawning a new one.active-session-script-publication.test.ts,replay-repair-abort-close.test.ts,replay-repair-record-exclusion.test.ts,session-replay-repair-transaction.test.tsall still pass.tsc --noEmit,oxlint, andoxfmt --checkall clean.Addresses #1384