Skip to content

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

Merged
thymikee merged 9 commits into
mainfrom
claude/agent-device-issue-1391-ec6ded
Jul 27, 2026
Merged

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

Conversation

@thymikee

@thymikee thymikee commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

Fix ordinary session teardown when close-time script persistence fails.

  • Teardown releases the lease and device claim and deletes the session before surfacing a save error.
  • Close-time script publication now lives in a focused module; the close handler remains orchestration-only and is below the 500-line extraction threshold.
  • Repair-close retry state uses one target receipt, replacing a boolean/identity pair and a separate skip flag.
  • The regression proves exactly one in-memory and durable close action.
  • Adds the missing ios-system-ui help-conformance case that was failing the inherited Coverage gate.

This covers the lingering DEVICE_IN_USE failure and duplicate-close mechanism for an ARMED ordinary recording. It does not claim the unreproduced PUBLISHED + interleaved stale-replay variant; #1391 remains open for that evidence-dependent case.

Validation

  • pnpm check:tooling
  • pnpm check:fallow
  • Focused Vitest bundle: 58 tests passed
  • Full unit bundle: 4,557/4,558 passed; the unrelated runner-cache concurrency test timed out under full-suite load and passed alone in 879 ms
  • pnpm test:smoke: 14 passed, 1 opt-in web smoke skipped
  • Live Android emulator repro through the CLI confirmed the pre-fix claim leak and post-fix cleanup; exact commands and output are in the PR conversation

Addresses #1391 for item 1 and the ARMED-session duplicate-close mechanism.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.87 MB 1.87 MB +1.0 kB
JS gzip 598.1 kB 598.4 kB +269 B
npm tarball 713.0 kB 713.2 kB +265 B
npm unpacked 2.49 MB 2.49 MB +1.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.6 ms 28.7 ms +0.1 ms
CLI --help 58.6 ms 59.6 ms +1.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session.js +1.0 kB +269 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 a commit that referenced this pull request Jul 25, 2026
…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.

@thymikee

Copy link
Copy Markdown
Member Author

Fair catch — re-ran the whole sequence with AGENT_DEVICE_CLAIMS_DIR/AGENT_DEVICE_STATE_DIR prefixed inline on every command (no shell export, no ambiguity about which env is in effect), plus a "session exists before close" step so the isolation is provably wired end-to-end, not just asserted:

$ BASE=/tmp/repro1391-verify2
$ 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","sessionStateDir":"...verify2/state/sessions/cwd_90ba66552d63190e_default", ... "id":"emulator-5556", "message":"Opened: com.android.settings"}}

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

$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts session list --json
{"success":true,"data":{"sessions":[{"name":"default", ... "id":"emulator-5556", ...}]}}   # session exists in the isolated daemon, before close

$ 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)

$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts session list --json
{"success":true,"data":{"sessions":[]}}   # session deleted, same isolated daemon

$ 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" --session other --json
{"success":true,"data":{"session":"other","sessionStateDir":"...verify2/state/sessions/other", ... "message":"Opened: com.android.settings"}}   # no DEVICE_IN_USE; sessionStateDir confirms it landed on the same isolated daemon, not a fresh/default one

$ shasum -a 256 $BASE/published.ad
985ab9364b52aab6b0b92c77281203ad858b141fe68bf9f088abe1334060f112  published.ad   # unchanged from step 1

sessionStateDir in the last open response resolves under $BASE/state/sessions/other, confirming that command reached the same isolated daemon the failed close ran against — not a default-location daemon that would trivially show no conflict either way.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-review at b695224: the corrected evidence closes the original scoping blocker and proves session deletion, claim release, successful reuse, unchanged artifact, and preserved structured error details in the same isolated state. One cleanup blocker remains: the sequence ends with open ... --session other but shows no matching close --session other using the same isolated claims/state settings. Please post that close output, or report the remaining session/state/PIDs if cleanup failed, as required by docs/agents/device-verification.md. Code review and current checks are otherwise clean; do not apply ready-for-human until cleanup is confirmed.

