Skip to content

fix(daemon): let a retried open supersede the claim its aborted attempt abandoned - #2105

Merged
thymikee merged 2 commits into
mainfrom
claude/device-claim-replay-timeout-a194a7
Aug 28, 2026
Merged

fix(daemon): let a retried open supersede the claim its aborted attempt abandoned#2105
thymikee merged 2 commits into
mainfrom
claude/device-claim-replay-timeout-a194a7

Conversation

@thymikee

@thymikee thymikee commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

--retries was worthless for a timed-out replay attempt: the failed attempt kept the simulator, so
every retry died at step 1 with ios device <UDID> is owned by session "…:attempt-1".

The claim, not the scheduler, was the hole. An open that fails after preparation deliberately
keeps its claim — the effects it may have started are unproven, and releasing the device would hand
an unknown state to the next session. But nothing recorded that the retained claim binds no session,
so it stayed on disk for the daemon's whole life: no session could release it, no sweep could settle
it (its owner is alive), and every later open of that device failed with DEVICE_IN_USE naming a
session session list does not report and close --session cannot reach. A replay-test retry is
just the loudest victim — a plain agent-device open that fails mid-launch blocked that device until
the daemon was restarted.

A rollback that finds no session owning the claim now abandons it rather than leaving it
untouched:

  • the claim file stays, so the host-global fence is unchanged — every other process still reads a
    live claim and still refuses the device;
  • the daemon that abandoned it supersedes it on its next acquire, because it is the only owner that
    can account for the effects in question (it owns the runner processes involved).

A transient-exclusive command (install, push, prepare, boot, shutdown) treats a claim this
daemon still holds as coverage and adds none of its own. An abandoned claim holds nothing, so it is
superseded into that command's own transient claim instead — otherwise the command would run unowned
and leave the abandoned record behind.

Retention stays exactly where it was earned. session close still retains a claim when teardown
proved a resource is unsettled (device_claim_close_effects_unconfirmed) — that device has known
outstanding work, and the suite's infrastructure stop is the designed response. Abandonment is for
the other case: a claim with nothing left alive to release it.

Before/after, same command, same simulator, same daemon (test --retries 1, attempt 1 timed out
mid-open):

before: ⨯ attempt-2 — Replay failed at step 1 (open "com.apple.Preferences"):
        ios device 524AC9DA… is owned by session "…:1-claim-timeout:attempt-1"
after:  ✓ claim-timeout.ad after 2 attempts (passed attempt 1.27s, total 3.40s)

Reported from the iOS smoke lane on #2100 CI
(run 33091927274, job 98587070677, head 15ec144): attempt-1's request log ends in
device_claim_open_effects_unconfirmed after the runner connect exhausted its retries and the
attempt timeout aborted the open, and the scheduler's cleanupSession was a 0 ms no-op because no
session had ever been stored.

Two new diagnostics: device_claim_open_effects_unconfirmed now carries the abandon outcome, and
device_claim_abandoned_superseded (info) records the takeover with the abandoned session name.

Validation

  • Live iOS simulator, on examples/test-app/replays/gesture-pan-duration.ad itself: dedicated
    throwaway device with the fixture app, real daemon over the repo CLI, host-global claims dir, at
    the shipped head. --timeout 3400 puts the attempt deadline inside step 1's open, which is the
    CI shape. Pre-fix that reproduces the reported failure exactly (… is owned by session "…:1-gesture-pan-duration:attempt-1"); on this head, 3/3 runs have attempt 2 run its own open
    and fail on its own timeout, with device_claim_open_effects_unconfirmed {outcome: "abandoned"}
    followed by device_claim_abandoned_superseded in attempt-2's request log. Full evidence and the
    cleanup record are in a PR comment. Validation simulator deleted, scratch daemon stopped.
  • session-device-claims.test.ts: a canceled attempt-1 through the real open handler and the real
    claim store, then attempt-2 opening the same device. Reverting session-open-execution.ts +
    device-claims.ts makes it fail on retry.ok, with the same DEVICE_IN_USE cause as CI.
  • device-claims.test.ts: abandon → same-daemon supersede (and no orphan reconciliation), abandoned
    claims still fencing a foreign state dir and a foreign owner, and the three abandon outcomes.
  • pnpm check:affected --run green (187 files / 1237 tests), plus check:layering and
    check:fallow.

Notes

