Skip to content

fix: enforce device claims for sessionless device mutations (#1799) - #1809

Merged
thymikee merged 1 commit into
mainfrom
fix/1799-transient-device-claims
Aug 18, 2026
Merged

thymikee merged 1 commit into
mainfrom
fix/1799-transient-device-claims

Conversation

@thymikee

@thymikee thymikee commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #1799.

Before: shutdown only refused a device that had an active session in its own daemon's session store, then called shutdownTarget(). It never consulted the host-global device claim store, so a daemon in another state directory terminated an emulator another worktree held a verified-live claim on and reported success. boot had the same gap.

After: a foreign live claim refuses the command with the existing DEVICE_IN_USE / DEVICE_CLAIM_LIVE_OWNER error (retriable: false, daemonless device status recovery hint) before any device operation exists.

#1799's third observation — daemon stop reporting claimsReleased: [] even when it released a claim — is fixed separately in #1818, stacked on this branch, so this PR stays scoped to the enforcement seam.

Design

The fix is not a claim check in the two handlers — it makes the class unrepresentable, per #1320's "Command descriptor policy" table:

  1. CommandDescriptor gains a REQUIRED deviceClaimPolicy with no default, using feat: add cross-worktree device ownership and safe recovery #1320's vocabulary (none | observe | require-owner | transient-exclusive | acquire-session | release-session). TypeScript forces every descriptor to declare one; a completeness/honesty test (mirroring the timeout-policy gate) pins the bounded, diffable set of everything that deviates from require-owner.
  2. Enforcement is derived from the trait at one seam. createRequestRuntimeBindings — the only way any handler obtains device operations — already caches one binding per device key, so admitting the claim as part of creating that binding is both the choke point and the natural per-device deduplication. bindExactDevice deliberately bypasses the cache, so it admits its own. There is no per-handler call to forget.
  3. transient-exclusive acquires a command-scoped claim after the gateway bind resolves (binding composes the operation catalog and mutates nothing) and before the narrowed projection reaches the handler; the request scope releases it on dispose, after the bindings it guards.
  4. Every other policy performs zero claim-store I/O, keeping feat: add cross-worktree device ownership and safe recovery #1320's non-goal ("no per-command host-global filesystem reads or writes to session-bound hot paths") intact. A claim already held by this daemon process covers the command rather than colliding with it — decided from the claim record's stateDir + owner identity, so a claim taken earlier in the same request (open's) can never lock the daemon out of its own device.
  5. acquireTransientDeviceClaim reuses the existing claim implementation (same lock, inspection, write, orphan reconciliation); it records session: "transient:<command>" so device status shows an honest owner for a claim that lives milliseconds. No second claim implementation.
  6. The same-daemon shutdown self-guard (active session → close --shutdown) is unchanged.

Classification (every command, honestly)

Policy Commands
acquire-session open
release-session close
transient-exclusive boot, shutdown, install, reinstall, install_source, push, prepare
observe devices, capabilities, device, doctor, apps, appstate
none lease/artifact/session bookkeeping, pure delegators (batch, install-from-source, react-devtools), and local-CLI commands
require-owner everything session-bound (including hover)

Why the sessionless-capable mutations stop where they do. I first classified keyboard, clipboard and trigger-app-event as transient-exclusive too. Live verification caught that as dishonest: those commands still declare ADR 0019 legacy platform execution and reach their device through dispatch, not through the request scope's device binding, so the gate never runs for them — a foreign keyboard status still succeeded against a claimed emulator. They are require-owner until their platform execution migrates to device-runtime, and the honesty test now requires device-runtime execution for transient-exclusive, so the same dishonest pairing cannot be declared again.

Two more deliberate require-owner classifications with reasons, as follow-ups:

  • record — sessionless record start leaves a recording running past the request, and a command-scoped claim released in finally would free a device whose attributable resource is still owned (feat: add cross-worktree device ownership and safe recovery #1320: "Prevent device claims from becoming free while attributable resources remain owned").
  • runtime (internal port-reverse) — same shape: it establishes durable per-session runtime state that outlives the request.

Validation

Regression tests, proven red first

Reverted the production change (git checkout HEAD -- on the touched production files, removed device-claim-admission.ts), kept the tests:

 FAIL  src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts >
       shutdown refuses a device held by a foreign live claim and never reaches the device
AssertionError: expected true to be false
  112|   expect(response?.ok).toBe(false);
 FAIL  ... > boot refuses a device held by a foreign live claim and never reaches the device
AssertionError: expected true to be false
  137|   expect(response?.ok).toBe(false);
 Test Files  1 failed (1) · Tests  2 failed | 2 passed (4)

That ok: true on a foreign-claimed device is #1799 exactly. Restored, green:

 ✓ src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts (4 tests)
   ✓ shutdown refuses a device held by a foreign live claim and never reaches the device

Coverage:

  • session-boot-shutdown-device-claims.test.ts — two-daemon-shaped (foreign state dir + claim): boot/shutdown refused with the typed reason, shutdownTarget/bootTarget never called, the owner's claim untouched, and the transient claim released after both a successful and a failing shutdown.
  • device-claim-admission.test.ts — one table over all six policies proving claim-store I/O happens only for transient-exclusive, plus foreign-claim refusal, same-daemon coverage, provider-owned skip, and the construction guard: the real createRequestExecutionScope claims for shutdown and not for snapshot on the same binding call.
  • device-claim-policy.test.ts — registry-driven completeness, the reviewed non-require-owner sets, and the transient-exclusive ⇒ device-runtime honesty invariant.

Live device verification (Android, Pixel_9_Pro_XL → emulator-5554)

Pre-fix repro on the reported 0.20.9 build. Daemon A (state dir A) held a live claim; daemon B (different state dir, different cwd) ran:

$ agent-device shutdown --platform android --serial emulator-5554 --state-dir <B> --json
{ "success": true, "data": { ..., "shutdown": { "success": true, "stdout": "OK: killing emulator, bye bye" } } }
$ adb devices        # emulator-5554 gone; A's claim file still present and `live`

With this branch (rebooted the AVD, both daemons on the built branch):

$ agent-device shutdown --platform android --serial emulator-5554 --state-dir <B> --json
{
  "success": false,
  "error": {
    "code": "DEVICE_IN_USE",
    "message": "android device emulator-5554 is owned by session \"fixed-a\" in workspace \"/…/agent-a4d4b25de54894d5a\".",
    "hint": "Inspect the owner with: agent-device device status --platform android --serial emulator-5554",
    "retriable": false,
    "details": { "reason": "DEVICE_CLAIM_LIVE_OWNER", "classification": "live",
                 "deviceKey": "local:android:none:emulator-5554",
                 "owner": { "session": "fixed-a", "workspace": "…", "stateDir": "…/stateA" },
                 "recovery": { "command": "agent-device device status --platform android --serial emulator-5554" } }
  }
}
$ adb devices        # emulator-5554 still up

boot --platform android --device Pixel_9_Pro_XL from B returned the identical refusal. After close --session fixed-a in A, B's shutdown succeeded ("OK: killing emulator, bye bye") and left no claim file behind.

Sessionless install from B while A held the claim was refused the same way, while the owner's own in-session install reached the device (it failed on INSTALL_FAILED_TEST_ONLY, an unrelated property of the helper APK I used) — the same-daemon coverage path with no claim-store collision.

Cleanup: both daemons stopped, the AVD I booted shut down, no leftover claim file. iOS was not exercised; the seam is platform-neutral and isLocalDeviceClaimTarget gates it on the admitted runtime owner exactly as open does.

Gates

pnpm exec tsc --noEmit, pnpm format, pnpm check:affected --run (432 files / 3684 tests, layering + fallow green — fallow reports no issues in the changed files). Wire compat: pnpm check:daemon-wire-compat (151 declarations, 0 changed — this PR adds no daemon wire surface).

Tradeoffs and follow-ups

  • Scope: 16 files, +947/−83. It expanded past boot/shutdown by design — feat: add cross-worktree device ownership and safe recovery #1320 requires auditing every sessionless device-mutating command, and the descriptor trait is what makes the audit permanent. A large share of the diff is one declaration line per descriptor, matching the existing timeoutPolicy convention; the interaction descriptors that were byte-identical now share a TARGETED_TOUCH_INTERACTION_TRAITS bundle beside the existing GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, which also removes the clone group the added line would otherwise have created.
  • Follow-ups: keyboard, clipboard, trigger-app-event become enforceable once ADR 0019 migrates their platform execution to device-runtime; record and runtime need a claim whose lifetime follows the durable resource, not the request, before they can leave require-owner.
  • install/reinstall/install_source/push/prepare are newly transient-exclusive — a behavior change for sessionless use against a foreign-claimed device (now refused). install was live-verified both ways; push/prepare were not.

@github-actions

github-actions Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
JS raw 2.27 MB 2.28 MB +3.8 kB
JS gzip 747.7 kB 748.3 kB +634 B
npm tarball 869.5 kB 870.1 kB +652 B
npm unpacked 3.03 MB 3.04 MB +3.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.0 ms 25.7 ms -0.3 ms
CLI --help 66.8 ms 64.7 ms -2.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +3.0 kB +895 B
dist/src/session2.js -1.1 kB -420 B
dist/src/sdk-batch-runner.js +1.9 kB +169 B
dist/src/runtime2.js 0 B -17 B
dist/src/runtime.js +1 B +4 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head b807323. Clean code/architecture verdict: claim policy is descriptor-required, transient-exclusive admission sits at the request-runtime binding seam before operations are exposed, legacy classifications are gate-rejected, and the two-daemon regressions plus live Android A/B directly prove #1799 and cleanup. Readiness is currently blocked by exact-head CI: Android Smoke failed in the unrelated live scroll helper (Active application interaction viewport is unavailable) and needs a rerun or same-load main comparison before it can be classified; iOS Smoke is still in progress. No ready-for-human label while that failure remains confirmed.

@thymikee

Copy link
Copy Markdown
Member Author

Re the Android Smoke failure at b807323: the failing step is smoke:automation-system scroll (Active application interaction viewport is unavailable, an Android helper IllegalStateException during viewport lookup) — nothing on this branch touches the helper, snapshot presentation, or the scroll path; the change only gates bindDevice for transient-exclusive descriptors (boot/shutdown/install*/push/prepare), and the smoke session runs through open (acquire-session) + session-bound commands, which do zero claim-store I/O. Re-ran the failed job to classify it as load flake vs. real; will update here with the result.

@thymikee

Copy link
Copy Markdown
Member Author

Android Smoke rerun on the same head passed → the earlier Active application interaction viewport is unavailable was load flake in the live scroll helper, unrelated to this branch. All checks green now.

@thymikee thymikee added ready-for-human Valid work that needs human implementation, judgment, or maintainer merge and removed ready-for-human Valid work that needs human implementation, judgment, or maintainer merge labels Aug 18, 2026
@thymikee
thymikee force-pushed the fix/1799-transient-device-claims branch from b807323 to 01fcba5 Compare August 18, 2026 10:29
@thymikee

Copy link
Copy Markdown
Member Author

Tightening pass after review — rebased onto current main and made smaller without weakening the guarantee.

Before → after (this PR's diff vs main)

files +/−
Before 23 +1251 / −88
After (#1809 alone) 16 +951 / −83
Split out to #1818 8 +294 / −41

What changed:

  1. Split. The daemon stop claim reporting (shutdown can terminate a device held by a foreign live claim (0.20.9) #1799's third observation) moved to fix: report real claim results from daemon stop #1818, stacked on this branch and opened as a draft; it will be retargeted to main after this merges. This PR is now only the descriptor policy, the admission seam, and the boot/shutdown regression.
  2. Reused the binding cache instead of adding one. createRequestRuntimeBindings already memoizes one binding promise per device key, so the claim admission now runs inside that binding creation — once per device, deduped by machinery that already existed. device-claim-admission.ts lost its own Map/pending-promise bookkeeping and now only remembers the ownerships it acquired so it can release them (94 → 83 lines, and the remaining logic is a straight-line acquire/release). bindExactDevice bypasses the cache by design, so it admits its own.
  3. Tests folded. device-claim-admission.test.ts (176 → 150) is now one table over all six policies asserting claim-store I/O only for transient-exclusive, keeping the foreign-refusal, same-daemon-coverage, provider-skip and createRequestExecutionScope construction-guard cases. device-claim-policy.test.ts (123 → 97) dropped the assertions TypeScript already proves and collapsed the four bounded sets into one diffable record. Both boot/shutdown regression tests and the claim-store setup now come from shared fixtures (ANDROID_EMULATOR, and a new src/__tests__/test-utils/device-claim-store.ts used by three test files) instead of per-file copies.
  4. Registry. The 76 one-line deviceClaimPolicy: declarations stay — required-with-no-default is the point, and it matches the timeoutPolicy convention. The fallow clone-group warning is resolved at its cause: fill/press/longpress were byte-identical for 14 lines, so they now share a TARGETED_TOUCH_INTERACTION_TRAITS bundle next to the existing GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS (which also absorbed the policy for the four commands that spread it). No suppression, no regenerated baseline — fallow audit now reports no issues in the changed files, where it previously reported one clone group.

pnpm format && pnpm check:affected --run green on both branches (432 files / 3684 tests here; 436 / 3700 on #1818).

@thymikee

Copy link
Copy Markdown
Member Author

Clean re-review at 01fcba5: the scope split is coherent—daemon-stop claim reporting moved to #1818, while #1809 retains the descriptor-owned claim policy, runtime-binding admission, transient lifecycle, and the exact foreign-claim boot/shutdown regressions for #1799. No shutdown-reporting files remain in this diff. Exact-head CI and all platform smokes pass; merge state is CLEAN. Ready-for-human.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 18, 2026
`boot` and `shutdown` never consulted the host-global device claim store, so a
daemon in one state directory could terminate an emulator another daemon held a
verified-live claim on and report success (#1799).

Rather than adding a claim check to those two handlers, this makes the class
unrepresentable: `CommandDescriptor` gains a REQUIRED `deviceClaimPolicy` trait
(#1320's vocabulary), and the request-execution scope enforces it where the
request runtime bindings create a device binding — the one seam through which
any handler can obtain device operations, and already the place per-device
deduplication lives. A `transient-exclusive` command acquires a command-scoped
claim before operations reach the handler, refuses a foreign live claim with the
existing DEVICE_IN_USE/DEVICE_CLAIM_LIVE_OWNER error, and releases in the
scope's finally. Every other policy performs no claim-store I/O, so session-bound
commands keep #1320's non-goal intact.
@thymikee
thymikee force-pushed the fix/1799-transient-device-claims branch from 01fcba5 to 36083a9 Compare August 18, 2026 11:58
@thymikee
thymikee merged commit 2b6d04a into main Aug 18, 2026
31 checks passed
@thymikee
thymikee deleted the fix/1799-transient-device-claims branch August 18, 2026 12:12
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-18 12:12 UTC

thymikee added a commit that referenced this pull request Oct 16, 2026
#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.
thymikee added a commit that referenced this pull request Oct 16, 2026
#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.
thymikee added a commit that referenced this pull request Oct 16, 2026
#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.
thymikee added a commit that referenced this pull request Oct 16, 2026
#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.
thymikee added a commit that referenced this pull request Oct 17, 2026
#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.
thymikee added a commit that referenced this pull request Oct 17, 2026
#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.
thymikee added a commit that referenced this pull request Oct 17, 2026
… it (#3714)

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

#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.

* test(integration): run the raw file push from inside the claiming workspace

#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.

* fix(daemon): record which kind of owner holds a device claim

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

* fix(daemon): decode legacy device claims kindless instead of guessing 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).

* test(daemon): tighten the device-claim admission tests

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.

* fix(daemon): state a claim kind only from a record that states one

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.

* fix(daemon): hold an existing claim only for its own claim kind

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

shutdown can terminate a device held by a foreign live claim (0.20.9)

1 participant