Skip to content

fix(device): state when nothing is claimed and reap dead claims - #1519

Merged
thymikee merged 3 commits into
mainfrom
fix/device-claim-status-and-pruning
Jul 31, 2026
Merged

fix(device): state when nothing is claimed and reap dead claims#1519
thymikee merged 3 commits into
mainfrom
fix/device-claim-status-and-pruning

Conversation

@thymikee

Copy link
Copy Markdown
Member

Found while investigating "the iPhone is connected to an agent but device status shows nothing except a stale-agents message". Two separate defects; the command was telling the truth, but in a way that reads as broken.

Bug 1 — the empty-state verdict is swallowed

$ agent-device device status
21 stale claims hidden; inspect with: agent-device device status --stale

In renderDeviceStatus, 'No local advisory device claims found.' is gated on hiddenStaleClaims === 0. So when live claims are empty and stale ones exist, both early returns are skipped and the output falls through to just the hidden-stale notice. The one case where a user most needs to hear "nothing holds this device" is exactly the case that never says it. --json had the answer all along: {"claims": [], "hiddenStaleClaims": 21}.

Now:

No live local advisory device claims found.
21 stale claims hidden; inspect with: agent-device device status --stale

Bug 2 — dead claims are never reaped

Claims are released on session close and daemon shutdown, but a process that dies abruptly leaves its file behind and nothing collects it. My real store had 21 claims spanning Jul 17 → Jul 31, every single owner dead (verified per-claim with kill(pid, 0)) — including the iPhone's, orphaned by a session from Jul 23.

Daemon startup now prunes them, right next to the existing web-browser orphan cleanup, best-effort and non-fatal with a device_claim_prune diagnostic.

Pruning is deliberately narrower than the CLI's stale filter. It removes only owner-process-dead. owner-state-dir-gone describes a live process whose state dir vanished — deleting that claim could hand its device to a second session. The CLI may hide both; only one is safe to delete.

This changes no behaviour beyond housekeeping: the conflict path already steps over a dead-owner claim (device_claim_advisory_conflict … classification: owner-process-dead), so these files were inert — just unbounded and noisy.

Testing

  • states that nothing is claimed when every claim is stale — verified revert-sensitive (fails when the render change is reverted).
  • prunes claims whose owner is gone and keeps every other claim — asserts the dead claim is deleted while a live claim, a live-but-state-dir-gone claim, and an unparseable file all survive.
  • Ran the reaper against a copy of the real 21-claim store: pruned 21, and the real store was left untouched.
  • pnpm check:affected --run passes.

Two defects found while asking which agent held a connected iPhone.

device status printed only "21 stale claims hidden" and no verdict: the
"No local advisory device claims found" line was gated on there being zero
stale claims too, so the one case where a user most needs to hear that nothing
holds the device is exactly the case that never said it. It now reports the
empty live set alongside the hidden-stale notice.

Nothing ever reaped claims whose owner died abruptly. Claims are released on
session close and daemon shutdown, but a killed process leaves its file
behind, and a real store had accumulated 21 of them spanning two weeks, every
owner dead. Daemon startup now prunes them, next to the existing web-browser
orphan cleanup.

Pruning is deliberately narrower than the CLI's stale filter: it removes only
owner-process-dead claims. owner-state-dir-gone describes a LIVE process whose
state dir vanished, and deleting that claim could hand its device to a second
session.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.91 MB 1.91 MB +919 B
JS gzip 612.7 kB 612.9 kB +240 B
npm tarball 730.3 kB 730.6 kB +248 B
npm unpacked 2.56 MB 2.56 MB +919 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.3 ms 28.0 ms +0.7 ms
CLI --help 58.1 ms 59.9 ms +1.9 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +846 B +214 B
dist/src/cli.js +66 B +18 B
dist/src/script.js +7 B +7 B

Two review findings on the startup prune.

The scan classified a claim dead and then unlinked it, but claim paths are
derived from the device key: a concurrent daemon can prune the same dead claim
while a new session writes its live successor to that exact path, and the
unlink would take the successor with it. Liveness and owner token are now
re-checked while holding the per-device claim lock, matching what
clearAdvisoryDeviceClaim already does, and a file whose name is not the
canonical path for the key it contains is left alone.

Daemon startup runs outside any diagnostics scope, where emitDiagnostic returns
without recording, so neither a successful prune nor a failure produced the
promised event. The prune now opens its own scope and flushes, the way
emitFatalDiagnostic does.
@thymikee

Copy link
Copy Markdown
Member Author

Both findings addressed at 117188b99. Both were real; the first was a genuine data-loss path I introduced.