Six files, all in the daemon claim lifecycle; no CLI, MCP, or help surface changes. device status
still reports an abandoned claim as live with its original owner session — accurate (it is still
fenced) but not self-explanatory; surfacing the abandoned state there is a follow-up, not a
prerequisite. This does not touch the underlying gesture-pan-duration runner flake that triggered
the CI timeout; it makes the retry a real retry.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.48 MB 2.48 MB +917 B
JS gzip 834.8 kB 835.0 kB +234 B
npm tarball 958.3 kB 958.5 kB +245 B
npm unpacked 3.32 MB 3.32 MB +917 B

npm unpacked components

Component Base Current Diff
JS / dist source 2.64 MB 2.64 MB +917 B
Apple runner source/project 581.1 kB 581.1 kB 0 B
macOS helper source 54.8 kB 54.8 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 45.4 kB 45.4 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.0 ms 28.9 ms -0.1 ms
CLI --help 83.4 ms 83.0 ms -0.4 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/perf-runtime-plan.js +642 B +121 B
dist/src/session2.js +219 B +64 B
dist/src/internal/daemon.js -13 B +18 B
dist/src/session-store.js 0 B -5 B
dist/src/registry.js 0 B +1 B

Top changed packed files

Packed file Base Current Diff
dist/src/perf-runtime-plan.js 63.6 kB 64.2 kB +642 B
dist/src/session2.js 216.0 kB 216.3 kB +219 B
dist/src/device-claim-inspection.js 4.1 kB 4.2 kB +64 B
dist/src/internal/daemon.js 107.3 kB 107.3 kB -13 B
dist/src/runtime.js 64.8 kB 64.8 kB +5 B

@thymikee

Copy link
Copy Markdown
Member Author

Not ready. The open-retry supersession path is sound, but acquireTransientDeviceClaim still treats any same-daemon claim as coverage before resolving abandonment. An abandoned open claim can therefore let transient-exclusive install/push/prepare/shutdown work run without new ownership and leave the abandoned record behind. Exclude abandoned claims from that shortcut, supersede them under the lock into a transient claim, and plant an abandoned→transient acquire/dispose regression while retaining live-session coverage. Remove the new implementation/control-flow narration; exact-head checks were still pending.

@thymikee

thymikee commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

CI green on head e72f02c: 17/17 required checks pass, including the iOS Smoke lane whose Run gesture pan-duration smoke replay step reported this bug. Merge-ready by the readiness split in docs/agents/pull-requests.md — live simulator evidence is in the Validation section.

@thymikee

Copy link
Copy Markdown
Member Author

Live device validation — the reported replay, on the shipped commit

My earlier comment claimed merge-ready on live evidence gathered from a stand-in replay
(open com.apple.Preferences) and gathered before the last two edits in this branch. Redone
properly: examples/test-app/replays/gesture-pan-duration.ad — the replay that reported this — on a
real iOS simulator, real daemon over the repo CLI, host-global claims dir, at head e72f02c.

Setup: dedicated throwaway iPhone 17 Pro / iOS 26.2 (created for this, deleted after), fixture app
installed, isolated AGENT_DEVICE_STATE_DIR. --timeout 3400 puts the attempt deadline inside step
1's open (a hot open takes ~3.6 s), which is the CI shape: the attempt is aborted after
preparation, so the claim is retained rather than released.

Pre-fix (HEAD~1, same command, same device):

⨯ gesture-pan-duration.ad
    failed at: Replay failed at step 1 (open "${APP_TARGET}"): ios device 3D34D358-… is owned by
    session "cwd:…:1-gesture-pan-duration:attempt-1" in workspace "…"

Shipped commit e72f02c, 3/3 runs: attempt 2 runs its own open and fails on its own
TIMEOUT after 3400ms — a real retry, not a claim conflict. attempt-2's request log shows the
handoff:

18:50:15.396  device_claim_open_effects_unconfirmed  {deviceKey: local:apple:ios:3D34D358-…, outcome: "abandoned"}
18:50:15.414  device_claim_abandoned_superseded      {abandonedSession: "…:1-gesture-pan-duration:attempt-1", abandonedAtMs: …}

At --timeout 3000 the pre-fix run did not retry at all: the aborted open unwound past the 2 s
REPLAY_TIMEOUT_CLEANUP_GRACE_MS, so the attempt was marked infrastructure and the suite stopped.
That path is unchanged and still correct — the replay is genuinely still running against the device.

