Skip to content

fix(ios): prevent duplicate mutations and preserve read retries - #4140

Open
thymikee wants to merge 15 commits into
mainfrom
t3/sm9-runner-journal-admission
Open

thymikee wants to merge 15 commits into
mainfrom
t3/sm9-runner-journal-admission

Conversation

@thymikee

@thymikee thymikee commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

The Swift journal owns execute/join/terminal admission, preventing completed interactions from running twice. An explicit read trait lets finished reads execute again after a lost reply and excludes them from the closed-id window. Execution tokens isolate joiners across re-admission. Recorded failures preserve their code and hint, and successful shutdown replays retain finish behavior.

20 files: 9 production, 7 tests, 1 fixture, 2 docs, 1 patch changeset. The closed-id marker window remains bounded at 4096; queued and running entries remain queryable.

Validation

Head 7c883d7aa617ca757de61304fb8b6cae957717ec, base f7879cf2d266ee1dd476d0342b2819bdeceb647b.

  • Fail-first: read admission, HTTP snapshot repeat, and missing error hint fail on the pre-fix working tree.
  • iOS focused Swift: 81 passed. Focused TypeScript at unchanged production head: 77 passed. Selector replay mutation fails; restoration passes.
  • pnpm check:xctest-selection and pnpm build:xcuitest passed.
  • Native duplicate check: 75 clones, zero new. Exact-head affected gate passed: receipt run-p6s57A, exactCommit=true, checkoutChanged=false.
  • Production-head 9c13f73 live route (final commit changes only tests) on mad2026-sol: open, snapshot, tap, close; runner exited. Same-ID listener tests prove a mutation runs once and a snapshot runs twice.
  • CI on the new head is pending. The prior Apple failure was the test’s host-label assumption; the corrected test observes fresh admission while its command queue is held. macOS host UI automation cannot initialize locally; simulator evidence is recorded separately.

The runner kept command lifecycle in two places: the transport's in-flight id set
and the command journal. They were cleared at different moments, so a repeat of a
commandId whose answer had already been delivered was admitted again and ran the
command a second time — a second tap. Pruning could also evict a command that was
still executing, which the daemon reads as notAccepted, and a late transition for a
pruned id could invent an entry the runner never claimed.

RunnerCommandJournal now answers each inbound request with one verdict: execute,
join, or terminal. A terminal id is answered from the recorded outcome, then the
recorded error, then COMMAND_ALREADY_FINISHED. Pruning drops only closed entries,
and an update never opens a record again. The journal closes a commandId at the
last fast RUNNER_BUSY or RUNNER_WEDGED refusal, so the read-only busy resend keeps
its reused id (ADR 0005). The transport keeps only the waiter list.
@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agent-device-website Ready Ready Preview Nov 1, 2026 10:57pm UTC

Request Review

@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 7 files

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

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

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.65 MB 5.67 MB +22.3 kB
Package (unpacked) 5.65 MB 5.67 MB +22.3 kB
Package (download) 1.72 MB 1.72 MB +4.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 31.3 ms 29.8 ms -1.5 ms
CLI --help 57.1 ms 59.5 ms +2.4 ms

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent review: needs changes. Reviewed exactly merge-base f7879cf2d266ee1dd476d0342b2819bdeceb647b → head e1c2cae64d14ec04da1d03a92782f110578e7835 against the coordinator's once-per-command-id admission requirement.

  1. P1 — Pruning makes a completed mutation executable again. apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift:209-213 (source). Finish a dispatched tap with id T, then finish 64 other commands. Pruning deletes every record of T; accept(T) takes the missing-entry branch at lines 62–63 and returns execute, so a delayed resend can tap again. This is also immediate under overload: with 65 non-terminal entries, finishing one can evict that very entry. Retaining only response bodies for a window is reasonable, but deleting the admission history violates the required “a completed or failed command id is never executed again.” Fix: preserve closed-id admission history independently of retained outcomes, so an evicted outcome yields a typed refusal rather than a new execution. If admission history must be bounded, enforce a safe admission/session boundary instead of silently treating previously executed ids as new. Add a transport regression that resends a finished mutation after retention pressure.

  2. P2 — A joined request can miss delivery and remain retained forever. apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift:279-287 (source). Keep 65 commands non-terminal. On transportQueue, accept(T) returns join. Before attachToInFlightExecution acquires its lock, commandExecutionQueue finishes T, pruning its now-terminal entry, and deliverCommandResult drains its waiters. Attachment then finds no closedOutcome, appends the completion, and no execution remains to wake it. The connection receives no answer and its completion remains in inFlightCommandWaiters. The same issue applies to fail, which uses the same pruning update. The journal still has its own NSLock; journal closure does not acquire inFlightCommandLock as the attachment comment claims. Fix: make admission and joiner subscription one atomic lifecycle operation, and preserve the outcome until all admitted joiners can receive it. Merely checking the current entry after the separate admission decision is insufficient. Add a deterministic test for this interleaving on both finish and fail.

Verification:

  • In a disposable checkout, pnpm install --frozen-lockfile, pnpm build, and pnpm check:xctest-selection passed. Focused apple-runner dispatch disclosure, session types, busy resend, and error classification tests: 75 passed across 4 files.
  • A standalone macOS XCTest executable used the actual journal source and extracted transport attach/deliver methods with minimal command/response fixtures. The PR's two pure journal regression test bodies both fail on base for the intended assertions (2 tests, 2 failures) and pass on head. The head harness ran 9 tests: the two findings above fail with 3 assertions; concurrent admission (100 sends → one execute), ordinary failed-execution waiter delivery, terminal-state immutability, and busy-refusal re-admission pass. This is focused source-level evidence, not a full runner/Xcode lane pass; I did not independently run the PR's listener regression on base.
  • At capacity with every entry non-terminal, the journal does not refuse admission: all 256 probe ids returned execute and remained accepted. The runner enqueues them; there is no capacity-specific response or hard bound. Non-terminal entries are preserved and continue reporting accepted/started, matching RUNNER_IN_FLIGHT_LIFECYCLE_STATES; the problem is what happens when an entry then closes.
  • COMMAND_ALREADY_FINISHED currently becomes a structured, non-retryable error with dispatched: unknown; it is neither success nor a busy/pre-send refusal. No retry storm reproduced. No TypeScript files changed. The intentional pre-dispatch RUNNER_BUSY resend remains available.
  • Simulator coverage needs qualification: the source-derived PR iOS Simulator lane selects none of the five new/changed admission tests. The macOS host lane and full nightly iOS Simulator lane include all five. These changed journal/transport methods and tests compile on both platforms, so a manually selected iOS run exercises the shared logic, but the normal PR simulator selection does not replace the unavailable host run. I did not rerun the full native host or simulator lanes or wait for CI.

Reviewed checkout remains clean; no source edits, commits, or pushes.

@apex-sentinel-staging

apex-sentinel-staging Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Apex Sentinel

Verdict: approve

Since the last review: 8 new · 0 still open · 6 no longer raised

8 finding(s): 1 minor, 7 nit. Verdict: approve. reached max rounds (1).

🟡 Minor (1)