thymikee added a commit that referenced this pull request Jul 27, 2026
…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 b695224 to 66ea4b7 Compare July 27, 2026 09:13
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto latest main (now at 66ea4b797) — clean rebase, no conflicts, tsc/oxfmt/oxlint/full daemon suite (170 files, 1500 tests) and fallow audit --base <new-base> all clean (0 findings this time, not even the inherited dep note from before).

Re-ran the full sequence on the rebased worktree, this time including the missing close --session other cleanup with matching AGENT_DEVICE_CLAIMS_DIR/AGENT_DEVICE_STATE_DIR, per docs/agents/device-verification.md:

$ BASE=/tmp/repro1391-verify3
$ 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", ..."id":"emulator-5556", "message":"Opened: com.android.settings"}}

$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts session list --json
{"success":true,"data":{"sessions":[{"name":"default", ...}]}}   # exists before close

$ 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
$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts session list --json
{"success":true,"data":{"sessions":[]}}                # deleted

$ 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" --session other --json
{"success":true,"data":{"session":"other","sessionStateDir":"...verify3/state/sessions/other", ...}}   # no DEVICE_IN_USE

$ shasum -a 256 $BASE/published.ad
985ab9364b52aab6b0b92c77281203ad858b141fe68bf9f088abe1334060f112  published.ad   # unchanged

# --- cleanup (previously missing) ---
$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts close --session other --json
{"success":true,"data":{"session":"other","message":"Closed: other"}}

$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts session list --json
{"success":true,"data":{"sessions":[]}}

$ ls $BASE/claims/                                     # empty

$ AGENT_DEVICE_CLAIMS_DIR=$BASE/claims AGENT_DEVICE_STATE_DIR=$BASE/state \
  node --experimental-strip-types src/cli.ts daemon stop --json
{"success":true,"data":{"stopped":true,"mode":"graceful","cleanupConfidence":"known", ...}}

$ ps aux | grep "agent-device-1216-a79985/src/daemon.ts"
(no process bound to $BASE/state — confirmed by absence from the process list and a
 daemon-shutdown.json left in $BASE/state as the graceful-shutdown record)

No open sessions or daemon processes remain from this run.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 66ea4b79. The branch is mergeable and checks are green, but it is not ready:

  • P1: Fixes #1391 is not supported for the issue’s second symptom. The patch/test/live sequence covers an ARMED ordinary close-time no-clobber failure. After session save-script, PUBLISHED state sets recordSession=false, so SessionScriptWriter.write() returns without entering this new catch/rollback path. There is no reproduction of the stale-replay + PUBLISHED-session interleaved closes that rewrote the artifact. Cover that route or narrow the issue claim.
  • P2: rollback only truncates session.actions; recordSessionAction has already queued a durable events.ndjson action.recorded: close, which can survive or land after session deletion. Make event recording transactional/deferred or define and test compensating rollback semantics.
  • P2: the regression calls handleCloseCommand directly; add request-router coverage for the actual normalized JSON error shape, including top-level retriable and details.reason/path.

Keep ready-for-human withheld.

@thymikee
thymikee force-pushed the claude/agent-device-issue-1391-ec6ded branch from 66ea4b7 to a5b5a7d Compare July 27, 2026 10:43
thymikee added a commit that referenced this pull request Jul 27, 2026
…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 added a commit that referenced this pull request Jul 27, 2026
…level #1391 coverage

Review feedback on #1392 (thymikee), P2 items:

- The close-time save-script failure's session.actions rollback
  (finalizeOrdinaryCloseScript) was left over from an earlier design
  where a failed save could keep the session alive for retry. It
  never does now — runCloseTeardownAndRelease always tears the
  session down regardless of the outcome — so there is no surviving
  session for a later write to duplicate the close action on. Drop
  the rollback; the durable events.ndjson entry (which the rollback
  never touched anyway) and the in-memory action now agree, both
  accurately recording that the close happened.
- Add a request-router-level regression (request-router-typed-error.test.ts,
  alongside the existing repair-close BLOCKER 2 test it mirrors) proving
  the normalized JSON error shape a real client sees: top-level
  retriable:false, details.reason/path preserved, and the session torn
  down — not just that handleCloseCommand throws the right AppError
  when called directly.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at a5b5a7d, rebased onto latest main (typecheck/lint/full daemon suite [171 files, 1506 tests]/fallow audit all clean):