[P2] race deletes a fresh live claim — correct, and worse than a stale-read: claim paths are sha256(deviceKey), so the successor for the same device reuses the exact path my scan had already classified dead. The unlink would delete a live session's claim.

Fixed by re-checking under the per-device lock, mirroring what clearAdvisoryDeviceClaim already does: take withDeviceClaimLock, re-inspect, and unlink only if the claim is still owner-process-dead and carries the same ownerToken the scan saw. I also skip any file whose name is not the canonical path for the device key it contains, so a stray file can never cause an unlink of something else.

The regression test drives the real race rather than simulating it: the test acquires the actual claim lock, starts the prune (which blocks on it), writes the live successor into that window, then releases. Verified revert-sensitive — removing the re-check makes it fail by deleting the successor, exactly as described.

[P2] prune diagnostics are discarded — also correct. emitDiagnostic early-returns when diagnosticsStorage.getStore() is empty, and startup has no scope, so both the success and failure events were silently dropped. The prune now opens its own withDiagnosticsScope({ command: 'daemon', session: 'daemon', logPath, debug: true }) and flushes, following emitFatalDiagnostic in the same file.

One note: the new race test sits at ~3.1 s against the 2.5 s slow-test budget. That is process-spawn cost from real owner-identity probes plus the real lock, not a sleep — the gate reports it inside the load-variance band. I left it exercising the real primitives rather than mocking them, since the mocked version would not have caught this bug; say the word if you would rather it were trimmed.

pnpm check:affected --run passes.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 117188b992ac97c71dc209174d86a7293497419e. [P2] The startup-prune diagnostic is still discarded: pruneDeviceClaimsForDaemonStartup() emits and flushes before publishDaemonInfo(), then writeInfo() immediately truncates daemon.log with fs.writeFileSync(logPath, ''). Thus every successful-startup device_claim_prune/device_claim_prune_failed event is absent from the log users are told to inspect. Initialize the daemon log before pruning or defer the diagnostic until after publication, and add a full runtime-start regression that reads the resulting daemon.log.

The lock/token recheck otherwise resolves the successor-deletion race, and no live-device evidence is needed for this host-filesystem-only behavior.

Residual risk: no separately authorized cross-vendor review was performed.

publishDaemonInfo truncates daemon.log, so the prune's diagnostic was written
and then wiped: every successful startup left no device_claim_prune event in
the log users are pointed at. The prune now runs after publication.

The regression starts the real runtime and reads the resulting daemon.log,
because the ordering is the bug — a test around the prune alone passes either
way.
@thymikee

Copy link
Copy Markdown
Member Author

Fixed at 4678b4e25.

Confirmed exactly as you described: the prune ran at line 333, publishDaemonInfowriteInfo truncated daemon.log with fs.writeFileSync(logPath, '') at line 345, so the event was written and then wiped. My earlier scope fix made the diagnostic reach the log; it just did not survive to be read.

The prune now runs after publishDaemonInfo. Pruning is housekeeping with nothing downstream depending on it, so deferring it costs nothing, and doing it after the daemon is up is arguably the better place anyway.

Regression is a full runtime start, as asked: it plants a dead claim, calls startDaemonRuntime, then asserts both that the claim file is gone and that daemon.log contains "phase":"device_claim_prune" with "pruned":1. Verified revert-sensitive — restoring the old ordering fails it with the claim pruned but the event missing from the log, which is precisely the symptom you reported. A test around the prune in isolation passes either way, which is why it had to be the real runtime.

One incidental find while writing it: resolveDeviceClaimRoot() reads process.env directly rather than the env passed to startDaemonRuntime, so the test has to redirect the store on the process itself. Not changed here — just noting it, since it means a daemon started with a custom env still shares the host claim store.

pnpm check:affected --run passes.

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

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 4678b4e2531b79287b0902862afb2dd6afed3225: the startup diagnostic loss is fixed. Daemon publication now happens before dead-claim pruning, and the full runtime regression proves both that the canonical dead claim is removed and that device_claim_prune with pruned: 1 survives in the resulting daemon.log; reverting the order breaks the assertion. Existing canonical-path, lock, and owner-token safeguards remain intact. Exact-head CI and all platform smokes are green, and no live-device run is required for this host-local claim/log path. Code review is clean and this is ready for human merge.

Non-blocking test hygiene: when AGENT_DEVICE_CLAIMS_DIR was originally absent, restore it with delete process.env.AGENT_DEVICE_CLAIMS_DIR rather than assigning undefined.

Residual risk: no separately authorized cross-vendor review was performed.

@thymikee
thymikee merged commit 2ba6ecf into main Jul 31, 2026
30 checks passed
@thymikee
thymikee deleted the fix/device-claim-status-and-pruning branch July 31, 2026 11:09
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-31 11:10 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