Where Finding
website/docs/docs/commands.md:542 🆕 New COMMAND_ALREADY_FINISHED error code ships with no changeset entry
🔵 Nit (7)
Where Finding
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift:111 🆕 Journal replays a retained success body for a read command, so a finished read id can never observe a second state change
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift:59 🆕 thrownErrorPayload(for:) classifies the same error twice and duplicates the timeout condition
packages/apple-runner/src/__tests__/runner-swift-settlement-fixtures.ts:133 🆕 Journal cross-language fixture is a source-text reader that fails on a Swift reformat, not on a behavior split
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift:13 🆕 The 4096 safety valve keeps its "64x the entry window" relation only in prose
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift:13 🆕 transition still keys a stale transition on commandId only, ignoring the executionToken this change minted
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift:288 🆕 registerJoiner is called while the journal lock is held, creating a lock-order edge that is only documented, not enforced
packages/apple-runner/src/runner-error-classification.ts:687 🆕 Daemon's key on COMMAND_ALREADY_FINISHED is not tied to the Swift wire code the way MAIN_THREAD_TIMEOUT is

Reply under an inline comment with @apex-sentinel-staging note ... or @apex-sentinel-staging pivot ... to re-check that finding.
Comment @apex-sentinel-staging review on this conversation to run a full review.

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 4 new findings on e1c2cae. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

At e1c2cae, the admission change breaks recovery for a read whose reply was lost. The mutation side looks right.

The daemon resends a read with the same commandId when it did not receive the reply (index.ts:141-160 sets the id once, outside the retry loop; runner-command-recovery.ts:111-120 rethrows the transport error so the loop resends). snapshot and screenshot keep no journal body (RunnerTests+Models.swift:283), and any read over 16 KB keeps none either. For that finished entry, accept now returns .terminal (RunnerTests+CommandJournal.swift:73), so the resend gets COMMAND_ALREADY_FINISHED with ok:false. On base, the same resend re-ran and returned a fresh capture. A snapshot or screenshot whose HTTP reply is lost (socket hang up, fetch failed, or a timeout that races the finish) now fails, and the error hint tells the user to run snapshot, the command that just failed. The ADR's rejected alternative assumes the caller already received the answer, but the daemon resends a read only when it did not. The rule the code must satisfy: a repeat of a finished commandId may skip execution only when it gets the recorded outcome, or when re-running could change device state. Please declare that on CommandTraits, which already owns retainsJournalResponseJson, as a read trait on the appRead and runnerCaptureRead groups and presentedSurfaceQuery, and have accept re-claim a finished entry with no retained body when its command is a read. Please do not infer it from retryOnSessionLoss. COMMAND_ALREADY_FINISHED should remain only for mutations whose body was dropped. Please amend the ADR paragraph "Rejected: gating re-admission on read-only classification", and add a listener test that sends snapshot twice with one commandId and expects two executions and an ok second reply.

Not blocking, and you can take or leave these: the two new tests in RunnerTests+CommandJournalTests.swift repeat the same 64-entry fill loop (lines 212-221 and 241-249), so a shared helper beside runnerJournalCommand would remove the only new clone that is not in the native dupes baseline; the execute(command:) seam in RunnerTests+TransportTests.swift:339 copies the transport's admission switch, so those tests prove the journal result through a copy and not the listener route (one shared function, or tests through startTransportListener, would fix that); and the ADR line "Never execute a commandId twice" should say it holds while the journal holds the id, since an undispatched refusal can be admitted again and an id pruned past the 64-entry window is claimed again.

Of the open inline threads, these still apply: terminal wording in the ADR, entry stays open wording, replay drops error code, status answered before accept, and vacuous coalesce assertion. Five lower-priority threads also still apply. These do not apply, so please resolve them: uptime anchor on replay (the response stamps current uptime at send time and replay stores no stamp), post-dispatch busy refusals (RUNNER_BUSY and RUNNER_WEDGED come only from the refusals before markDispatched), and empty commandId on join (accept returns .execute for an empty id, so .join never carries one).

The PR body's live run was at d22b69a, not e1c2cae, and it covered only the mutation repeat. Please post a run at the final head on a simulator, sending each of these twice straight to the listener with one fixed commandId, and quote both runner.log excerpts. First, a mutation (tap or pasteboardWrite) should show one AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED, the repeat answered with AGENT_DEVICE_RUNNER_COMMAND_ALREADY_FINISHED, and the device state unchanged. Second, snapshot -i (after the fix above) should show two COMMAND_ACCEPTED markers and an ok second reply that carries nodes, not COMMAND_ALREADY_FINISHED. I did not run Swift tests or a device. The lost-reply resend route comes from reading the daemon code, not from a reproduced run.

The Repo Guards failure comes from this PR: the jscpd clone at RunnerTests+CommandJournalTests.swift [212:35-221:6] vs [241:5-249:6] is the fill loop in the two tests it adds. Every other clone in the log is in files the PR does not touch. Before merge, the finished read needs to run again on a repeated commandId (trait, ADR change, listener test), the fill loop needs one shared helper, and the live run above needs to be posted at the final head.

… one decision

Pruning an entry kept the entry's admission fact with the body it shared, so a
pruned id became executable again and a delayed resend could drive the device a
second time. Pruning now leaves the id in a bounded closed set that answers
admission with the typed refusal, and a pruned refusal keeps its id free for the
busy resend (ADR 0005).

The join verdict and registering the send for the execution's answer were two
steps, so a close that landed between them left the send waiting on a delivery
that had already gone out. They are one operation the journal's lock covers.
@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Dispositions for both needs-changes findings. Head 3bbe6a889005ec13c898b347b21989b57a243e0a.

P1 — pruned id executable again (RunnerTests+CommandJournal.swift). Confirmed, fixed. Chosen direction: a compact closed marker, not a watermark. Pruning a dispatched terminal entry now leaves its id in closedCommandIds (with closedOrder for eviction), bounded at maxClosedCommandIds = 4096 — 64x the 64-entry window, since a marker carries the id and nothing else. A resend that hits a marker gets COMMAND_ALREADY_FINISHED from accept and never reaches execute. Residual bound: after 4096 further dispatched closes the oldest marker is dropped and that id is admissible again.

Rejected the watermark: commandId is runner-${crypto.randomUUID()}, minted per request at packages/apple-runner/src/runner-contract.ts:459-467, unordered, reused across retries and across runner generations, and never persisted — no ordering exists to compare.

Two marker rules: a pruned entry that never dispatched leaves its id free, otherwise the RUNNER_BUSY read-only resend that carries the same id would be refused (ADR 0005); and a marker settles admission only — status still answers notAccepted, because only an entry can report an outcome. ADR 0005 also corrected: that refusal keeps dispatched: unknown, not no — the runner won't re-run, and only it knows whether an earlier send reached the device.

P2 — join decided before the close (RunnerTests+Transport.swift). Confirmed, fixed by making it atomic. accept(command:registeringJoin:) decides and registers the waiter inside one NSLock-held operation; the .join verdict now means "already registered". The transport's registerJoiner takes only inFlightCommandLock and never re-enters the journal, so there is no lock order to invert. closedOutcome and attachToInFlightExecution are gone. Covered deterministically for both close paths: testJournalAnswersAJoinerWhenTheCloseThatAnswersItAlsoPrunesTheEntry and testJournalAnswersAJoinerWhenTheExecutionFails.