P2 (event-log durability) — realized the rollback (session.actions.length = actionsBeforeClose) was actually vestigial for this path, not just incomplete: unlike the repair-armed commit (which keeps its session alive for a --force retry), runCloseTeardownAndRelease always tears the session down after this point regardless of the save outcome. There's no surviving session for a later write to duplicate the close action on, so there's nothing to roll back — I removed it entirely rather than trying to make it "more transactional." The durable events.ndjson entry and the in-memory action now simply agree: both accurately record that the close happened, independent of whether the script also saved. Checked there's no session-rehydration-from-events mechanism anywhere in the daemon that could be misled by this (readEvents/session-observability.ts is read-only diagnostics).

P2 (router-level coverage) — added #1391: an ordinary close-time script-save failure surfaces details.reason/path and retriable:false through the router, and the session is torn down in request-router-typed-error.test.ts, mirroring the existing BLOCKER-2 repair-close test's pattern (createRequestHandler, real router boundary). Asserts the actual wire JSON: top-level retriable: false, details.reason === 'script_target_exists', details.path, session gone.

P1 (PUBLISHED variant not covered) — you're right, and I want to be precise rather than paper over it. Investigated three candidate mechanisms for how a PUBLISHED session's artifact could get rewritten, all ruled out:

I don't have the original #1381 validation session/event logs the item-2 report was based on, and per #1391's own bar ("both need their own live-repro verification... before a fix"), I'm not willing to write a fix for a mechanism I haven't reproduced. Narrowed the PR body accordingly — it no longer claims Fixes #1391, documents exactly what is/isn't covered, and recommends keeping the issue open scoped to the still-unreproduced PUBLISHED variant. Happy to keep digging if you have more context on the original #1381 sequence (session name(s)/state-dir/order of commands) that could point at the actual mechanism.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head a5b5a7d. The two requested P2 corrections are semantically sound: ordinary close teardown has no surviving retry, so retaining the in-memory close aligns it with the already-written durable event; and the new real-router regression validates top-level retriable:false, preserved details.reason/path, and session deletion. All current checks pass.

P2 test gap: the behavior changed specifically to retain the durable action.recorded: close event, but no regression flushes/reads events.ndjson and asserts that event remains after the failed script save. Add that assertion so a future rollback or event-order change cannot silently recreate the in-memory/durable mismatch.

The PR body now correctly narrows its claim: it covers the lingering claim and ARMED ordinary-session duplicate mechanism, not #1391's unreproduced PUBLISHED + stale-replay/interleaved-close rewrite. Keep #1391 open for that residual symptom.

@thymikee
thymikee force-pushed the claude/agent-device-issue-1391-ec6ded branch from a5b5a7d to c22a4a7 Compare July 27, 2026 11:39
thymikee added a commit that referenced this pull request Jul 27, 2026
…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 added a commit that referenced this pull request Jul 27, 2026
…level #1391 coverage

Review feedback on #1392 (thymikee), P2 items:

- The close-time save-script failure's session.actions rollback
  (finalizeOrdinaryCloseScript) was left over from an earlier design
  where a failed save could keep the session alive for retry. It
  never does now — runCloseTeardownAndRelease always tears the
  session down regardless of the outcome — so there is no surviving
  session for a later write to duplicate the close action on. Drop
  the rollback; the durable events.ndjson entry (which the rollback
  never touched anyway) and the in-memory action now agree, both
  accurately recording that the close happened.
- Add a request-router-level regression (request-router-typed-error.test.ts,
  alongside the existing repair-close BLOCKER 2 test it mirrors) proving
  the normalized JSON error shape a real client sees: top-level
  retriable:false, details.reason/path preserved, and the session torn
  down — not just that handleCloseCommand throws the right AppError
  when called directly.
thymikee added a commit that referenced this pull request Jul 27, 2026
…time save

