Skip to content

fix(daemon): scope a transient device claim to the session that holds it - #3714

Merged
thymikee merged 7 commits into
mainfrom
t3/session-device-claims
Oct 17, 2026
Merged

thymikee merged 7 commits into
mainfrom
t3/session-device-claims

Conversation

@thymikee

@thymikee thymikee commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #3345. Another session’s install or reinstall refuses while a session owns the device. Acquisition requires the recorded claim kind to match the acquirer, including its transient command. Legacy kindless records remain fences and cannot donate ownership tokens. Owning-session operations remain valid.

Example: open Lab --session a; install app.apk --session b returns DEVICE_IN_USE, reason DEVICE_CLAIM_LIVE_OWNER, owner a; reinstall Lab app.apk --session a succeeds. The common acquisition boundary, admission, records, tests, help and docs span 18 files.

Validation

Head 00e495278e, base a61556b8: direct pnpm check:affected --run --head 00e495278ec3b3d2b1af932902b8edba115078f1 passed with exactCommit: true, unchanged clean fingerprints, 10 executed checks, seven documented skips and 2,333 passing related tests. Receipt: .tmp/validation/run-v6Vzc8/receipt.json.

Raw daemon/CLI proof covers foreign refusals, owner reinstall, close and no remaining claim. Author audit corrects provenance and the initial wrong test expectation; final settlement tests pass 15/15. Independent review found no blocking issues, with bound-checkout mutation limitations; both checkouts are restored clean.

Maintainer accepts the fix; readiness label is present. Current-head iOS CI failed public-CLI preflight with a 15-second daemon startup timeout and empty daemon log. Cause and branch participation remain unproven. Other checks passed. The requested retry has not run: campaign policy requires proven infrastructure failure. #3782 tracks the startup class.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.34 MB 5.35 MB +1.6 kB
Package (unpacked) 5.34 MB 5.35 MB +1.6 kB
Package (download) 1.61 MB 1.61 MB +373 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 22.8 ms 23.2 ms +0.3 ms
CLI --help 61.8 ms 61.5 ms -0.3 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/daemon-contracts/src/device-claims.ts
Comment thread packages/daemon/src/resources/device/device-claim-admission.ts Outdated
Comment thread src/commands/schema/cli-help.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread website/docs/docs/sessions.md Outdated
Comment thread website/docs/docs/sessions.md Outdated
@github-actions

github-actions Bot commented Oct 16, 2026 •

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

@thymikee

Copy link
Copy Markdown
Member Author

I reviewed d3c79e1 and found two problems that need to be fixed before merge. CI is still pending, with five jobs queued or in progress and no failures yet.

Coverage now needs a second ownership rule in device-claim-admission.ts. The routed session must match, and the root of the claim's recorded workspace must also equal the root of the caller's cwd. This disagrees with session routing, where an explicit --session is named-local and means the same session from every workspace. From worktree B, open, snapshot and click --session a act on session a, but install, reinstall, push and boot --session a refuse with "owned by session a in workspace A". Help says that error is never retriable, and its recovery is to close the owner or stop the daemon. It also compares the root where isCurrentClaimOwner compares the raw workspace. And open records meta.cwd ?? process.cwd(), so a session opened by an SDK or Node client without a cwd records the daemon's cwd. A later request in that same session with a cwd outside that root refuses its own claim. The android-lifecycle push fixture had to move its cwd into the checkout to get past exactly this. Issue #3345 expects "passing the owning --session keeps working" for agents in separate worktrees, and here it fails for them. The rule should be that a transient-exclusive command is covered exactly when the daemon-owned claim belongs to the session that routing resolved for the request. That routed address is the identity every other command already uses. Please remove coveredClaimIsOwnedByRequest, callerCwd and the scope-root comparison, and move resolveCallerScopeRoot back into session routing. If workspace fencing of named sessions is wanted, it belongs in session routing, where it would cover every command and not only transient-exclusive ones.

This change decides which install, reinstall, push, boot and shutdown requests reach a device. The only live evidence is a "parent verified" Android log that is not linked or accessible, and the issue was reported on iOS simulators. docs/agents/pull-requests.md asks for remote-accessible live evidence of the changed route. Please run the issue repro in one daemon on a real iOS simulator or Android emulator at d3c79e1, and post the transcript with a device status dump. It should show open <app> --session a, then install <app> <path> --session b and reinstall ... --session b both failing with DEVICE_IN_USE naming session a, then reinstall ... --session a succeeding. After close --session a, device status should show no claim for the device.

Could the fix be smaller? Pass only the routed sessionName into createDeviceClaimAdmission, and cover when the daemon-owned claim's session equals it. Then make command-scoped claims impossible to match as a session, either with a kind discriminant on the claim record or by reserving the transient: prefix at session-name validation, and have isCurrentClaimOwner exclude them. That would remove the special transient:<command> conflict branch and the name collision in the first thread below, keep the abandon-on-failed-unlink change, and should cut production lines roughly in half. What would need deciding first is whether named-local sessions may be shared across workspaces. If not, that rule belongs in session routing for every command.

Not blocking, and fine to take or leave: once the workspace rule is gone, the android-lifecycle fixture in android-lifecycle.test.ts can go back to tempRoot and drop its 8-line comment.