Fail-then-pass, iOS Simulator lane, disposable worktrees. P1 at base f7879cf2: Executed 2 tests, with 3 failures — re-admitted as accepted, and the resend of a finished recordStop answered "recording stopped", consuming the recorder the test installed. P2 at prior head e1c2cae6: Executed 1 test, with 2 failures — the joined send unanswered, and still registered. Both bodies pass at this head.

Checks: focused Swift lane Executed 76 tests, with 0 failures; check:xctest-selection (527 methods, 0 unreachable); build:xcuitest; check:native-dupes 0 new clones; check:affected --run --head 3bbe6a88 — all runnable checks passed, exactCommit=true, receipt .tmp/validation/run-mBST6A/receipt.json. The transport path changed, so the short live route was repeated on mad2026-sol with this build: three sends of one id, then 64 fills to prune it, and the resend got COMMAND_ALREADY_FINISHED with the pasteboard unchanged.

Note for lane planning: the PR iOS simulator lane selects only the 5 shared-branch tests, so these admission tests run on the macOS host lane and the nightly, not the PR simulator lane. The host lane cannot enter automation mode on this machine.

@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 5 files (changes from recent commits).

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

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

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 8 new findings on 3bbe6a8. The verdict and the full list are in the summary comment.

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Round-2 independent review: needs changes. Reviewed e1c2cae64d14ec04da1d03a92782f110578e7835 → 3bbe6a889005ec13c898b347b21989b57a243e0a and affected runner/daemon interactions.

P1 remains — FIFO marker eviction makes a completed mutation executable again. apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift:232–233. Complete a dispatched tap T, then complete 4,159 distinct dispatched commands: its outcome is pruned but its marker still refuses a resend with COMMAND_ALREADY_FINISHED. Complete one more distinct dispatched command: inserting the 4,097th marker removes T via removeFirst(). Resend T: accept falls through to claim at line 88 and returns execute, so the transport enqueues the tap again. I reproduced these exact verdicts with the actual journal source in a standalone Swift probe. Marker access does not refresh FIFO retention.

The disposition explicitly acknowledges this residual behavior. The PR body says “bounded closed-id window,” and ADR 0005 specifies 4,096 ids, but neither establishes a safe replay/admission boundary; the ADR still requires “Never execute a commandId twice.” Thus the original correctness failure is postponed, not resolved. Random UUIDs explain why an ordering watermark is unavailable, but do not make silently forgetting executed ids safe. Fix: retain admission history for the runner's admission lifetime, or enforce a typed refusal/session-generation boundary before dropping markers that could still be resent. Add a regression crossing the marker limit. If the intended contract instead permits duplicate execution outside this window, that is a deliberate weakening of the once-per-id requirement that needs explicit acceptance and consistent wording.

P2 resolved. accept(command:registeringJoin:) holds the journal lock through registerJoiner. The latter only appends under inFlightCommandLock; the only other production acquisition drains the waiter array and unlocks before invoking completions. No production path acquires the journal lock while holding inFlightCommandLock. A source-level probe forced both finish and fail to race registration while closing immediately pruned the entry: both waited for registration, delivered the joined answer once, and left no waiter.

Other requested interactions checked:

  • COMMAND_ALREADY_FINISHED remains ok: false and surfaces as AppError.code and details.runnerErrorCode = COMMAND_ALREADY_FINISHED, with dispatched: unknown, no retriable flag, and structured-failure classification. Direct assertions confirmed no success, busy resend, pre-send refusal, or restart classification. The read-only resend budget is one attempt for this error.
  • A marker-only id reports notAccepted. Daemon charge settlement does not treat that as terminal; lost-reply recovery invalidates conservatively and fails a mutation as runner_reply_lost, rather than reporting success or replaying it. The runner-session-types.ts comment saying “never held” is now stale, but its state handling remains conservative.
  • Both fast busy refusals precede markDispatched. An undispatched refusal can be re-admitted, including after pruning; once dispatch is marked, the entry stays terminal. The command execution queue delivers the original result before executing its next queued command.

Validation in a disposable checkout: pnpm install --frozen-lockfile, pnpm build, and pnpm check:xctest-selection passed (527 declared methods, zero unreachable). Six affected apple-runner TypeScript suites passed: 156 tests (dispatch disclosure, session types, busy resend, error classification, command recovery, recovery wiring). The initial unit-core invocation selected no tests; the actual run used --project apple-runner. The Swift probe used the production journal and extracted register/deliver methods with minimal wire fixtures; it is not a full native runner lane or device run. No CI wait. Both checkouts remain clean; no source edits, commits, or pushes.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Thanks for the update. At 3bbe6a8 the join race and the fast-refusal re-admission are fixed, but a lost-reply resend of a read still fails, and this change makes that failure last longer. I read the code only. I did not run Swift tests or a device, so the fail-then-pass counts, the 76-test lane and the check:affected receipt are unverified.

The earlier finding on RunnerTests+CommandJournal.swift:96 still stands, and this delta widens it. The daemon mints one commandId and resends a read with the same id after a lost reply (index.ts:143-160). A snapshot is marked dispatched and keeps no journal body (Models.swift:283), and the same holds for screenshot and any read over 16 KB. So the resend gets COMMAND_ALREADY_FINISHED at :96-99. New here: pruneIfNeeded (:229-234) also writes a closed marker for a dispatched read, so the refusal now outlives the 64-entry window, where base re-claimed the id. A snapshot or screenshot whose reply is lost now fails with ok:false, and the hint tells the user to run snapshot, the command that just failed. The rule should be: a repeat of a finished commandId may skip execution only when it gets the recorded outcome, or when re-running could change device state. Please declare that as one read trait on CommandTraits, next to retainsJournalResponseJson, for the appRead and runnerCaptureRead groups and presentedSurfaceQuery. Do not infer it from retryOnSessionLoss. Use that one trait at both places that spend an id: accept re-claims a finished entry with no retained body when the command is a read, and pruneIfNeeded writes closed markers only for non-read commands. Then COMMAND_ALREADY_FINISHED stays mutation-only. Please also amend the rejected-gating paragraph at ADR 0005:139, and add a listener test through the transport route (not the execute seam) that sends snapshot twice with one commandId and expects two executions and an ok second reply.

The admission route at Transport.swift:176 changed again in this delta, but the posted live run covers only a pasteboard mutation repeat and quotes no runner.log excerpt. The read repeat, which is the route that regressed, was never run. After the fix above, please post a simulator run at the final head that sends each command twice straight to the listener with one fixed commandId, and quote both runner.log excerpts. For tap or pasteboardWrite, I expect one AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED, the repeat logged as AGENT_DEVICE_RUNNER_COMMAND_ALREADY_FINISHED, and device state unchanged. For snapshot -i, I expect two AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED markers and an ok second reply carrying nodes, with no COMMAND_ALREADY_FINISHED.

Could the same trait make this simpler overall? If "can re-running change device state" is declared once, reads never spend an id because they re-run or replay, and only dispatched mutations spend one and leave a closed marker. That removes the read regression, makes the closed-id set mutation-only (smaller, and the FIFO-eviction residual then applies only to mutations, which the daemon sends once at index.ts:144-146), and avoids adding another guard around dispatched for the read case. The ADR's rejected read-only gating would become the adopted rule, scoped to finished entries with no retained body. Does anything else need to change first besides amending ADR 0005:139?