The fixture app available locally is a month-old Release build, so the flow diverges at step 5
(wait text "pan duration >=400ms") on a full run regardless of this change. That does not touch what
is under test here: step 1 open succeeds, and the question is only whether attempt 2 can take the
device. CI's iOS Smoke lane runs the same replay with a current fixture and passes on this head.

Cleanup: validation simulator deleted, scratch daemon stopped, the one stale claim its last attempt
left in ~/.agent-device/device-claims removed; no sessions or devices left open.

…pt abandoned

An `open` that fails after preparation keeps its device claim: the effects it
may have started are unproven, so releasing the device would hand an unknown
state to the next session. Nothing recorded that the claim binds no session,
so it stayed on disk for the daemon's whole life and every later open on that
device failed with DEVICE_IN_USE naming a session that no longer exists. A
`test --retries` run spent its whole retry budget on that conflict.

Rolling an unowned claim back now marks it abandoned instead of leaving it
untouched. Other processes still read a live claim, so the host-global fence
is unchanged; the daemon that abandoned it supersedes it on its next acquire.
…ommand

`acquireTransientDeviceClaim` treated any claim of this daemon as coverage, so
an abandoned open claim let install/push/prepare/shutdown run without taking
ownership and left the abandoned record behind. Only a claim that still holds
the device covers a transient command; an abandoned one is superseded under the
claim lock into the command's own transient claim, which its dispose releases.
@thymikee
thymikee force-pushed the claude/device-claim-replay-timeout-a194a7 branch from e72f02c to 9f7459d Compare August 28, 2026 05:59
@thymikee

Copy link
Copy Markdown
Member Author

Code review is clean at 9f7459d2. The abandoned same-daemon claim is now excluded from coverage and superseded under the existing claim lock; the focused regression is non-vacuous and preserves live-session coverage plus foreign-owner fencing. Current main is disjoint, so no rebase is owed.

This is not merge-ready yet: several exact-head checks remain in progress, and the new transient-exclusive recovery path has focused fixture proof but no exact-head live simulator replay. Attach the smallest practical aborted-open → transient command → claim cleanup evidence before merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 28, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main (2e87347, clean — the host-kit import moves did not collide) and addressed
the review. Head is now 9f7459d.

acquireTransientDeviceClaim no longer takes an abandoned claim as coverage. Only a claim that
still holds the device covers a transient-exclusive command; an abandoned one falls through to
claimHeldDevice under the same claim lock, where the supersession path turns it into the command's
own transient:<command> claim, which its dispose releases. isAbandonedDeviceClaim names the state
once and both call sites compose it, so the two polarities cannot drift apart.

Live proof of the exact hole you named, both sides under a single daemon (a keepalive session on
a second simulator holds it, since the CLI stops a daemon it spawned and each invocation would
otherwise get a fresh pid):

pre-fix  (daemon 35538)  install → "Installed: com.callstack.agentdevicelab"
                         claim after: session=attempt-1 abandoned=True ownerPid=35538   ← ran unowned, record left behind
with fix (daemon 21718)  install → "Installed: com.callstack.agentdevicelab"
                         claim after: none                                              ← superseded into transient:install, released on dispose

Regression: an abandoned claim becomes this command's own transient claim and is released in
device-claim-admission.test.ts — asserts transient:install while admitted and an empty store after
dispose. Reverting just the shortcut condition fails it with [ 'aborted-open' ] vs
[ 'transient:install' ]. The live-session coverage test above it is untouched and still green.

Note that superseding also ends the fence: after the transient command disposes, the device has no
claim at all. That is the intended reading — a completed, owned mutation supersedes the unproven
state the abandonment was preserving — but it is a real semantic, so flag it if you want the fence
retained across a transient takeover instead.

Narration removed: the block comment on rollbackNewSessionClaim, the one on the abandoned-claim
predicate, and the rationale prose on abandonDeviceClaim. What is left is the outcome vocabulary
(mirroring DeviceClaimClearOutcome), a one-line contract on the exported function, the field doc,
and the amended acquireTransientDeviceClaim doc, which had to change because its stated rule did.

CI: 17/17 pass on 9f7459d, iOS Smoke included. Validation simulators deleted, daemon stopped, no
claim files left behind.

@thymikee
thymikee merged commit 5e30451 into main Aug 28, 2026
18 checks passed
@thymikee
thymikee deleted the claude/device-claim-replay-timeout-a194a7 branch August 28, 2026 06:33
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-28 06:34 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