Open threads that still apply: the transient: name collision at #3714 (comment), the missing-cwd ownership check at #3714 (comment) (the fix is to remove the workspace rule, not tighten it), the help text on transient claims at #3714 (comment), the commands.md wording at #3714 (comment), the sessions.md owner wording at #3714 (comment), and the sessions.md workspace condition at #3714 (comment). The commands.md and sessions.md workspace threads become moot if the workspace rule is removed.

Before merge, decide on the workspace rule, either by keying coverage on the routed session alone or by moving fencing into session routing. Then post the live transcript at the new head.

@thymikee
thymikee force-pushed the t3/session-device-claims branch from d3c79e1 to 1c8f3e5 Compare October 16, 2026 14:17

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 14 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/daemon-contracts/src/device-claim-record.ts Outdated
Comment thread packages/daemon-contracts/src/device-claim-record.test.ts
Comment thread test/integration/provider-scenarios/android-lifecycle.test.ts Outdated
@thymikee
thymikee force-pushed the t3/session-device-claims branch 3 times, most recently from fdacffa to f885d74 Compare October 16, 2026 14:59

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/daemon-contracts/src/device-claim-record.ts Outdated
Comment thread packages/daemon-contracts/src/device-claim-record.test.ts Outdated
@thymikee
thymikee force-pushed the t3/session-device-claims branch from f885d74 to 30e2b73 Compare October 16, 2026 15:17
@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. At 30e2b73 the label guard and the kind-aware coverage fix the earlier copyable-label hole for the transient direction, and the docs and record-decoding threads are addressed. Two problems from the earlier review at d3c79e1 (#3714 (comment)) remain, and one of them has a new route.

The claim owner match still ignores the new kind. transientCommand is read only by acquireTransientDeviceClaim, but isCurrentClaimOwner in https://github.com/callstack/agent-device/blob/30e2b73/packages/daemon-contracts/src/device-claim-settlement.ts#L190 still matches on session, workspace and daemon. While install holds a claim for transient:install, an open --session transient:install from the same workspace reaches session-open-execution.ts:428, gets held, and adopts the command's claim and token. When install disposes, it clears that claim, and the open session is left with none, so another daemon can take the device mid-use. A claim should match an acquirer only when both kind and session match. Please enforce that in isCurrentClaimOwner by passing transientCommand through resolveExistingClaim, so every acquire route (open and transient admission) is covered. Then the label guard at device-claims.ts:166 can go. Please add an admission test where open under the label is refused while the command is in flight. I traced this statically and did not run it.

Coverage still requires the claim's workspace root to equal the caller's cwd root, in https://github.com/callstack/agent-device/blob/30e2b73/packages/daemon/src/resources/device/device-claim-admission.ts#L79. An explicit --session routes as the same named-local session from every workspace, so from worktree B snapshot --session a works while install --session a is refused with a non-retriable DEVICE_IN_USE. This is the case the issue describes, and the android-lifecycle fixture still has to move its cwd to get past it. The rule should be: a daemon-owned open-session claim covers a request exactly when its session equals the routed session. That removes callerCwd, coveredClaimIsOwnedByRequest and caller-scope-root.ts. If named sessions should be fenced by workspace, please do that in session routing for every command. Could the kind-aware owner match plus this session-only rule be the whole change? What has to be decided first is whether named-local sessions may be shared across workspaces.

I could not find a live transcript for the changed admission route, which decides which install, reinstall, push and boot requests reach a device. At the final head, on one daemon with an iOS simulator or Android emulator, please run open <app> --session a. Then run install and reinstall ... --session b, which should both fail with DEVICE_IN_USE naming session a. Then run reinstall ... --session a, which should succeed. After close --session a, device status should show no claim. Please post that output.

The Coverage check fails, and I think this PR causes it. The new caller-scope-root.ts is imported eagerly by session-routing.ts and device-claim-admission.ts, and the eager-closure budget tests count those entry points. The log tail is truncated, so this rests on that overlap and your report of five failing budgets. Removing that module should clear it. The Smoke Tests failure is open --relaunch timing out in xcrun simctl terminate after 15000ms. The PR does not change that code path, so it looks like simulator latency. I did not run the new admission tests against the code before this update.

On the open threads: the inline threads on owner-identity null start times and the legacy-record test claim still apply (the first goes away with the kind check above). Six lower-priority threads are fixed at this head and can be resolved. The !requestScopeRoot || thread does not apply, because the fix is to remove the workspace rule, not tighten it. The sibling-route thread at r4295092657 is fixed for the transient direction, and the open direction is the first finding above.

Before merge, the claim owner match must compare kind, coverage must key on the routed session alone, and the live transcript must be posted at the final head.

@thymikee
thymikee force-pushed the t3/session-device-claims branch from 30e2b73 to cac1247 Compare October 16, 2026 16:42
@thymikee

Copy link
Copy Markdown
Member Author

Validation at cac1247ac5f1d9fbf56bea18d0262f5831523694:

  • pnpm check:affected --run --head cac1247ac — status=passed, exitCode=0, exactCommit=true, checkoutChanged=false, resolvedBase=mergeBase=4db1b7d67. Passed: format, lint, typecheck, layering, fallow, build, vitest-related, command-docs, docs-links, docs-prose. Remaining skips are GitHub-authoritative or parked lanes.
  • CI at this head: Coverage, Integration Tests, Lint/Typecheck & Package, and Repo Guards & Compatibility all success — the five eager-closure assertions that failed Coverage at 30e2b7335 are resolved by returning resolveCallerScopeRoot to sessions/session-routing.ts with composition-root injection, with no budget change. Linux, Android, Docs, Size, Deploy green; the ios.yml simulator lane and macOS lane are still queued at this head.
  • Fail-before/pass-after at original budgets: the new kind, renewal, and legacy-admission assertions fail against 30e2b7335 and pass here.
  • Independent fresh-context review of this exact commit: no findings. It confirmed claimOwnerKind has one production consumer (the coverage decision), that legacy records always decode unstated so the null === null start-time window can never reach an accepting kind decision, that no-cwd coverage is strictly narrower than base rather than newly open, and that the four new tests assert persisted record bytes or real DEVICE_IN_USE refusal through the admission seam.

@thymikee

Copy link
Copy Markdown
Member Author

Live boundary evidence at cac1247ac — real acquireDeviceClaim/acquireTransientDeviceClaim plus the real resolveCallerScopeRoot that the composition root injects into admission, two fake non-physical Android devices under a throwaway AGENT_DEVICE_CLAIMS_DIR, no daemon, device, or shared state touched:

  • Owner session claim recorded against a subdirectory: acquired. Owner's install run from the checkout root and reinstall from the subdirectory: both covered-by-owned-claim, because both sides canonicalize to one scope root.
  • Second session of the same daemon: conflict, classification live.
  • Owner's session address pasted into a foreign checkout: conflict.
  • In-flight transient:install claim seen by a request addressed exactly transient:install from the same workspace — session string and workspace both match, so only the stored claimOwnerKind: 'transient-command' can refuse it: conflict.

iOS/macOS lanes are still pending at this head. The 30e2b7335 iOS failure was an xcrun simctl terminate host timeout in a file family this diff never touches; that lane turning green here would be observation, not causal proof.

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. The code at cac1247 closes most of the earlier threads, but one defect from the last round is still open, and the live evidence is still missing.

Fixed since 30e2b73: transient coverage now refuses any claim that is not an open-session claim, and legacy records decode as unstated instead of inferring a kind from the label.

Still open: isCurrentClaimOwner in https://github.com/callstack/agent-device/blob/cac1247/packages/daemon-contracts/src/device-claim-settlement.ts#L190 matches only session, workspace and daemon identity, and it never reads claimOwnerKind. While install holds a transient-command claim at session transient:install from workspace W, open <app> --session transient:install from W reaches acquireDeviceClaim, then claimHeldDevice, then resolveExistingClaim. That call returns held, and renewHeldClaim hands the open session the command's owner token. When install disposes, it clears the file, so the open session holds no claim and another daemon can take the device mid-use. The same predicate also adopts an unstated legacy record under a matching pid with a null start time, which settlement.test.ts:100 asserts. The new guard only closes the transient route (device-claims.ts:149-166), so this breaks the rule from #3345 that a claim belongs to its holder. The rule should be: an acquirer holds an existing claim only when the claim kind equals the acquirer's kind (open-session for acquireDeviceClaim, transient-command with the same transientCommand for transient admission) and the session, workspace and daemon identity also match, and unstated is never held. Please pass the acquirer's kind and command from claimHeldDevice through resolveExistingClaim into isCurrentClaimOwner, so every acquire route is covered. Then add an admission test where acquireDeviceClaim for session transient:install in the same workspace returns conflict while the holder is in flight, and change settlement.test.ts:100 so a legacy record is not adopted.

The only evidence at head for https://github.com/callstack/agent-device/blob/cac1247/packages/daemon/src/resources/device/device-claim-admission.ts#L76 is a direct module exercise with fake devices, and it bypasses the daemon router, request scope and CLI. Please post raw output at the final head, on one daemon with an iOS simulator or Android emulator. Run open <app> --session a, then install and reinstall <app> <path> --session b, which should both fail with DEVICE_IN_USE and reason DEVICE_CLAIM_LIVE_OWNER naming session a. Then run reinstall ... --session a, which should succeed. After close --session a, device status should show no claim.

Could the fix also remove code? If claimHeldDevice passes the acquirer's kind and command into resolveExistingClaim, and isCurrentClaimOwner requires kind equality, transient admission keeps only the open-session coverage check (session match plus the workspace rule you answered). Every other existing claim then falls through to the shared held, settle or conflict path, so the label guard at device-claims.ts:161-166 and the separate claimKind !== 'open-session' refusal look unneeded. Does that hold?

Not blocking: claimOwnerKind and the optional transientCommand are two fields the decoder keeps in agreement by hand (device-claim-record.ts:99-107, device-claims.ts:221-222), so one discriminated field would be safer, but you can take or leave that.

On the open threads, the copied-label thread at #3714 (comment) still applies through the open route above, and the coverage thread at #3714 (comment) still applies for the same reason. You can resolve #3714 (comment) (the no-cwd SDK case stays cooperative on purpose, and admission.test.ts:401 pins it). You can also resolve these, which are fixed at head: #3714 (comment), #3714 (comment), #3714 (comment), #3714 (comment), #3714 (comment), #3714 (comment), #3714 (comment) and #3714 (comment).

I traced the open-route takeover by reading the code and did not run it, and I ran no tests at head. Both Smoke Tests jobs are still queued or running, and Smoke exercises open, install and reinstall through the changed admission route, so please check its log before merge. Before merge, make isCurrentClaimOwner require claim kind equality with an open-route regression test, then post the live transcript at the final head.

@thymikee

thymikee commented Oct 16, 2026 •

Copy link
Copy Markdown
Member Author

Coordinating shared CI evidence (read-only; no action implied for your change): your iOS Smoke lane at cac1247ac (run 38934043679 / job 117084917018) failed the same Preflight iOS runner through public CLI step with the same signature as ee2a12ef5 on #3707 (run 38938772746 / job 117100873516): kind: daemon_startup_failed, startupTimeoutMs: 15000, startupAttempts: 1, zero-byte daemon.log, metadataState hasInfo/hasLock false, no assertions executed. A third matching startup failure hit PR #3735 9c7d2602c (run 38933658233, pid 5079) within ~35 minutes of yours. Counter-datapoint: 9203a7a40 passed the same step (began 17:49:28Z; success JSON at 17:50:12Z) in that window and also contains the #3711/#3712 extractions your tree carries — these cross-tree observations do not establish a cause or exclude branch interactions. I am reporting unclassified startup behavior in the shared preflight, not an infrastructure verdict. Pinned evidence and analysis: #3707 (comment)

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. The head is 76766ea. It is a rebase, so the main defect from the earlier review (#3714 (comment)) is still there. The transient coverage check, the help text and the docs are now fixed.

isCurrentClaimOwner matches only session, workspace and daemon identity. While install from workspace W holds a transient claim under session transient:install, open <app> --session transient:install from W reaches resolveExistingClaim through acquireDeviceClaim. That returns held, and the open session receives the command's owner token. When install finishes and clears the file, the open session has no claim but believes it has one, so another daemon or workspace can take the device mid-use. The same predicate also adopts an unstated legacy record when pid and start time match. Please make the rule this: an acquirer holds an existing claim only when claimOwnerKind equals its own kind (and the same transientCommand for transient admission), session, workspace and daemon identity match, and unstated is never held. Enforce it in isCurrentClaimOwner, which every acquire route goes through, by passing the acquirer's kind and command from claimHeldDevice. Then the label guard at device-claims.ts:161-166 is no longer needed. Please add a regression test where acquireDeviceClaim for session transient:install in the same workspace returns conflict while the transient claim is in flight. I traced this path by reading the code. I did not run it.

The admission route for open, install and reinstall (device-claim-admission.ts:76) still has no live evidence through the daemon router and CLI. The existing evidence is a direct module run with fake Android devices. At the final head, please post raw output from one daemon with an iOS simulator or Android emulator. The run should show: open <app> --session a; then install and reinstall <app> <path> --session b both fail with DEVICE_IN_USE, reason DEVICE_CLAIM_LIVE_OWNER, naming session a; then reinstall <app> <path> --session a succeeds; then, after close --session a, device status shows no claim.

Two open review threads still apply to the same code: the transient session-string match and the unstated legacy adoption. The fix above should cover both, so please resolve them with it. These threads no longer apply and can be resolved: no-cwd SDK case (cooperative by design, pinned by admission.test.ts); cli-help description, commands.md rule, malformed claims in sessions.md, workspace match in sessions.md, legacy decode, record test, android-lifecycle test and process-scoped comment (all fixed at this head).

Smoke Tests is still running and has no failure yet. It drives open, install and reinstall through this admission route, so its result matters here. The earlier iOS Smoke failure was a daemon startup failure in the preflight step, before any claim code runs, and the same failure hit #3707 and #3735. I ran no tests, and I did not wait for Smoke. Before merge, the settlement fix with its regression test and the live daemon transcript are needed, and Smoke must pass.

#1809 made install/reinstall and their transient-exclusive siblings covered by
any claim this daemon's process held, so a second session in the same daemon
could install over a device another session had open. Sessions of one daemon
are separate device owners: admission now compares the request's routed session
against the held claim, and a foreign session's claim refuses the command with
DEVICE_IN_USE the way open already does. Session addresses are copyable, so the
covered claim must additionally record the caller's own workspace, canonicalized
on both sides through the same scope root session routing hashes; a request
without a cwd carries no workspace claim. A release whose unlink fails now
abandons the claim instead of leaving it live, so the owner is never locked out
of its own device. The versioned help and user docs name the second
DEVICE_IN_USE flavor's real owner; main's device-verification notes already
attribute that claim to another session, so no guidance budget moves there.
…kspace

#3345's coverage rule pairs the routed session with the workspace that took the
claim. The raw push request carried a temp cwd, so it routed under another
workspace, and the open session's claim no longer covered it. Running it from a
directory of the checkout reproduces the real CLI owner shape the client calls
above already form, and keeps the relative payload resolution the test is for.
Probe evidence at the pre-fix commit: the push refused with
DEVICE_CLAIM_LIVE_OWNER naming the checkout workspace.
An in-flight command's transient claim and an open session's claim differed
only by the shape of the claim's `session` label, so a request whose routed
session string happened to equal `transient:<command>` — reachable today with
`--session transient:install`, which the store sanitizes only in directory
segments — matched the coverage branch and ran while the first request still
held the device (review r4295092657). The record now stores the kind:
`transientCommand` names the scoped command (schema v3), and coverage admits
only open-session claims; a command-scoped claim is a live owner to every
request but its holder, proven by the release token at the admission layer.
Legacy v1/v2 records translate the label that encoded the kind, once, in the
record decode (tag history: v2 is released in v0.21.28, so live records use
it). A session literally named this command's label is refused rather than
renew-adopted, so its claim keeps only what its principal grants.

Fold in the review's documentation findings: the DEVICE_IN_USE help names
in-flight commands as owners a caller cannot close (r4295092708 sibling),
commands.md and sessions.md state the workspace condition next to session
ownership without implying every caller sends a workspace (r4295092708,
r4295092739), and sessions.md names the owner only when the claim is
readable, pointing at `device status` otherwise (r4295092723).

Shrink the android-lifecycle raw-push fixture under the merge-base length:
the file is over the 1,000-line ratchet tripwire and may not grow, so the
push payloads are written directly into the claiming workspace by a loop
(Coverage job test-file-size ratchet, run 38905771494).
… a kind

r4295744998: released v1/v2 recorded a command-scoped claim and an open
session literally named `transient:<command>` as the same record, so the
label is not a kind proof, and stamping a guessed kind made the schema
version alone decide whether a colon-named session's claim covered its
commands. Legacy records now decode kindless, which is safe by
construction rather than by guess: every consumer of a claim's kind first
requires `isClaimOwnedByThisDaemon` (same stateDir, pid, and process start
time), a live process writes only v3 records whose kind is stored, and the
forged-identity window that reaches a kind decision anyway is pinned by a
closest-negative test: a kindless legacy record under this process's
identity behaves exactly as a colon-named open session's claim — refused
to foreign commands by the label guard, covering for the named session —
never as a command claim any in-flight request can adopt or leak.

The record test pins the kind assertions directly (ordinary legacy and
current claims decode `transientCommand` undefined; the v3 kind comes only
from its stored field; malformed kinds fail closed) and the
android-lifecycle provenance comment states what the no-cwd client calls
actually record: the daemon-side fallback workspace at the routed `default`
address, joined explicitly by the pushes whose checkout-child cwd exists
for relative payload resolution, not as a workspace-authority test
(r4295745008, r4295745014).
Drop the duplicated transient-claim refusal case (its label-guard sibling already
proves it) and rewrite comments that narrated mutations and review rounds into
statements of the constraint each test pins.
A migrated v1/v2 record decoded with no kind at all, and coverage read an
absent transientCommand as proof of an open session. The ownership check in
front of that decision matches pid plus start time, and two processes agree
on both whenever a start time is unreadable, so a leftover v2 command claim
can outlive PID reuse and present as this daemon's own session.

Decoded claims now carry a persisted claimOwnerKind. A current writer states
open-session or transient-command; a record that predates the field states
unstated and can hold and fence a device but can never cover a command. The
kind is stored rather than derived from schemaVersion so renewal, abandon,
and clear keep writing the current shape — a renewed record that kept its
legacy version would be stamped with the migrated device shape and read back
as inconsistent.
An acquirer now holds an existing claim only when the record states the very
kind of claim the acquirer would write itself — the same transientCommand for
transient admission — under the same session, workspace, and daemon identity.
An unstated record matches no acquirer, so acquisition never adopts a migrated
v1/v2 record, and an open session named after a command label never inherits an
in-flight command's owner token. claimHeldDevice passes the acquirer's typed
kind and command to resolveExistingClaim, the boundary every acquire route
goes through, which retires the label guard in acquireTransientDeviceClaim.

Ref: #3714 (comment)
@thymikee
thymikee force-pushed the t3/session-device-claims branch from 76766ea to 00e4952 Compare October 17, 2026 11:44
@thymikee

Copy link
Copy Markdown
Member Author

Settlement fix + live daemon transcript at 00e495278ec3b3d2b1af932902b8edba115078f1 (rebased onto current origin/main a61556b8fd3268dd0b8032409ad0f0d06107019b; the six earlier commits replay byte-identical per git range-diff 0563666b..76766ea4 origin/main..HEAD, all =).

The rule, as requested. isCurrentClaimOwner (packages/daemon-contracts/src/device-claim-settlement.ts) now holds an existing claim only when claimOwnerKind equals the acquirer's own kind, with the same transientCommand for transient admission, plus session, workspace and daemon identity. unstated matches no acquirer, so a migrated v1/v2 record can fence the device but is never adopted at acquisition, including the ownerStartTime: null case. claimHeldDevice derives the typed kind and passes the command through to resolveExistingClaim — the one boundary every acquire route goes through — and the label guard in acquireTransientDeviceClaim is gone. Process identity (isClaimOwnedByThisDaemon) is unchanged.

Regression test (fail-before at 76766ea, pass after at 00e4952) in device-claim-settlement.test.ts:

  • an open session addressed at a command label never adopts the in-flight command claim — while install holds a transient claim, acquireDeviceClaim for session transient:install in the same workspace/daemon returned acquired with the command's owner token at 76766ea (3 failed | 12 passed, exit 1); now returns conflict and the token stays with the command.
  • a second request never holds an in-flight command claim, under its own label or another's — same-principal refusal for a different command's acquirer.
  • a migrated record states no kind, so acquisition never holds it — same principal, session, workspace, with and without an unreadable start time; previously acquired + renewal, now conflict with the record untouched. The schema-shape pin moved to a renewal through a migrated record's own token writes back the shape the reader expects (renewal stays token-gated).

Live proof through the real daemon + CLI (one daemon, isolated --state-dir /private/tmp/ad-3345-r11-live, T3-opened emulator-5554 / ReactNative_API_35 Android 15.0, unclaimed; CLI from this build at the pushed head; own sessions r11-a/r11-b, own cleanup):

== open --session r11-a :: exit 0
Opened: com.callstack.agentdevicelab   (claim: claimOwnerKind "open-session", session r11-a)

== install <apk> --session r11-b --json :: exit 1
"code": "DEVICE_IN_USE", "details.reason": "DEVICE_CLAIM_LIVE_OWNER"
"owner": { "session": "r11-a", ... }   diagnosticId mvcbk10t-f1dd1f3a

== reinstall com.callstack.agentdevicelab <apk> --session r11-b --json :: exit 1
same DEVICE_IN_USE / DEVICE_CLAIM_LIVE_OWNER naming r11-a   diagnosticId mvcbk1bx-3bf0b4de

== reinstall com.callstack.agentdevicelab <apk> --session r11-a :: exit 0
Installed: com.callstack.agentdevicelab

== close --session r11-a :: exit 0 → Closed: r11-a
== agent-device device status :: "No local device claims found." (exit 0); claim file absent

Daemon-side NDJSON for both refusals carries phase: "device_claim_conflict" with ownerSession: "r11-a" (sessions/r11-b/requests/3b4739283d528431.ndjson, 6dd5a760c96bb960.ndjson). Own daemon stopped gracefully (daemon-shutdown.json: claims released/orphaned/superseded/unattributable all empty). device status/daemon stop genuinely reject the session-scoped flags (captured INVALID_ARGS in the raw log before the corrected invocations above).

Gate at this exact commit: pnpm check:affected --run --head 00e495278ec3b3d2b1af932902b8edba115078f1 — receipt passed with exactCommit: true, checkoutChanged: false, head/fingerprint unchanged start→end; format/lint/typecheck/layering/fallow/build/vitest-related (2333 passed)/command-docs/docs-links/docs-prose all exit 0; skips carry named GitHub owners (Integration/Smoke/Replay lanes).

Two notes, honestly bounded: the live legs prove the admission route (open/install/reinstall through the router + claim store) end-to-end; the transient-label adoption itself is proven by the focused regression tests above, since timing two CLI commands in flight on one device is not scriptable from one shell. The two threads the head comment referenced (r4295092657, r4296143962) already show isResolved: true via the API — this commit is the substantive answer to both. Smoke at the previous head passed (all 4 lanes green at 76766ea4); it will run again naturally at this head.

Raw transcript (verbatim /tmp/ad-3345-r11-live-final.log, built head, one daemon, emulator-5554)
BEGIN: 2026-10-17T11:37:24Z
LIVE_HEAD: 00e495278ec3b3d2b1af932902b8edba115078f1
LIVE_BASE(origin/main): a61556b8fd3268dd0b8032409ad0f0d06107019b
LIVE_DIRTY: 0
CLI: /Users/michal/.t3/worktrees/agent-device/t3-c9b5f0e0/bin/agent-device.mjs (built at LIVE_HEAD via pnpm build)
DEVICE: emulator-5554 (T3 device_open: ReactNative_API_35 Android 15.0, booted, unclaimed)
STATE_DIR: /private/tmp/ad-3345-r11-live   SESSIONS: a=r11-a (owner), b=r11-b (intruder)
CLAIM_FILE: /Users/michal/.agent-device/device-claims/9265a84bb5f9084a506d763bc8c599bbb11709a6c88d0daecff12321102ecc7c.json
CLAIM_EXISTS_BEFORE: no

### step 0: own daemon + baseline install outside any session (expect SUCCESS; claim released)
== install baseline :: expect=SUCCESS EXIT=0
Installed: com.callstack.agentdevicelab

### step 1: open <own app> --session a (expect SUCCESS; open-session claim)
== open --session r11-a :: expect=SUCCESS EXIT=0
Opened: com.callstack.agentdevicelab
Session state: /private/tmp/ad-3345-r11-live/sessions/r11-a
--- live claim (raw): ---
{"schemaVersion":3,"deviceKey":"local:android:none:emulator-5554","session":"r11-a","workspace":"/Users/michal/.t3/worktrees/agent-device/t3-c9b5f0e0","stateDir":"/private/tmp/ad-3345-r11-live","device":{"id":"emulator-5554","family":"android","kind":"emulator","target":"mobile","name":"ReactNative API 35"},"ownerPid":99622,"ownerStartTime":"Sat Oct 17 13:37:25 2026","ownerToken":"7fbece84-34a3-439c-9e46-650029231c91","createdAtMs":1792237046862,"updatedAtMs":1792237046870,"claimOwnerKind":"open-session"}

### step 2: install <artifact> --session b (expect REFUSE DEVICE_IN_USE / DEVICE_CLAIM_LIVE_OWNER naming r11-a)
== install --session r11-b --json :: expect=REFUSE EXIT=1
{
  "success": false,
  "error": {
    "code": "DEVICE_IN_USE",
    "message": "android device emulator-5554 is owned by session \"r11-a\" in workspace \"/Users/michal/.t3/worktrees/agent-device/t3-c9b5f0e0\".",
    "hint": "Inspect the owner with: agent-device device status --platform android --serial emulator-5554",
    "diagnosticId": "mvcbnxuo-cedcf178",
    "logPath": "/private/tmp/ad-3345-r11-live/sessions/r11-b/requests/2328f85fad2f9dde.ndjson",
    "diagnosticsRecord": {
      "session": "r11-b",
      "requestId": "2328f85fad2f9dde"
    },
    "retriable": false,
    "details": {
      "reason": "DEVICE_CLAIM_LIVE_OWNER",
      "classification": "live",
      "deviceKey": "local:android:none:emulator-5554",
      "owner": {
        "session": "r11-a",
        "workspace": "/Users/michal/.t3/worktrees/agent-device/t3-c9b5f0e0",
        "stateDir": "/private/tmp/ad-3345-r11-live"
      },
      "recovery": {
        "command": "agent-device device status --platform android --serial emulator-5554"
      },
      "dispatched": "unknown"
    }
  }
}

### step 3: reinstall <app> <artifact> --session b (expect REFUSE same reason naming r11-a)
== reinstall --session r11-b --json :: expect=REFUSE EXIT=1
{
  "success": false,
  "error": {
    "code": "DEVICE_IN_USE",
    "message": "android device emulator-5554 is owned by session \"r11-a\" in workspace \"/Users/michal/.t3/worktrees/agent-device/t3-c9b5f0e0\".",
    "hint": "Inspect the owner with: agent-device device status --platform android --serial emulator-5554",
    "diagnosticId": "mvcbny67-012c8e9e",
    "logPath": "/private/tmp/ad-3345-r11-live/sessions/r11-b/requests/e0215f449d9bb0ef.ndjson",
    "diagnosticsRecord": {
      "session": "r11-b",
      "requestId": "e0215f449d9bb0ef"
    },
    "retriable": false,
    "details": {
      "reason": "DEVICE_CLAIM_LIVE_OWNER",
      "classification": "live",
      "deviceKey": "local:android:none:emulator-5554",
      "owner": {
        "session": "r11-a",
        "workspace": "/Users/michal/.t3/worktrees/agent-device/t3-c9b5f0e0",
        "stateDir": "/private/tmp/ad-3345-r11-live"
      },
      "recovery": {
        "command": "agent-device device status --platform android --serial emulator-5554"
      },
      "dispatched": "unknown"
    }
  }
}

### step 4: reinstall <app> <artifact> --session a (expect SUCCESS; own claim covers)
== reinstall --session r11-a :: expect=SUCCESS EXIT=0
Installed: com.callstack.agentdevicelab

### step 5: close --session a (expect SUCCESS)
== close --session r11-a :: expect=SUCCESS EXIT=0
Closed: r11-a

### step 6: device status (expect no remaining own claim on emulator-5554)
== device status :: expect=NO_CLAIM EXIT=1
Error (INVALID_ARGS): Flag --state-dir is not supported for command device.
Hint: Check command arguments and run --help for usage examples.
Diagnostic ID: mvcbnzrh-a1a37631
Diagnostics Log: /Users/michal/.agent-device/logs/default/2026-10-17/2026-10-17T11-37-31-858Z-mvcbnzrh-a1a37631.ndjson
CLAIM_EXISTS_AFTER: no

### daemon request diagnostics for the refused legs (raw NDJSON, claim phases only shown as raw lines):
--- /private/tmp/ad-3345-r11-live/sessions/r11-b/requests/2328f85fad2f9dde.ndjson ---
{"ts":"2026-10-17T11:37:29.603Z","level":"warn","phase":"device_claim_conflict","session":"r11-b","requestId":"2328f85fad2f9dde","command":"install","data":{"deviceKey":"local:android:none:emulator-5554","classification":"live","ownerSession":"r11-a","ownerStateDir":"/private/tmp/ad-3345-r11-live"}}
--- /private/tmp/ad-3345-r11-live/sessions/r11-b/requests/e0215f449d9bb0ef.ndjson ---
{"ts":"2026-10-17T11:37:29.936Z","level":"warn","phase":"device_claim_conflict","session":"r11-b","requestId":"e0215f449d9bb0ef","command":"reinstall","data":{"deviceKey":"local:android:none:emulator-5554","classification":"live","ownerSession":"r11-a","ownerStateDir":"/private/tmp/ad-3345-r11-live"}}
--- daemon.log tail ---
AGENT_DEVICE_DAEMON_PORT=57813

### shutdown OWN daemon (isolated state dir only)
== daemon stop :: expect=SUCCESS EXIT=1
Error (INVALID_ARGS): Flags --platform, --serial are not supported for command daemon.
Hint: Check command arguments and run --help for usage examples.
Diagnostic ID: mvcbnzws-237a05e9
Diagnostics Log: /Users/michal/.agent-device/logs/default/2026-10-17/2026-10-17T11-37-32-046Z-mvcbnzws-237a05e9.ndjson
END: 2026-10-17T11:37:32Z
### step 6 corrected (device status takes no --state-dir/--platform outside a session; run from the same build):
No local device claims found.
STATUS_EXIT=0
CLAIM_FILE_EXISTS_AFTER: no
### own daemon stop (isolated state dir):
No running daemon found.
STOP_EXIT=0

@thymikee

Copy link
Copy Markdown
Member Author

Author evidence-provenance audit for #3714 (comment) — the original comment and its raw transcript appendix are preserved verbatim; these are corrections to the summary around them, not new claims of re-execution.

  1. Fail-before detail. The recorded fail-before run at 76766ea (pnpm vitest run --project unit-core …device-claim-settlement.test.ts, direct exit 1, 3 failed | 12 passed) contained: (a) an open session addressed at a command label never adopts the in-flight command claim — acquired vs expected conflict: a valid fail-before of the maintainer's defect; (b) a second request never holds an in-flight command claim… — that failure was my initial wrong oracle (I expected the second same-command request to be acquired, observed conflict, then corrected the expectation to refuse): it is not a fail-before of production behavior; (c) a migrated record states no kind, so acquisition never holds it — failed on the first loop assertion (readable start time); the second, null-start-time iteration never executed at 76766ea, so fail-before for that case is not independently proven. My summary's "both unstated cases" overstated the evidence. The final 15-pass at 00e4952 pins both loop iterations post-fix. No reruns were performed to manufacture a different fail-before record.

  2. Live-proof IDs. The summary quoted mvcbk10t-f1dd1f3a/3b4739283d528431 and mvcbk1bx-3bf0b4de/6dd5a760c96bb960. Those belong to an earlier pre-rebase dirty-tree run (head 76766ea4 + uncommitted fix, LIVE_DIRTY: 3) and are historical only. The clean run at the pushed head (00e4952, LIVE_DIRTY: 0) — the head-attributed proof — is mvcbnxuo-cedcf178/request 2328f85fad2f9dde (install) and mvcbny67-012c8e9e/request e0215f449d9bb0ef (reinstall), as in the raw appendix.

  3. Daemon-stop provenance. The appendix's final No running daemon found. is a second stop invocation. The actual graceful stop was a separate standalone agent-device --state-dir /private/tmp/ad-3345-r11-live daemon stop at ≈11:37:39Z printing Daemon stopped (graceful). (exit 0; that output was not captured into the appendix). Independent corroboration retained: daemon-shutdown.json mtime 11:37:39Z with all claim counters empty, no daemon process, and the device-claims file absent.

  4. Push exit. The push line captured PUSH_EXIT after piping through tail, so it is tail's status, not a direct push exit certificate. The effect is proven separately: the + 76766ea404...00e495278e … (forced update) output, git ls-remote showing 00e495278e…, and the GitHub PR head at the same SHA.

  5. Review-round method limits. My reviewer instructions said focused vitest projects were fine beside the broad gate — overbroad: subprocess-heavy focused suites also compete with the gate, and none of that establishes or exculpates contention. Gate finished 11:43:47Z, reviewer launched 11:41:46Z; any overlap is what the reviewer's actual commands show. Additionally, the reviewer mutated tracked product files directly in its bound checkout during mutation checks (MutA: exit 1, 2 failed/13 passed; MutB: exit 0, 15 passed; MutC in progress at audit time) instead of a disposable experiment — recorded as limited observations, not a compliant isolated mutation certificate, and not grounds to re-run mutants for historical repair. What MutB's clean pass means for test detectability will be adjudicated strictly from the reviewer's own report at completion, together with a verification that its checkout is restored clean at the exact head.

@thymikee

Copy link
Copy Markdown
Member Author

This PR is ready. The fixes since the earlier review (#3714 (comment)) hold at 00e4952. A session string like transient: no longer adopts an in-flight claim, and an unstated legacy record is never held at acquisition and never covers. Both cubic-dev-ai P1 threads are fixed at this head, so the author can resolve #3714 (comment) (the kind check at device-claim-settlement.ts:220 and the coverage refusal at device-claims.ts:164) and #3714 (comment) (unstated records are excluded by DeviceClaimAcquirerKind and never cover).

Not blocking, and you can take or leave these: the transientCommand comparison at https://github.com/callstack/agent-device/blob/00e4952/packages/daemon-contracts/src/device-claim-settlement.ts#L221 cannot fire today, because acquireTransientDeviceClaim already refuses every live transient-command record of this daemon (keep it as type-level completeness or drop it and let the kind check carry the rule), and the test comment at https://github.com/callstack/agent-device/blob/00e4952/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts#L134 says the refusal is pinned at the settlement boundary when it actually comes from the early guard at device-claims.ts:164.

The code looks good at 00e4952. I did not run the new tests or a mutation pass. I judged regression validity from a read of the earlier commit and your recorded fail-before. The live-proof claims come from your comment at this head, and I did not inspect the raw transcript artifacts. I also did not check the null-start semantics of ownerIdentityMatches, so I cannot say whether the null-start case of the migrated-record test fails before the fix.

Smoke Tests failed in "Preflight iOS runner" because the daemon did not start within 15 s (no lock, no daemon.json). The step before it took about 51 s, so the host looks overloaded. This matches #3782 and is likely unrelated, since the diff only affects code that runs after a request reaches a running daemon. I judged this from the log excerpt and did not see daemon.log. Please re-run Smoke Tests, then merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 17, 2026
@thymikee
thymikee merged commit d17f710 into main Oct 17, 2026
19 of 20 checks passed
@thymikee
thymikee deleted the t3/session-device-claims branch October 17, 2026 14:05
thymikee added a commit that referenced this pull request Oct 17, 2026
* origin/main:
  fix(daemon): scope a transient device claim to the session that holds it (#3714)

# Conflicts:
#	packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts
#	packages/daemon/src/server/request-execution-scope.ts
thymikee added a commit that referenced this pull request Oct 17, 2026
…bals

* commit 'd8a9bd40c0':
  fix(daemon): abandon a transient device claim under the same env that cleared it
  fix(daemon): scope a transient device claim to the session that holds it (#3714)
  test: keep the scenario harness's state dir and transport out of reach of its env override
  test: fail the transport test on an unexpected exit or throw and always remove its workspace
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.

install and reinstall from another session in the same daemon replace the app on a claimed device

1 participant