Smoke Tests (apple.yml smoke-apple) is still running, and I have no failure excerpt, so the outcome at this head is unknown. That job drives real runner commands through Transport.handleRequestBody into RunnerCommandJournal.accept, which this PR changes, so please read it before merge if it fails. All other checks pass.

On the open inline threads: these still apply as P2 and need an answer: weak 'already stopped' assertion, test uses a mutating command, ADR 'terminal' wording, replay loses error code, ADR 'stays open' wording and ADR 'once per request' wording. Five lower-priority threads still apply as well. These do not apply and can be resolved: uptime stamp on replay (the journal JSON is stored unstamped, so replay pairs current uptime with the current receipt); fixed at this head: atomic join registration; 4160-command window (no producer reaches it, so please only state the bound at ADR:132); pruned-id test (prune removes the refused entry, and an optional status assertion would only add clarity); RUNNER_BUSY/WEDGED (both return before markDispatched); scroll-coalesce waiters (fixed in TransportTests.swift:274 and :289); empty-id join (accept returns .execute for an empty id); unstamped failures (all ok:false replies are unstamped today); replayed shutdown (the terminal branch passes shouldFinish=false); pruned status (base already answered notAccepted); unpruned gap (a finished entry still refuses from the entry); HTTP status of refusals (structured refusals already travel as 200 with ok:false).

Before merge, the read trait, the ADR amendment, the listener snapshot-twice test and the two-route live run at the final head are needed.

…ion boundary

The daemon holds no per-id instant after which it never resends a commandId: its
resend budgets are attempt counts and per-exchange timeouts redrawn per attempt,
and a restart-and-replay posts the same id after a replacement boot. Tieing
marker retention to such a deadline would need a wire-contract change, so the
journal keeps a spent id for the life of the runner process and the 4096 cap is
named for what it is: a memory safety valve. The eviction site and ADR 0005 now
state the residual bound, and one test pins both halves of the rule — refused
for the whole window, oldest id released by exactly one close past it.

@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

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 7 new findings on b9aa7ae. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Disposition for the round-2 P1 bound. Head b9aa7ae85daeb6115b1b53a5093f9deb03ae6c21. Nothing on the transport path changed, so no new live run.

The deadline option is not available without a contract change, so this PR does not add one. I checked every path that resends a commandId:

  • The read-only resend loop budgets attempts, not an instant: 8 for RUNNER_BUSY, 3 for transport failures, 200ms doubling to a 1s cap (packages/apple-runner/src/index.ts:122-129). No deadline argument reaches retryWithPolicy; only the caller's abort signal ends it (index.ts:163-165).
  • The connect loop and the startup route bound each attempt by a timeoutMs redrawn per exchange (runner-startup-transport.ts:116-128, runner-exchange.ts:132), not one absolute instant.
  • restartSessionAndRunCommand re-sends the same command object after a full replacement boot (runner-lifecycle.ts:367-391), capped by MAX_RUNNER_RECYCLES_PER_REQUEST = 1 in a process-local map keyed per request (runner-recycle-ledger.ts:12,27-36). That boot's budget is DEFAULT_RUNNER_START_BUDGET_MS = 10 * 60_000 (runner-start-budget.ts:33), so a replay can legitimately land minutes after the first send.
  • Nothing on the wire is time-bounded: the body is JSON.stringify(command) with no timestamp or attempt field (runner-transport.ts:110), the Swift Command decodes no deadline (RunnerTests+Models.swift:222-262), and the only headers are content type and the token (runner-contract.ts:475,495-497).

The runner's own requestTimeout bounds request arrival, not in-flight duration, and is cancelled once the body is complete (RunnerTests+Transport.swift:9,412-419). So there is no per-id "never resend after this instant" to tie retention to. A timestamp per marker would also not buy safety: a journal does not survive a runner restart, so a replay across a replacement boots a new journal that answers notAccepted for every id, and that case is governed by charge settlement, not by this set.

What this PR does instead. The 4096 cap is renamed to say what it is — closedCommandIdSafetyValve, "memory safety valve, not an admission boundary" — and the journal now keeps a spent id for the life of the runner process, with the valve the only release. The bound is stated at the eviction site in one line: a resend more than 4096 completed commands later can re-execute, and the daemon resends only after a lost response within one request deadline. ADR 0005 states the same residual bound, records that a deadline-based rule was rejected because it needs a contract change, and narrows the summary line to "never execute a commandId twice within one runner process", which is what the mechanism actually guarantees. I also fixed the stale comment you flagged in runner-session-types.ts:336: notAccepted covers an id the journal never claimed or one whose entry was pruned and whose closed-id record was released; it remains non-terminal to the daemon.

Regression crossing the marker limit — testJournalRefusesASpentIdAcrossTheClosedIdWindowAndReleasesOnlyTheOldest pins both halves of the rule at the exact arithmetic (the first 63 closes only fill the 64-entry window and leave no marker): 4096 dispatched closes behind a spent tap, resend still refused COMMAND_ALREADY_FINISHED; one more dispatched close releases exactly the oldest id, and the next send of it is execute. That execute after the valve is the accepted bound, now named in code, the ADR and the body rather than only acknowledged.

Checks at this head: focused Swift lane (journal, transport, dispatch, execution, recording, main-thread, models, alert) Executed 81 tests, with 0 failures; check:xctest-selection 528 methods, 0 unreachable; native-dupes 0 new clones; doc prose/links; apple-runner vitest 878. pnpm check:affected --run --head b9aa7ae8 — all runnable checks passed, exactCommit=true, receipt .tmp/validation/run-Pfm639/receipt.json. First run of that gate failed only on apple-platform-output-guard.test.ts, whose 20s fake-harness timeout for record is load-sensitive (29.6s under gate load, passes in isolation); the rerun was green.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Coordinator check of round 3 (3bbe6a8890..b9aa7ae85d), done in place of a third full review per the round cap:

  • I accept P1 as a stated bound, not a defect. The wire carries no per-id deadline, so a retention rule based on time would need a contract change that is outside this PR. Within one runner process, the window refuses a spent id until 4096 later dispatches have closed. The daemon resends an id only after a lost response, so it never resends one that late. The new test pins both sides of the window.
  • ADR 0005 meaning check: the summary line now says "within one runner process", which matches the code. The bound paragraph and the rejected-deadline paragraph agree with the eviction comment. The stale notAccepted comment in runner-session-types.ts is fixed.
  • Non-blocking wording nit in ADR 0005: "A journal that survives a runner restart is a new journal" contradicts itself. "A runner restart starts a new journal" says the same thing plainly. Fold it into any later push.

No blocking findings. P2 was resolved in round 2.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

I reviewed b9aa7ae against the earlier review at 3bbe6a8. One finding from it is still open, and there is one new ask for live evidence. All 19 checks pass, and no conflicts are known. Before merge, the read-repeat regression must be fixed and the simulator run posted.