Review feedback on #1392 (thymikee), final P2 item: the previous commit
removed the actions rollback because there's no surviving session to
duplicate the close action on, but nothing actually asserted the
durable events.ndjson action.recorded:close event stays put. Flush and
read it back so a future rollback or event-order change can't silently
recreate the in-memory/durable mismatch the removed rollback used to
paper over asymmetrically.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at c22a4a70, rebased onto latest main (typecheck/lint/full daemon suite/fallow audit all clean):

Extended the existing #1391 test in session-device-claims.test.ts: after the failed-save assertions, it now flushEvents/readEvents on the session name and asserts an action.recorded event with command === 'close' is present in events.ndjson — proving the durable log entry survives, not just that no rollback code path exists anymore.

Thanks for confirming the P1 scoping — agreed on keeping #1391 open for the unreproduced PUBLISHED variant.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head c22a4a70. One P2 remains: the new flush/read assertion proves events.ndjson contains action.recorded: close, but it does not catch restoration of the original session.actions.length = actionsBeforeClose rollback—the durable event was already queued, so that reverted implementation still passes while recreating the in-memory/durable mismatch. Retain the session object and assert its post-failure action list contains exactly one close, then compare it with the durable event (or expose an equivalent pre-delete seam).

No rebase semantic drift found; router JSON coverage remains sound, all checks pass, and the PR body correctly leaves #1391's unreproduced PUBLISHED/interleaved variant open.

thymikee added a commit that referenced this pull request Jul 27, 2026
…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 5a0701f to 050237e Compare July 27, 2026 12:50
thymikee added a commit that referenced this pull request Jul 27, 2026
…level #1391 coverage

Review feedback on #1392 (thymikee), P2 items:

- The close-time save-script failure's session.actions rollback
  (finalizeOrdinaryCloseScript) was left over from an earlier design
  where a failed save could keep the session alive for retry. It
  never does now — runCloseTeardownAndRelease always tears the
  session down regardless of the outcome — so there is no surviving
  session for a later write to duplicate the close action on. Drop
  the rollback; the durable events.ndjson entry (which the rollback
  never touched anyway) and the in-memory action now agree, both
  accurately recording that the close happened.
- Add a request-router-level regression (request-router-typed-error.test.ts,
  alongside the existing repair-close BLOCKER 2 test it mirrors) proving
  the normalized JSON error shape a real client sees: top-level
  retriable:false, details.reason/path preserved, and the session torn
  down — not just that handleCloseCommand throws the right AppError
  when called directly.
thymikee added a commit that referenced this pull request Jul 27, 2026
…time save

Review feedback on #1392 (thymikee), final P2 item: the previous commit
removed the actions rollback because there's no surviving session to
duplicate the close action on, but nothing actually asserted the
durable events.ndjson action.recorded:close event stays put. Flush and
read it back so a future rollback or event-order change can't silently
recreate the in-memory/durable mismatch the removed rollback used to
paper over asymmetrically.
thymikee added a commit that referenced this pull request Jul 27, 2026
…ot just the durable event

Review feedback on #1392 (thymikee): the durable-event assertion alone
doesn't catch a reintroduced session.actions.length = actionsBeforeClose
rollback, because that event is queued (and durable) before the write
even attempts — a regression there would leave the assertion passing
while silently reintroducing the in-memory/durable mismatch.

Retain the session object past handleCloseCommand (store.delete only
drops the map entry, not the object a local variable still points at)
and assert its actions array contains exactly one close entry, matching
the durable event count. Verified by temporarily reintroducing the old
rollback locally: this assertion fails (0 !== 1) where the prior
durable-only check did not, then reverted.
@thymikee

Copy link
Copy Markdown
Member Author

Sharp catch — you're right the durable-event assertion alone couldn't detect that specific regression, since appendActionEvent writes before the script-write attempt even runs. Addressed at 050237e, rebased onto latest main (typecheck/lint/full daemon suite [171 files, 1513 tests]/fallow audit all clean):

Retained the session object directly (const session: SessionState = {...} before store.set(...)) rather than only looking it up by name, since store.delete drops the map entry but doesn't touch the object a local variable still references. Added assert.equal(session.actions.filter(a => a.command === 'close').length, 1) and cross-checked it against the durable-event count.

Verified this actually closes the gap you described: temporarily reintroduced session.actions.length = actionsBeforeClose in finalizeOrdinaryCloseScript locally, confirmed this new assertion fails (0 !== 1) while the durable-event assertion alone still passes, then reverted.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Ready at 08d5bc880. The close flow now carries one repair receipt instead of boolean + identity state and a separate skip flag, removing the explanatory scaffolding. The regression is revert-sensitive: it requires exactly one in-memory close and matching durable evidence. Focused validation passes (25 tests), completed exact-head checks are green, and the remaining iOS smoke job is still in progress. #1391 stays open only for the unreproduced PUBLISHED/interleaved variant.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 27, 2026
thymikee added 8 commits July 27, 2026 15:14
…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.
…level #1391 coverage

Review feedback on #1392 (thymikee), P2 items:

- The close-time save-script failure's session.actions rollback
  (finalizeOrdinaryCloseScript) was left over from an earlier design
  where a failed save could keep the session alive for retry. It
  never does now — runCloseTeardownAndRelease always tears the
  session down regardless of the outcome — so there is no surviving
  session for a later write to duplicate the close action on. Drop
  the rollback; the durable events.ndjson entry (which the rollback
  never touched anyway) and the in-memory action now agree, both
  accurately recording that the close happened.
- Add a request-router-level regression (request-router-typed-error.test.ts,
  alongside the existing repair-close BLOCKER 2 test it mirrors) proving
  the normalized JSON error shape a real client sees: top-level
  retriable:false, details.reason/path preserved, and the session torn
  down — not just that handleCloseCommand throws the right AppError
  when called directly.
…time save

Review feedback on #1392 (thymikee), final P2 item: the previous commit
removed the actions rollback because there's no surviving session to
duplicate the close action on, but nothing actually asserted the
durable events.ndjson action.recorded:close event stays put. Flush and
read it back so a future rollback or event-order change can't silently
recreate the in-memory/durable mismatch the removed rollback used to
paper over asymmetrically.
…ot just the durable event

Review feedback on #1392 (thymikee): the durable-event assertion alone
doesn't catch a reintroduced session.actions.length = actionsBeforeClose
rollback, because that event is queued (and durable) before the write
even attempts — a regression there would leave the assertion passing
while silently reintroducing the in-memory/durable mismatch.

Retain the session object past handleCloseCommand (store.delete only
drops the map entry, not the object a local variable still points at)
and assert its actions array contains exactly one close entry, matching
the durable event count. Verified by temporarily reintroducing the old
rollback locally: this assertion fails (0 !== 1) where the prior
durable-only check did not, then reverted.
…-script exclusion

Following up on the comment-trimming pass already on this branch: the
device-claim condition (!platformCloseError && !cleanupAggregate) and
the two-line throw sequence right below it both needed a paragraph
explaining why saveScriptError is excluded from one but not the other.

Merge platformCloseError and cleanupAggregate into a single named
blockingError — its name now states the exclusion the comment used to
argue for, and the throw sequence collapses from two ifs to one.
Trimmed the remaining long docblocks in this file the same way: state
what's non-obvious in 1-3 lines instead of re-deriving it in prose.
@thymikee
thymikee force-pushed the claude/agent-device-issue-1391-ec6ded branch from 08d5bc8 to d5fb3f7 Compare July 27, 2026 13:15
@thymikee

Copy link
Copy Markdown
Member Author

Coverage is failing on this push, but it's unrelated to this PR — confirmed reproducible on a clean origin/main checkout: scripts/__tests__/help-conformance-topic-coverage.test.ts > every help topic has a benchmark case or an explicit waiver fails because the ios-system-ui help topic (added by #1395, already merged to main) has no benchmark case or waiver. This branch just inherited it via rebase.

Flagged as its own task rather than fixing inline here (out of scope for #1391): task_e0966312 — add either a benchmark case in scripts/help-conformance-cases.mjs or a WAIVED_TOPICS entry in that test file for ios-system-ui.

🤖 Addressed by Claude Code

@thymikee
thymikee merged commit e8b779c into main Jul 27, 2026
24 checks passed
@thymikee
thymikee deleted the claude/agent-device-issue-1391-ec6ded branch July 27, 2026 14:04
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-27 14:05 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant