Skip to content

fix(daemon): keep close-time script-save failures from leaking the session/device claim#1392

Open
thymikee wants to merge 3 commits into
mainfrom
claude/agent-device-issue-1391-ec6ded
Open

fix(daemon): keep close-time script-save failures from leaking the session/device claim#1392
thymikee wants to merge 3 commits into
mainfrom
claude/agent-device-issue-1391-ec6ded

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

  • Split out of Live CLI replay handoff: session vanishes immediately after a successful replay (no close) #1384 per the fix(daemon): keep an active replay session's daemon alive over the CLI path #1390 review: two symptoms recorded in Live CLI replay handoff: session vanishes immediately after a successful replay (no close) #1384's original report — a lingering DEVICE_IN_USE claim right after close, and a published .ad rewritten with duplicated trailing close lines under interleaved closes — needed their own live-repro verification (real daemon over CLI, not the in-process provider harness) before a fix. This does that verification and fixes the shared root cause.
  • Root cause: in handleCloseCommand's ordinary (non-repair) teardown path, the close-time script write (sessionStore.writeSessionLog, triggered implicitly by open --save-script, or by this close's own --save-script) can throw an uncaught AppError — most commonly a no-clobber refusal when the target file already exists. That throw propagated straight out of handleCloseCommand, skipping lease release, device-claim release, and sessionStore.delete() entirely, while the close action had already been appended to the session's history with no rollback.
  • This single gap explains both symptoms:
    1. Lingering DEVICE_IN_USE: the session and its device claim survive the failed close, so the next command targeting that device sees it as still owned by the stale session.
    2. Duplicate close lines / rewritten artifact: retrying the close (e.g. with --force) re-records another close action on top of the one never rolled back from the failed attempt, then successfully overwrites the target with both — a script with two trailing close lines.
  • Live-repro'd against a real Android emulator over the CLI: open --save-script=<existing-file> then close (or close --save-script) reliably reproduced both symptoms on main; verified they're gone after the fix.

Fix

  • runSessionCloseTeardown now catches the ordinary-session script-write failure, rolls back the just-recorded close action (mirroring the existing repair-armed commitRepairBeforeClose rollback-on-failure pattern), and returns it separately from platformCloseError instead of letting it escape uncaught.
  • handleCloseCommand lets teardown (lease release, device-claim clear, session delete) complete unconditionally on a script-save failure — exactly as an ordinary platform-close failure already doesn't block them — then surfaces the failure to the caller after teardown, via a new toOrdinaryCloseSaveScriptFailure wrapper with a corrected message/hint: the shared publishHealedScriptAtomically wording ("retry close --save-script") describes the repair-commit retry contract, which no longer applies here since the session is already gone by the time the agent sees the error.

Test plan

  • New regression test in src/daemon/handlers/__tests__/session-device-claims.test.ts (#1391: a close-time script save failure still clears the advisory claim and deletes the session): a plain close against a session armed with a save-script path that already exists on disk throws the corrected AppError, but still clears the advisory device claim, deletes the session, and leaves the pre-existing target file untouched.
  • Live-repro'd against a real Android emulator over the CLI (not the in-process harness): confirmed the bug on main (session/claim lingers after a failed close --save-script; a retried close duplicates the trailing close line), then confirmed both are fixed.
  • Full unit suite: 482 files / 4162 tests pass (npx vitest run --project unit-core).
  • tsc --noEmit, oxlint, and oxfmt --check all clean.

Fixes #1391

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.84 MB 1.84 MB +1.2 kB
JS gzip 587.5 kB 587.8 kB +296 B
npm tarball 701.9 kB 702.2 kB +296 B
npm unpacked 2.46 MB 2.46 MB +1.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.9 ms 30.7 ms +1.8 ms
CLI --help 61.0 ms 65.9 ms +4.9 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session.js +1.2 kB +296 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head d9bfa714. Not ready.

P2 — preserve the structured save failure when wrapping it after teardown. writeRecordedSessionScript raises an AppError with machine-readable details.reason = "script_target_exists" and details.path, but toOrdinaryCloseSaveScriptFailure rebuilds the error from only its message and drops those details (as well as any original diagnostic/log path). Keep the original structured fields while overriding only the close-specific hint/retriable behavior, and assert the routed close response still carries the save reason/path.

The teardown/rollback path itself looks sound. The claimed Android live repro is still not independently verifiable: it provides no exact commands/output, claim state, or before/after artifact hashes, which are the evidence requested in #1391.

thymikee added 3 commits July 25, 2026 12:33
…ssion/device claim

A close-time script write (implicit from `open --save-script`, or this
close's own `--save-script`) that refuses to publish (e.g. a no-clobber
target-exists AppError) threw uncaught out of `handleCloseCommand`,
skipping lease release, device-claim release, and `sessionStore.delete`
entirely — while the `close` action had already been recorded with no
rollback.

Live-repro'd over the real CLI against an Android emulator: this single
gap explained both symptoms split out of #1384 into #1391 — a lingering
`DEVICE_IN_USE` claim after a failed `close`, and a published `.ad`
rewritten with duplicated trailing `close` lines when the same close was
retried (each attempt re-recorded a `close` action on top of the one
never rolled back from the prior failure).

Catch the write failure, roll back the just-recorded `close` action
(mirroring the existing repair-armed commit-failure pattern), and let
teardown (lease release, device-claim clear, session delete) complete
regardless — exactly as an ordinary platform-close failure already
doesn't block them. The failure is still surfaced to the caller, but
after teardown, with a corrected hint: retrying the same close is no
longer meaningful since the session is now gone.

Fixes #1391
…nder fallow's complexity gate

CI's fallow code-quality check flagged handleCloseCommand (126 lines,
19 cyclomatic / 16 cognitive) and runSessionCloseTeardown (73 lines) as
exceeding the large-function/high-complexity thresholds after the
prior commit's fix.

Extract runCloseTeardownAndRelease (teardown + lease release + claim
clear + delete + ordered error surfacing) and buildCloseSuccessResponse
(final response shaping) out of handleCloseCommand, and
finalizeOrdinaryCloseScript out of runSessionCloseTeardown. No behavior
change — same control flow, split into named, independently-readable
steps; fallow now reports 0 complexity findings for this diff.
…se-time save failure

Review feedback on #1392 (thymikee): toOrdinaryCloseSaveScriptFailure
rebuilt the AppError from only the original message, dropping its
machine-readable details.reason ("script_target_exists"), details.path,
and cause. A caller dispatching on those fields (or reading the CLI's
--json error.details) lost them even though the underlying write
failure carried them.

Preserve the original error's details/cause, overriding only the
close-specific hint and retriable:false. Extends the #1391 regression
test to assert the routed close response still carries reason/path.
@thymikee
thymikee force-pushed the claude/agent-device-issue-1391-ec6ded branch from d9bfa71 to b695224 Compare July 25, 2026 10:34
@thymikee

Copy link
Copy Markdown
Member Author

Addressed both points at b695224:

P2 (structured details dropped)toOrdinaryCloseSaveScriptFailure now preserves the original write error's details (and cause) via { ...error.details, ...overrides }, overriding only hint/retriable. Extended the #1391 regression test to assert error.details.reason === 'script_target_exists' and error.details.path === targetPath survive the wrap.

Live-repro verifiability — re-ran end-to-end against a real Android emulator (Pixel 9 Pro XL, emulator-5556) with isolated AGENT_DEVICE_CLAIMS_DIR/AGENT_DEVICE_STATE_DIR so the evidence isn't polluted by ambient claims from other worktrees on the same machine:

$ echo "already-published-content" > $BASE/published.ad
$ shasum -a 256 $BASE/published.ad
985ab9364b52aab6b0b92c77281203ad858b141fe68bf9f088abe1334060f112  published.ad

$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts open com.android.settings \
  --device "Pixel 9 Pro XL" --save-script=$BASE/published.ad --json
{"success":true,"data":{"session":"default", ... "message":"Opened: com.android.settings"}}

$ cat $BASE/claims/*.json
{"schemaVersion":1,"deviceKey":"local:android:none:emulator-5556", ...,
 "session":"cwd:90ba66552d63190e:default", ...}

$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts close --json
{"success":false,"error":{"code":"COMMAND_FAILED",
  "message":"The session was closed, but its script was not saved: A file already exists at .../published.ad; ...",
  "hint":"Remove the existing target (or pass --force/--overwrite), then re-record with open --save-script.",
  "retriable":false,
  "details":{"reason":"script_target_exists","path":".../published.ad"}}}

$ ls $BASE/claims/          # empty — device claim released despite the failed save
$ node ... session list --json
{"success":true,"data":{"sessions":[]}}   # session deleted

$ node ... open com.android.settings --device "Pixel 9 Pro XL" --session other --json
{"success":true, ... "message":"Opened: com.android.settings"}   # no DEVICE_IN_USE

$ shasum -a 256 $BASE/published.ad
985ab9364b52aab6b0b92c77281203ad858b141fe68bf9f088abe1334060f112  published.ad   # byte-identical, untouched

Confirmed on main (pre-fix) the equivalent sequence leaves the claim file in place and the session listed as still active after the failed close, matching the two symptoms in #1391.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Review at b695224: the structured-error fix is sound and current checks are green, but the live evidence still has one narrow scoping gap. AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state is set inline only for the initial open and failing close; the subsequent session list --json and open ... --session other omit both variables. Those commands can therefore query the default state/claims rather than the isolated run, so they do not yet prove that the failed close deleted that session or released its device for the next open. Please rerun every command with the same env (or matching explicit --state-dir) and post the outputs. The claim-directory emptiness and unchanged-file hash are already correctly scoped.

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.

Live CLI replay handoff: lingering DEVICE_IN_USE claim and stale-close artifact rewrite (split from #1384)

1 participant