A lost reply to snapshot, screenshot or a large read now fails with COMMAND_ALREADY_FINISHED. The daemon resends the same commandId after socket hang up or fetch failed (runner-error-classification.ts#L395-L408 drives the resend loop). The runner marks the read dispatched (RunnerTests+CommandDispatch.swift#L195) and keeps no body for it. So accept answers the resend with the recorded outcome, and the caller gets a non-retried ok:false. After 64 more commands, pruneIfNeeded writes a closed marker, so the refusal lasts for 4096 ids. The hint then tells the user to run snapshot, the command that just failed. On base, the same resend succeeded, so this is a regression on the read path every run uses. The rule: a repeat of a finished commandId may skip execution only when the runner answers it with the recorded outcome, or when re-running could change device state. Please declare one read trait on CommandTraits next to retainsJournalResponseJson, set by the appRead and runnerCaptureRead groups and presentedSurfaceQuery. Read it at both sites that spend an id: accept re-claims a finished read entry with no retained body, and pruneIfNeeded writes closed markers only for non-read commands. Please amend the "Rejected: gating re-admission on the runner's read-only classification" paragraph in ADR 0005 to match. A listener test that sends snapshot twice with one commandId should expect two executions and an ok second reply. That test fails at this head, and green CI does not cover it.

This PR changes the admission route at RunnerTests+Transport.swift#L176 that every runner command takes. The posted live run covers one pasteboard repeat and quotes no runner.log excerpt. The read repeat, which is the route that regressed, has no simulator run. After the fix, please post a simulator run at the final head. Send tap or pasteboardWrite twice straight to the listener with one fixed commandId. Quote one AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED, the repeat logged as AGENT_DEVICE_RUNNER_COMMAND_ALREADY_FINISHED, and unchanged device state. Then do the same for snapshot -i. Quote two AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED markers, an ok second reply that carries nodes, and no COMMAND_ALREADY_FINISHED.

Not blocking: in ADR 0005 line 137, "A journal that survives a runner restart is a new journal" contradicts itself, so "A runner restart starts a new journal." is clearer, and you can take or leave it.

The open inline threads from other reviewers still apply for these topics: dispatch assertion in test, recordStop test route, ADR terminal wording, failure code lost, ADR "stays open" wording and "once per request" wording. Five lower-priority threads also still apply.

These threads do not apply, so please resolve them: r4435424717 (the journal stores the unstamped response, so a replay pairs current uptime with the current receipt); r4435768779 (the 4096-id bound is stated and accepted, and no producer resends a mutation); r4435768783 (the .execute result comes from the claim at line 91, not line 100); r4435917782 (a busy refusal returns before markDispatched, so it never targets a closed id); r4435917786 (closedCommandIdSafetyValve is named as the one release in the same doc block); r4435917800 (a restart-and-replay lands on a fresh journal, though I did not trace runner-lifecycle.ts to confirm it); r4435461766 (a busy or wedged refusal returns before markDispatched); r4435781274 (every ok:false reply is unstamped today); r4435781280 (the terminal branch passes shouldFinish=false); r4435936186 (base already answered notAccepted for a pruned id, and the daemon treats it as non-terminal).

This was a read-only review. I did not run the Swift lanes, check:affected or a simulator, so the 81-test count and the receipt are unverified. I also did not open the earlier comments that quote the pasteboard run.

@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 11 files (changes from recent commits).

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

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

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 7 new findings on 3f8401d. The verdict and the full list are in the summary comment.

…ancy

The daemon marked the runner drained on any structured failure it did not classify as
occupancy. A repeat refused by the command journal is that shape: the journal answered
from its record without executing, so it observed nothing about the main thread, and a
refusal landing mid-drain was read as proof the abandoned work had drained. Retention and
handoff act on that report, so a runner still grinding XCTest work became eligible for
reuse. Occupancy is now one three-valued verdict from the classifier, keyed on the typed
runner code, and the silent reading leaves the report standing. The runner keeps stamping
failures as it did: a stamp on a failure would move every other failure route, not this
one.
Review round 4 cleanups, none of them a behavior change. The admission doc names the
selector the code has, accept(command:registeringJoin:), and so does ADR 0005. The
COMMAND_ALREADY_FINISHED code comment now states the disclosure the daemon actually
publishes, unknown. ADR 0005 defines terminal as finished execution rather than an effect
on the device, says a pre-dispatch refusal closes its entry as failed while leaving the id
reusable, says a runner restart starts a new journal, and scopes the 4096 bound to
lost-response resends, since an id refused before dispatch never enters the window. The
daemon comment keeps one clause for notAccepted, because the wire never distinguishes a
still-marked pruned id.

In tests: the busy-resend test drops a stray try and its read-only claim, the
pruned-refusal test asserts its own prune precondition, the closed-id window test proves
the valve releases exactly one id by still refusing the next-oldest, and the duplicated
transport comment goes. The already-finished refusal now builds two whole messages rather
than splicing a clause into another sentence.
@thymikee
thymikee force-pushed the t3/sm9-runner-journal-admission branch from 3f8401d to bb83f90 Compare November 1, 2026 15:26

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 9 new findings on bb83f90. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Round-4 disposition: the occupancy defect is fixed and the queued review nits are folded in. Head bb83f90803fb7cbc6d93e0ae4db49247fd66f9d0. No live run: the transport path's Swift is comment- and test-only at this head and the transport itself is unchanged (git diff b9aa7ae8..HEAD touches no transport source line).

The defect, and why the fix is daemon-side. A .terminal repeat is answered from the journal, so jsonResponse stamps neither uptime nor occupancy (both are guard ok else { return self }, RunnerTests+Models.swift:393, :403). The daemon's occupancy rule at b9aa7ae8 was two-way: occupied for RUNNER_BUSY/MAIN_THREAD_TIMEOUT, drained for any other structured failure. A refusal served mid-drain therefore fell into the second branch and told the daemon the abandoned work had drained, and retention and handoff act on that report (runner-session-types.ts:178, :388; runner-session.ts:700, :718). The asymmetry was new with this PR, because before it every reply to a repeated commandId came from a real execution.

I took the daemon branch you directed rather than stamping the terminal reply, for the reason in the code: stampingCurrentMainThreadBusy is on the shared Response, so a failure-carrying stamp would change what every other failure route sends, and ok: false is documented as "a refusal is itself the busy signal and needs no stamp". A refusal that executed nothing genuinely does not know the thread's state, so silent is the honest reading, not a fallback.

The fix. Occupancy is now one verdict from the classifier that owns runner error meaning: runnerMainThreadOccupancyReport returns occupied | drained | silent (runner-error-classification.ts:667-687), keyed on the typed details.runnerErrorCode (COMMAND_ALREADY_FINISHED_RUNNER_CODE), never on message text, and silent writes nothing. recordUnansweredRunnerExchange applies that verdict instead of deciding it (runner-exchange.ts:233-238). isRunnerMainThreadOccupiedError is replaced at its one caller rather than left beside a rival reading; isStructuredRunnerFailure is now a type guard so the code check narrows. ADR 0005 records the three readings.

Tests. runner-session-close.test.ts:290 sets runnerMainThreadBusy = true, serves the terminal-repeat refusal, and asserts the mark stays true — verified to fail on b9aa7ae8 on that assertion, and it validates the rejection's runnerErrorCode so a preflight or transport rejection cannot pass it for the wrong reason. runner-error-classification.test.ts pins the verdict per typed code plus the Swift wire literal. The busy, MAIN_THREAD_TIMEOUT, unstamped-healthy and served-failure-clears cases stay green. A new bot thread asked for that validator; it is folded into the fix commit.

Queued nits, one commit, no behavior change. The admission doc now names accept(command:registeringJoin:). The RunnerTests.swift comment states the disclosure the daemon really publishes (unknown). ADR 0005 defines terminal as finished execution rather than an effect on the device, says a pre-dispatch refusal closes its entry as failed while the id stays reusable, says a runner restart starts a new journal, and scopes the 4096 bound to lost-response resends since a refused-before-dispatch id never enters the window. The daemon comment keeps one clause for notAccepted. The stray try is gone, the busy-resend test no longer claims read-only scope, the pruned-refusal test asserts its own notAccepted prune precondition, the window test proves the valve releases exactly one id by still refusing the next-oldest before anything re-claims, the duplicated transport comment is deleted, and the refusal builds two whole messages.

Checks at this head. Focused Swift lane (journal, transport, dispatch, execution, recording, main-thread, models, alert): Executed 81 tests, with 0 failures. check:xctest-selection 528 methods, 0 unreachable. check:native-dupes exit 0, no new clones. check:doc-prose/doc-links clean. apple-runner vitest 881 (up 3). pnpm check:affected --run --head bb83f90803 — all runnable checks passed, exactCommit=true, receipt .tmp/validation/run-OuazbE/receipt.json. The Swift tree is identical to the one the lane ran.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Thanks for the update. At bb83f90 the validator test now asserts details.runnerErrorCode === 'COMMAND_ALREADY_FINISHED', so that part of the earlier round is fixed. The main problem from the earlier review is still open, and no simulator run has been posted yet.

The journal still answers a repeat of a read command, and this was raised at 3bbe6a8 and b9aa7ae. The daemon resends read-only commands (snapshot, screenshot, reads) with the same commandId after a lost reply (index.ts:143-160). The runner marks the first send dispatched (CommandDispatch.swift:195), so accept answers the resend with .terminal (CommandJournal.swift:102). For snapshot and screenshot that is the non-retried COMMAND_ALREADY_FINISHED, and pruneIfNeeded keeps the id refused for 4096 more commands. On base, a snapshot whose reply was lost re-ran and succeeded. Now it fails on the read path every agent run uses. A read that threw (for example MAIN_THREAD_TIMEOUT) is closed with a code-less payload (:146). Its resend replays that payload (:255), and the classifier reads it as drained, so the daemon can clear the busy mark while abandoned work still runs. The new silent branch covers only COMMAND_ALREADY_FINISHED. The rule the code must satisfy: the journal never answers a repeat of a read-class command (appRead, runnerCaptureRead, presentedSurfaceQuery), and a read never spends its id. Please declare one read trait on CommandTraits next to retainsJournalResponseJson, set by those groups, and not inferred from another field. Both sites that spend an id must read it: accept re-claims a finished read entry instead of returning .terminal, and pruneIfNeeded writes closed markers only for non-read entries. Then .terminal is reached only by a mutation repeat. Please replace the "Rejected: gating re-admission on the runner's read-only classification" paragraph in ADR 0005:142 with this rule, and add a listener-route test that sends snapshot twice with one commandId and expects two executions and an ok second reply. That test fails at this head.

Every runner command takes the admission route at Transport.swift:176-202, and this PR changes it. No simulator run at bb83f90 is posted, and the read repeat that regressed has never run on one. After the fix above, please post a simulator run at the final head. Send each command twice straight to the listener with one fixed commandId and quote both runner.log excerpts. For tap or pasteboardWrite, show one AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED, the repeat logged as AGENT_DEVICE_RUNNER_COMMAND_ALREADY_FINISHED, and unchanged device state. For snapshot -i, show two AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED markers, an ok second reply that carries nodes, and no COMMAND_ALREADY_FINISHED.

Could the daemon avoid special cases for journal replays? If the journal never answers or spends a read-class id, .terminal is reached only by a mutation repeat, which the daemon never sends in-session. Then the replayed-failure occupancy question cannot arise on the read path, and the COMMAND_ALREADY_FINISHED to silent line can stay as the one typed case. The first step is amending ADR 0005:142 to accept read-trait gating.

On the open inline threads: the one on the replayed failure read as drained (r4436220817, r4436258832) still stands and the read trait closes it. Five lower-priority threads also still apply. These threads do not apply and can be resolved: r4436212225 is fixed at this head; r4436220821 and r4436258825 because the shutdown delivery always calls finish(), which cancels the listener; r4436220835 because replaying the recorded recordStart success is the one-execution contract; r4436220837 because the journal stores the unstamped response; r4436220844 because that gating predates this delta; r4436258837 because the springboard snapshot has no device-visible effect; r4436258838 because shutdown is not inline and does spend its id; r4436258841 because no state records an error before markDispatched; r4436258850 because accept refuses a held id before claim.

The only check not passing is a review-app check waiting for action. It is not a build or test job. The other 18 checks pass, but green CI does not cover the read repeat, because no test sends a read twice through the listener. I did not run the Swift lanes, vitest or a simulator, so the 81-test and 881-test counts are unverified. I also did not measure how often the daemon's per-exchange timeout lands in the window that the MAIN_THREAD_TIMEOUT replay needs.

Before merge, please add the read trait with the ADR change and the listener test, then post the simulator run at that head.

A repeat of a `commandId` the runner already finished is answered from the journal's record, so that
answer has to carry what the answer the caller lost carried. Two things did not: a command that closed
by throwing was recorded without its wire code, so a replayed `MAIN_THREAD_TIMEOUT` reached the daemon
as an unclassified refusal and cleared the main-thread occupancy report at the moment the timed-out
command was what occupied the thread; and a replayed `shutdown` answered without the flag that ends
the runner's own test run, so the runner kept listening until disposal escalated to a signal.

Both now derive from the same owner as the live answer: `RunnerTests.wireErrorCode(for:)` names the
code the transport encodes and the journal records, and one `finishesRunner` flag decides the finish
for every route a `shutdown` answer travels. The cross-language tie in
`runner-swift-settlement-fixtures.ts` reads the Swift declaration so the daemon's occupancy verdict and
the code the runner writes for it cannot drift apart silently.

@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 8 files (changes from recent commits).

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

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

@thymikee
thymikee force-pushed the t3/sm9-runner-journal-admission branch from 9f017aa to c2ca3ae Compare November 1, 2026 17:06

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 5 new findings on c2ca3ae. The verdict and the full list are in the summary comment.

`terminal` answers a repeat from the record the journal holds, so which answer ends the runner is that
record's fact. Deriving it from the repeat's own request let a caller end the runner on some other
command's answer: a spent `commandId` reused under the name `shutdown` received that end, while a
recorded `shutdown` answer named as another command withheld it. The verdict now carries
`answersShutdown`, read from the command the entry names, and ADR 0005 states the ownership.
The live path finishes the runner only on a `shutdown` that succeeded, so the journal's replay has to
read the outcome before deciding. `terminal` now gates `answersShutdown` on the recorded answer being
`ok`: replaying a refused `shutdown` kept asking the runner to finish, which would end a runner whose
caller was still being told it stayed up.

States the two ownership facts this round's review turned up, and neither needs a guard: only the
`execute` verdict can reach `markDispatched`, so no busy refusal can follow the mark for the command it
refuses; and the request's `close` is what ends the deadline bookkeeping, so the journal answer is the
only writer for that connection. ADR 0005 records what `notAccepted` costs a lost charge.

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 6 new findings on a08339f. The verdict and the full list are in the summary comment.

A `commandId` the runner refuses before dispatch is claimed again, and the transport keyed its joiners by
the id, so the delivery of that refusal could answer the sends that joined the execution which followed it
— one execution's answer handed to another execution's callers. The journal now mints an identity per claim
and hands it with the verdict, and the transport keys waiters by it.

`markDispatched` sets only its own mark, so the retention order keeps recording claim order, and both it and
the state transitions go through one locked mutation. The main-thread-timeout hint keys on the error the
watchdog throws rather than on the code derived from it. ADR 0005 states the per-claim identity, and the
classification test pins the published shape of `COMMAND_ALREADY_FINISHED` the way its siblings are pinned.

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 7 new findings on 6db42ef. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026 •

Copy link
Copy Markdown
Member Author

Review follow-up at c252743bbb8daa486dce0c6651d8551b216d8d4b.

  • Finished reads now re-execute under the explicit CommandTraits.isReadOnly declaration on appRead, runnerCaptureRead, and presentedSurfaceQuery; pruning records only non-read ids, and a mutation renamed to a read still receives its original outcome.
  • The WIP is retained and completed: one error-payload owner preserves code and hint; dispatch disclosure has a real-send fixture; outcome/occupancy comments and the refusal hint are corrected.
  • The existing atomic-join/generation and successful-shutdown behavior remain covered, and the pre-dispatch refusal test now proves the recorder was untouched.
  • The marker-bound and unlocked-token reports were false positives: the loop leaves 4096 markers after insertion, and token minting already happens under the journal lock.

Fail-first on the pre-fix working tree: the read-class matrix fails for all eight classes, the second HTTP snapshot returns COMMAND_ALREADY_FINISHED, and the timeout replay loses its hint. Green: 80 focused iOS XCTests, 4 follow-up tests at this head, 77 focused TypeScript tests, XCTest selection, both production XCUITest targets; the foreground exact-head affected gate is still waiting on the shared host lock and is not claimed passed.

Live at this head on local/A6F3591A-75DC-4783-8A35-604EAAB7C355 (mad2026-sol): worktree build and daemon, state /private/tmp/pr4140-live-final-state; bound CLI open Settings → snapshot → tap About → close, all successful, runner PID 22219 and its daemon exited. The production build-data directory is inside this worktree's .tmp because the runner refuses automatic cleanup of an override elsewhere.

Direct HTTP listener evidence from the same device and final-head unit build:

2026-11-01 22:25:58.409807+0100 AgentDeviceRunnerUITests-Runner[10188:181452051] AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=snapshot commandId=snapshot-twice
2026-11-01 22:25:59.617993+0100 AgentDeviceRunnerUITests-Runner[10188:181452051] AGENT_DEVICE_RUNNER_COMMAND_COMPLETED command=snapshot commandId=snapshot-twice ok=1
2026-11-01 22:25:59.641180+0100 AgentDeviceRunnerUITests-Runner[10188:181452048] AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=snapshot commandId=snapshot-twice
2026-11-01 22:26:00.792006+0100 AgentDeviceRunnerUITests-Runner[10188:181452051] AGENT_DEVICE_RUNNER_COMMAND_COMPLETED command=snapshot commandId=snapshot-twice ok=1
2026-11-01 22:26:01.854915+0100 AgentDeviceRunnerUITests-Runner[10188:181452051] AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=pasteboardWrite commandId=pasteboard-twice
2026-11-01 22:26:01.858014+0100 AgentDeviceRunnerUITests-Runner[10188:181452052] AGENT_DEVICE_RUNNER_COMMAND_COMPLETED command=pasteboardWrite commandId=pasteboard-twice ok=1
2026-11-01 22:26:01.867407+0100 AgentDeviceRunnerUITests-Runner[10188:181452048] AGENT_DEVICE_RUNNER_COMMAND_ALREADY_FINISHED command=pasteboardWrite commandId=pasteboard-twice

Both snapshot responses are ok with nonempty nodes; the repeat write proposes a different value but the pasteboard remains journal-first-write. Four final-head listener/admission tests pass.

The earlier Smoke failure is a 30-second host swiftc timeout compiling video-validator, before device dispatch. Its video/cache source blobs are identical to successful main 88558fb45dc140a227b9e4adf1d24f35b65a1d5d, where smoke:capture-close passed; the authorized single rerun passed, confirming the failure was transient. No timeout or production-code workaround was added.

Full PR scope relative to f7879cf2: 18 files (9 production, 6 tests, 1 fixture, 2 docs); production +450/-164, with tests/fixtures/docs separate. Local macOS UI automation could not initialize, so simulator evidence is distinct. This round used a cold self-review without a new independent reviewer; hosted checks/reviews remain authoritative. No merge or label change.

@thymikee thymikee changed the title fix(ios): let the command journal decide admission once per commandId fix(ios): prevent duplicate mutations and preserve read retries Nov 1, 2026

@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 12 files (changes from recent commits).

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

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

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 4 new findings on c252743. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Cubic follow-up at 25f05acac396fa142f42bfc4be4606f34c866b0c:

  • r4437466901 is real: the daemon's read-only policy resends querySelector with the same id, while the runner's selector-resolution trait had omitted isReadOnly. The trait now owns the opt-in; no command-name check was added to journal admission or pruning. The separate session-loss retry policy stays independent.
  • r4437466907 is fixed: the pasteboard test snapshots and restores .items, preserving non-string content.

Fail-first against c252743bbb: 3 tests, 4 failures—completed selector success/failure replays, a spent pruned selector id, and an HTTP repeat returning stale found: true. With the fix, the focused iOS Swift lane passes 81 tests, 0 failures. The listener now returns found: false for its second query and logs both executions:

2026-11-01 22:45:48.813104+0100 AgentDeviceRunnerUITests-Runner[11423:181782024] AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=querySelector commandId=selector-twice
2026-11-01 22:45:50.061212+0100 AgentDeviceRunnerUITests-Runner[11423:181770750] AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=querySelector commandId=selector-twice

pnpm check:xctest-selection passes (540 declarations, none unreachable); pnpm build:xcuitest passes for iOS and macOS. The exact-head affected gate is running in the foreground but still waiting on the shared host lock; no pass is claimed yet.

Live at this head on mad2026-sol, with the worktree build and private state /private/tmp/pr4140-selector-live-state: open Settings → snapshot → tap General → close, all successful; runner PID 24536 and the private daemon exited. Both new threads are replied to and resolved. CI is running at this head; no merge or label change.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Native duplicate fix at f4185176c0ae876f8d60cf5e435a643eac92503d.

Reproduced the Repo Guards failure locally with pnpm check:native-dupes: 77 clones, 2 new. Both new Swift clones were in RunnerTests+TransportTests.swift: pasteboard setup at 486–494 versus selector setup at 530–538, and snapshot setup at 509–517 versus selector setup at 528–536.

The three tests now use one private withTransportClient helper that owns listener startup and client/listener cleanup. No allowlist or baseline was changed. The same native duplicate check now passes at this exact head: 75 clones, zero new. The three affected iOS listener tests pass, and XCTest selection remains 540 methods with none unreachable.

This is test-only: production source is identical to 25f05acac396fa142f42bfc4be4606f34c866b0c, where the 81-test Swift lane, both production builds, and private-state live route passed. The exact-head affected gate is still running in the foreground and waiting for the shared host lock; CI is running at this head. No merge or label change.

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 7 new findings on f418517. The verdict and the full list are in the summary comment.

Comment thread website/docs/docs/commands.md Resolved
@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Apex follow-up at 9c13f73ce06c9bf90b4b84676f9f7b31341af402:

  • Added .changeset/ios-runner-journal-admission.md with an agent-device patch entry for the new refusal, unknown dispatch disclosure, and snapshot-before-retry guidance, matching recent patch changesets.
  • Made isReadOnly mandatory at every trait construction, so a new trait cannot silently inherit a mutation classification; read re-admission stays independent of body retention, and alert accept/dismiss remain non-read.
  • Removed the unused wireErrorCode helper: the shared error-payload builder now owns the timeout predicate, code, and hint together, preserving the original error identity check.
  • Added the Swift commandAlreadyFinished literal to the cross-language occupancy contract. Deliberately changing that literal fails exactly one test (1 failed, 32 passed); restoring the exact file passes all 33 classification tests.
  • Kept the bounded source-contract reader rather than adding a general Swift parser. The native tests independently exercise the behavior. The hypothetical stale-transition and reverse-lock-order paths have no production producer: journal transitions are synchronous on the serial command queue, late watchdog work updates its receipt/drain state, and waiter delivery unlocks before callbacks.

Validation at this head: focused Swift 81 passed, focused TypeScript 77 passed, check:xctest-selection passes, build:xcuitest passes for iOS and macOS, and native duplicate detection remains 75 clones, zero new. The exact-head affected gate is still running in the foreground and waiting on the shared host lock; no pass is claimed.

Live on mad2026-sol with this build and private state /private/tmp/pr4140-apex-live-state: open Settings → snapshot → tap General → close, all successful; runner PID 57430 and its private daemon exited. The seven unresolved discussion threads in this round are receiving a fix SHA or a concrete reason and are resolved. No merge or label change.

@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 5 files (changes from recent commits).

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

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

@thymikee

thymikee commented Nov 1, 2026 •

Copy link
Copy Markdown
Member Author

Review follow-up at 290acfd0b44ecd1e7cb972c080d2f4bc06f233e2:

  • Added isReadOnly to ExpectedTraits, every command row and alert-action row, and the shared assertion; each expected value is explicit and independent of the production trait factories.
  • Deliberately setting selectorResolution.isReadOnly to false fails the exhaustive test at {"command":"querySelector"} isReadOnly; restoring the exact production file passes the full focused Swift lane: 81 tests, zero failures.
  • pnpm format, pnpm check:xctest-selection, and pnpm build:xcuitest pass; native duplication remains 75 clones, zero new, without allowlist or baseline edits.
  • The exact-head affected gate passed, receipt run-r5RX1v: exactCommit=true, checkoutChanged=false. Format, lint, typecheck, layering, Fallow, native duplication, build, related tests, unit tests, command docs, docs links/prose, XCTest selection, and packaged Swift all passed; provider integration was deduplicated into the unit suite. Hosted Coverage, Integration Tests, and Repo Guards & Compatibility also pass at this SHA; Apple Smoke Tests is the only running check.
  • This commit changes only tests; the production build and private-state live route at 9c13f73 still apply: open Settings, snapshot, tap General, close, runner and private daemon exited.

Thread r4437579952 is fixed and resolved; all 86 threads are resolved, with no additional unresolved thread at the final head. No merge or label change.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Fixed the CI failure in 7c883d7aa617ca757de61304fb8b6cae957717ec: the selector test incorrectly assumed the runner host exposes the label “Agent Device Runner”. It now sends identical requests, holds the command queue, and observes completed → accepted → completed, proving fresh execution independently of host-app labels; changing the trait back to replay fails the admission expectation. Focused Swift: 81 passed; check:xctest-selection and exact-head check:affected --run --head HEAD pass (run-p6s57A, exactCommit=true, checkoutChanged=false); native duplicates remain 75, zero new.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

At 7c883d7 I found no correctness problems, and the evidence you gave is enough. All 20 checks pass, including Apple Smoke Tests, which failed at an earlier head on a host-label assumption in a test that the last commit removed. There are no conflicts. I read the code and the gate output; I did not run the Swift lanes, vitest, check:affected or a simulator, so test counts and gate receipts are yours. The direct-listener runner.log comes from the c252743 build, not from 7c883d7. I accepted it because the journal and transport production code are identical from c252743 to head. The snapshot replies and the unchanged pasteboard are your statements, not quoted output.

Compared with the earlier review (#4140 (comment)), a thrown failure now keeps its wire code and hint, reads re-execute instead of replaying, waiters are keyed per claim, and the dispatch-disclosure row and docs are in. The correctness questions are settled. One design question remains open before merge: whether .terminal(answersShutdown:) is needed at all (https://github.com/callstack/agent-device/blob/7c883d7/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift#L147). The live delivery of a successful shutdown already calls finish() in sendResponse, even when the send fails (Transport.swift:134-145), and finish() is idempotent. So the field changes only which send calls finish() first, never whether the runner ends. Can .terminal carry only the outcome and replay with shouldFinish false, as at bb83f90? That would also drop the record-name check, the two shutdown-replay transport tests and one ADR 0005 sentence. If you keep it, please name the production state in which the original delivery does not finish. I would keep executionToken: the journal re-claims a finished read or a pre-dispatch refusal, so the waiter key needs a per-claim identity. The only smaller option is to hold the joiners in the entry and hand them back from finish/fail, which I do not ask for.

Not blocking, take or leave: journalRecordsDerivedCode in runner-swift-settlement-fixtures.ts (line 200) regex-tests Swift source text, so a behaviour-identical refactor turns it red and a discarded call keeps it green; keeping the RunnerWireErrorCode reads and relying on the Swift behaviour tests is enough. Swift isReadOnly and TS RUNNER_COMMAND_TRAITS.readOnly are two declarations of one classification that the admission rule needs to agree, so one parity test over every queued CommandType would catch the next drift. The new justifying comments (the closed-id proof in pruneIfNeeded, the terminal comment in Transport.swift, the executionToken doc) can shrink to the one constraint the code cannot show.

For the inline threads: #4140 (comment) (source-text regex) still applies as a P4 note above, and #4140 (comment), #4140 (comment) and #4140 (comment) (shutdown finish) apply only as the question above. The other threads do not apply: they are fixed at head or benign by design (pruning keeps non-terminal entries, the serial queue bounds the backlog, the single writer after inbound.close(), the 4096 window arithmetic, and mutation failures replaying with their own code), and they are already resolved.

Before merge, please settle the shutdown question: drop .terminal(answersShutdown:) or explain the state that needs it.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant