Skip to content

fix(capture-kit): make a completed durable record terminal - #4139

Open
thymikee wants to merge 9 commits into
mainfrom
fix/sm10-durable-capture-terminal
Open

thymikee wants to merge 9 commits into
mainfrom
fix/sm10-durable-capture-terminal

Conversation

@thymikee

@thymikee thymikee commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

Keep completed durable capture records terminal, fence destructive cleanup, and replay only validated kind-owned outcomes.

Terminal finish now consumes typed forceCleanup() results. Capture intent retains pending cleanup and preserves replay; disposal intent reports a typed teardown failure. One shared helper beside isConfirmedCleanup, exposed through capture-kit, owns cleanup retries for all four handles. App-log retries skip successful release steps.

34 files against merge-base: production +439/-159; tests/fixtures +1219/-11. This round: ten production files +81/-134; four test files +72/-22. No record routing change.

Validation

Base 2d27d0755e; tested head 8c436df63fce0a77dc3ee96773387e8fcdea1da5. The same three-file regression command: 4 failed / 26 passed on base plus tests; 30 passed at head. Eager-import plus regression suite: 912 passed. An initial eager-import failure was fixed by co-location.

AGENT_DEVICE_VITEST_MAX_WORKERS=1 pnpm check:affected --run --head 8c436df63fce0a77dc3ee96773387e8fcdea1da5 passed. Receipt .tmp/validation/run-XkPtW7/receipt.json: exactCommit=true, checkoutChanged=false. 1,212 related / 16,784 unit passed; one skipped.

No new live run was required because record routing is unchanged. Historical iOS serial smoke remains attributed to 2d27d0755e, with artifact retention enabled; it did not exercise disposal failure.

Cold self-review completed; no independent review was started this round. Coverage, Integration Tests and native CI lanes remain GitHub-authoritative. Review dispositions, route audit and validation evidence.

Fence equality proves ownership, not admission. Two callers that captured the
same live resource present the same fence, so serialization alone let a queued
cleanup or a late progress write drive a record another attempt had already
committed back to `open`, and a cleanup-pending outcome could leave it open
with its export already served.

The fence owner now revalidates the record's lifecycle while it holds the
fence. A completed record is terminal: a run that finds one is answered through
`onCompleted` before it receives a lease, so it cannot repeat native or
destructive work, and a transition that would reopen a committed record is
refused with the typed reason `resource-already-completed`. Recovery's copy of
`markCompleting` is gone; the one transition rule lives in
`durable-capture/fence.ts`.
@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 2, 2026 7:43am UTC

Request Review

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.67 MB 5.67 MB +2.9 kB
Package (unpacked) 5.67 MB 5.67 MB +2.9 kB
Package (download) 1.72 MB 1.72 MB +766 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 32.3 ms 35.5 ms +3.2 ms
CLI --help 71.6 ms 70.2 ms -1.4 ms

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 6 files

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 commented Nov 1, 2026 •

Copy link
Copy Markdown

Apex Sentinel

Verdict: approve

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

6 finding(s): 3 minor, 3 nit. Verdict: approve. reached max rounds (1).

🟡 Minor (3)

Where Finding
packages/capture-kit/src/durable-capture/transitions.ts:96 🆕 Terminal disposal failure is reported as a successful stop while the session keeps the live handle
packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts:59 🆕 A missing export file now reaches the stop-recovery warn branch, whose message says the manifest holds no response
packages/daemon/src/sessions/session-teardown.ts:155 🆕 Settling a committed-record refusal also skips the recording's leased-session retention
🔵 Nit (3)
Where Finding
packages/capture-kit/src/capture-admission/app-log-durable-resource.ts:6 🆕 LOG_BACKENDS duplicates the app-log backend union with no shared source of truth
packages/capture-kit/src/app-log-live-handle.ts:26 🆕 Terminal disposal retry is hand-copied across four live handles
packages/platform-apple/src/logs/start.ts:284 🆕 Apple app-log finish retry re-terminates and re-disposes the background process

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: 7 new findings on be20b30. 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.

Needs changes. Reviewed exactly merge-base f7879cf2d266ee1dd476d0342b2819bdeceb647b → head be20b30cee2477b7810ad6ee2cf05f94de744f94. Two parts of the coordinator's required terminal invariant remain unenforced, and the blanket finish refusal introduces a teardown regression.

  1. P2 — Revoke old leases or revalidate the persisted lifecycle on every write. packages/capture-kit/src/durable-capture/fence.ts:73-81. The new guard checks only this invocation's captured current. A run can retain its lease/progress callback and finish while still open; a subsequent run commits the record; invoking the old transition during that subsequent run still sees its own cached open and overwrites the completed manifest. A source-level runtime probe reproduced completed → open, with no typed refusal, while the later committing run held the fence. This is the stale-lease case required by the brief; the new progress test only checks a lease against its own completion. Revoke the lease when its run exits and validate the current persisted fence/lifecycle before publication, so escaped progress and completion writes cannot overwrite another run's committed outcome. Add coverage for two different leases, rather than only two transitions on one lease.

  2. P2 — Put descriptor cleanup inside terminal admission. packages/capture-kit/src/durable-capture/recovery.ts:195-203, also packages/capture-kit/src/durable-capture/finish-recovered.ts:105-113. Both unreattachable branches execute authority.control.cleanup(envelope) before entering the fence. If recovery selects an open record and waits for reattachment, another finish can commit it before recovery resumes with transport-not-reattachable or owner-unavailable. Recovery then still invokes native/destructive descriptor cleanup against the completed resource; the new onCompleted only suppresses the subsequent manifest write. A probe reproduced cleanupCalls: 1 with lifecycle already completed. Move the cleanup call and its transition together into the fence's run; the completed branch must settle/replay before cleanup is invoked. Cover both recovery entrypoints with a completion during authority acquisition.

  3. P2 — Replay the committed result for a queued recording finish. packages/capture-kit/src/durable-capture/transitions.ts:73-78. Two finishes that captured the same live handle previously both succeeded: createScreenRecordingLiveHandle memoizes its completed finish. Now the second receives non-retriable resource-already-completed, despite an available committed export. This has a production teardown path: daemon-runtime.ts:792-847 proceeds to session teardown after the request-drain deadline even if an export is still in flight; session-teardown.ts:195 can queue its disposal finish behind that export. When the export commits, teardown now fails, reports session_cleanup_incomplete, skips retainLeasedSessionRecording immediately after the await, and prevents the successful-teardown claim release. A probe using the real recording handle reproduced first-finish success, second-finish failure, and exactly one native finish. Supply a kind-owned replay of the stored completion under the existing guards, including to disposal callers; do not call the native finish again. The typed refusal is appropriate when no valid completed outcome can be served, but successful completion must not itself become an unreleased-resource error.

Review coverage: git grep found seven production fence calls, all in these shared capture-kit modules; each supplies the required callback. The shared paths serve screen recording, audio probe, app log, and perf capture. Network capture does not call this fence or participate in the four-kind durable-session resource set. The duplicated markCompleting is collapsed. Ordinary sequential record stop replay still routes through resolveScreenRecordingStopRecovery, so I am not claiming that every second stop fails. Records already completed at startup remain ignored by candidate selection, and a new adoption uses a new fence generation; those paths were not changed. No export/recorder equivalence is needed for any finding above.

Validation: install/build succeeded; ran the durable-capture suite once: 39 passed. The adversarial probes above loaded the reviewed source directly and used scratch manifests, without driving devices or modifying tracked source. I did not rerun the base suite under the single-run limit: the PR reports 3 failed / 36 passed on base, and those counts remain author-reported. The added cases cover queued admission and a lease's own late progress, but not the different-lease and descriptor-cleanup cases above. CI was not awaited.

Live evidence: the PR body says iOS Simulator 26.1 and contains no device id/UDID. It therefore does not establish use of the assigned mad2026-apex, iOS 26.2. The reported stop and close exercise the shared ordinary recording route; the body explicitly says the exact terminal-admission window was not reached. That evidence does not cover stale leases, descriptor recovery after completion, or shutdown after the drain deadline.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

I found no new problems in be20b30 beyond the open threads below. The two cleanup-ordering threads must be fixed before merge: finish-recovered.ts and recovery.ts. Native cleanup runs before the fence checks terminal admission there, so the change does not fully keep native work away from a completed record. Please run the cleanup inside the fence run, the way recovery.ts already does for forced cleanup.

Not blocking, take or leave: a duplicate or retried record stop, perf stop or logs stop used to share the handle's finish and both calls returned the export, but the second call now hits onCompleted (transitions.ts#L76) and fails with a non-retriable resource-already-completed. Either document that behavior, or answer reads of a committed result from the manifest, as finish-recovered already does, and keep the refusal for writes only. Also, the comment at transitions.ts#L213 says every handle answers cleanup after a completed finish without signalling, but the perf handle runs native cleanup anyway. It is safer to justify the skip by the normal route: a committed finish clears the binding, so no cleanup follows.

Of the other open threads, the two above are the ones that still apply, plus one nit about the duplicated cleanup block and comment (thread). One more (thread) is unverified: I did not check whether disposing the recovery control also releases the reattached handle for each kind. These do not apply and can be resolved: the already-missing cleanup comment is never surfaced to users (thread); no doc lists capture-kit fence reasons (thread); the completed branch cannot resolve to undefined (thread); the helper is meant for module-internal use (thread); and the export replaces the deleted private copy that recovery.ts imports (thread).

Smoke Tests and Coverage are still running, and nothing has failed yet. Because this code sits under every record, perf and logs stop, a Smoke failure on a capture command would be relevant here. The Apex Sentinel check shows action required from its change-request review, not from a code check. I did not run the unit tests; the base-fails and head-passes result is the author's report. After the cleanup moves inside the fence, please wait for Smoke Tests and Coverage to finish.

@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 18 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 44800d1. The verdict and the full list are in the summary comment.

@thymikee
thymikee force-pushed the fix/sm10-durable-capture-terminal branch from 44800d1 to eaeb2d2 Compare November 1, 2026 13:36

@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 eaeb2d2. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026 •

Copy link
Copy Markdown
Member Author

Round 2 dispositions — review 5727843200

Head 83d7b4d7b143ce683086f5334240a704a33c48ea (force-pushed over be20b30c).

Finding 1 — stale lease. Accepted, fixed. withDurableCaptureResourceFence re-reads and validates the persisted record at every transition, not only at lease issue, and revokes the lease when its own run exits (releasedLeaseError, reason ownership-fence-lost). New coverage uses two different leases: fence.test.ts asserts a lease that outlived its run cannot write, and that an escaped lease cannot overwrite the record the committing run is writing. Both fail at be20b30c (expected [Function] to throw an error, and expected 'published' to match object { details }).

Finding 2 — descriptor cleanup outside admission. Accepted, fixed. Destructive cleanup now runs inside the fence's run, through new owner cleanupDurableCaptureDescriptor (transitions.ts), used by both entrypoints: recovery.ts (unreattachable branch) and finish-recovered.ts. The terminal branch settles already-missing and removes nothing. New cases at both entrypoints complete a record during authority acquisition and assert cleanup was never called; both fail at be20b30c ("vi.fn()" to not be called at all, but actually been called 1 times).

Finding 3 — queued finish refused despite a committed export. Accepted, fixed. DurableCaptureRecordDefinition.completionFromMetadata? is optional and may be async; finishDurableCaptureHandle awaits the reader and throws completedDurableCaptureError only when nothing is serveable. Screen recording replays its whole stored stop response, read by the existing single reader in screen-recording-stop-recovery.ts; app-log replays its three flat completion keys. Neither stored shape changed. Audio-probe and perf-capture deliberately keep their original metadata and honestly refuse: their whole-result metadata risked exceeding the bounded 4096-node durable JSON budget and would have re-shaped a released manifest format. Teardown now settles that remaining refusal (isCompletedDurableCaptureError, applied by settleCommittedRecordStop to the four durable-capture steps and not to the snapshot helper, which owns no record), so shutdown after the drain deadline no longer reports session_cleanup_incomplete. Regression at the real boundary: screen-recording-session-resource.test.ts proves a disposal queued behind a committed stop replays that export and signals the recorder once; session-teardown-resource-order.test.ts proves both teardown callers settle it. All fail at be20b30c.

Evidence for the round-1 body gap. Accepted, corrected. Round 1 stated iOS 26.1 with no device id. The assigned device was used and is now named: mad2026-apex, 96A36322-100B-4464-9745-AF1130348773, iOS 26.2. Repeated on this head's build: record start → swipes → record stop → second record stop → close. The second stop returned the same outPath with exit 0, no error line, and no diagnostics id; the record stayed completed (recorder: confirmed, nativePathDisposition: retired), and record contact-sheet decoded 21 changed cells over 5.5 s from the 2.83 MB clip, so the file is real video. close 0.4 s into an in-flight record stop: both exit 0, record stayed completed at fence generation 2. Scope of that live evidence: a sequential second stop is served by resolveScreenRecordingStopRecovery, as your review noted, so the live route establishes the user-visible behaviour (a second stop stays a stop) and that exports still commit; the stale-lease write, the descriptor cleanup during authority acquisition, and the onCompleted replay behind a teardown are established by the failing-at-base cases above, which drive those exact paths.

Validation. The 8 new cases fail at be20b30c for the reasons above — 9 failing tests, because the teardown case is a test.each over both callers — and pass on head: 9 failed | 47 passed at the base, 56 passed on head. The base run is a disposable git worktree at be20b30c with test files only, no production patch at all, per docs/agents/test-experiments.md; worktrees removed. pnpm check:affected --run --head 83d7b4d7 passed all runnable checks, exactCommit=true — lint, typecheck, layering, fallow, build, vitest-related, unit; receipt .tmp/validation/run-YKSiI0/receipt.json. Wider suites: 653 files / 5110 tests incl. test/repo-guards. pnpm format clean.

CI follow-up on this head. Repo Guards & Compatibility failed at eaeb2d20: the production-exports gate flagged completedDurableCaptureError as a re-export with no production consumer, i.e. a test-only production export, which docs/agents/test-authoring.md forbids. It is no longer exported; session-teardown-resource-order.test.ts now builds the refusal from the typed reason directly, as other daemon tests do. This also removed the only production line the fail-first base run had needed. pnpm run check:production-exports is clean locally.

Diff size. 18 files, +926/−84 against merge-base f7879cf2. The replay seam is optional and kind-owned precisely so it did not grow further: nothing stores a whole result, no manifest format was re-shaped, and only the two kinds whose committed metadata already is their whole outcome serve a replay.

…llers

Revalidate the persisted lifecycle at every transition, not only at lease
issue, and revoke a lease when the run it was issued to ends, so an escaped
progress or completion write cannot publish onto a record another run
committed.

Move descriptor cleanup inside the fence that decides terminal admission, at
both recovery entry points, so destructive native cleanup cannot run for a
record that committed while recovery authority was being acquired.

Answer a finish that arrives after the commit with the completion the record
holds, through a kind-owned optional reader, instead of refusing it. Session
teardown treats the remaining refusal as settled rather than as an unreleased
resource, so a second record stop stays what it was: a stop.
@thymikee
thymikee force-pushed the fix/sm10-durable-capture-terminal branch from eaeb2d2 to 83d7b4d Compare November 1, 2026 14:05

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

Approve — no new blocking findings. Reviewed be20b30cee2477b7810ad6ee2cf05f94de744f94 → 83d7b4d7b143ce683086f5334240a704a33c48ea directly, plus the affected callers against merge-base f7879cf2d266ee1dd476d0342b2819bdeceb647b.

All three round-1 findings are resolved:

  1. Stale leases: durable-capture/fence.ts:66-88 revalidates the persisted fence/lifecycle before each publication and revokes the lease in finally. Both different-lease cases now protect the committed record; an escaped lease also cannot publish while a later run is still open.
  2. Descriptor cleanup: both recovery entrypoints now use cleanupDurableCaptureDescriptor (transitions.ts:243-260). The destructive callback executes inside admitted run; a record committed during authority acquisition bypasses it. The new cases cover both entrypoints.
  3. Queued finish / teardown: transitions.ts:78-81 replays a kind-owned completion. Recording reuses the existing reader, app-log validates its three stored keys, and session-teardown.ts:155-164 settles the specific terminal refusal for kinds with no replay. Other cleanup errors still propagate.

Requested interaction checks:

  • Second audio/perf stop: sequential behavior is unchanged from merge-base: audio returns the inactive result (session-audio.ts:181-200); perf already returned INVALID_ARGS when its live slot was empty (session-perf-runtime.ts:290-291). Both commands hold the session execution lock through their handlers (request-execution-scope.ts:295-314,495, request-execution-locks.ts:31-59), so ordinary concurrent requests cannot both capture the old slot. A second direct/shared finish that already captured that slot does change from the memoized handle result on base to a typed refusal. The production overlap identified in round 1 is shutdown after the drain deadline, and its disposal caller now settles that refusal. I found no newly failing user stop route. Persisting full audio/perf results is unnecessary for this fix; a disposal no-op belongs at the caller that needs no result, as implemented.
  • Metadata / upgrade: no stored shape or envelope version changes. Recording's existing response object and app-log's existing flat completion keys remain owned by their capture kind; durable-capture only invokes the optional reader. Reads validate the JSON envelope and completion fields. A legacy/cleanup-only completed record without completion metadata returns undefined, then the shared finish gives resource-already-completed; teardown settles it. The sequential recording recovery route instead returns none for that record, as before. None of these paths dereferences missing metadata or fabricates an export.
  • Revalidation cost / locking: each transition adds one synchronous verified file open/read/close, JSON parse, validation and freeze before the existing synchronous publication. There is no additional fence acquisition or await in transition, and neither completion reader re-enters the fence. Recording normally writes a handful of stop checkpoints, not a per-frame stream. This adds I/O, but I found no deadlock or concrete performance regression; I did not benchmark stop latency.
  • ADR 0024: terminal admission prevents overwriting a committed export; it does not change stopObservation into proof of recorder termination. Existing recording checkpoints, native-path disposition and the preserve-retry-material failure policy remain intact. Failed capture finishes stay open and can retry. The terminal live-handle cleanup still delegates to the kind's handle; descriptor cleanup after a competing commit is suppressed. Comments equating every commit with confirmed cleanup are too broad for ADR 0024, but I found no new executable export/recorder conflation in this revision. The ADR's remaining completed-but-unsettled recovery rollout is not introduced by this PR.
  • Size / smaller design: against merge-base, production is +312/-80 and tests/fixtures +614/-4; round 2 alone is production +247/-86, tests/fixtures +443/-10. The scope is justified by the three boundary fixes. One optional reader and one fenced descriptor-cleanup helper avoid a second completion cache, a changed manifest format, or repeated native finish work. The latest Size report shows +2.4 kB unpacked / +639 B download, and Bundle Size passed on this exact head. I do not see a materially smaller fix that preserves the same boundaries; the explanatory comments could be shortened separately.

Validation: pnpm install --frozen-lockfile && pnpm build succeeded. Ran the permitted durable-capture and capture-admission suites together exactly once: 18 files / 82 tests passed. No daemon-wide tests or device runs were performed, CI was not awaited, and the tracked worktree remains clean. This is a scoped code-review approval, not an assertion that all merge checks or native lanes have passed.

The Coverage lane scored the new app-log completion reader CRAP 110 at 0%
coverage: nothing exercised it. These cases drive it through the resource
it belongs to, so a queued teardown finish is answered from the record the
first stop committed, and a record whose journal cannot name its whole
completion keeps the typed refusal instead of inventing a log path.

@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 daf7555. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

CI fix — new head daf7555f189dcca77ac5cb6a2270ac23dffdc2fd

What failed. Coverage (run 41314388188, job 124871250200) failed at the step Check coverage-based CRAP of touched functions:

| `packages/capture-kit/src/capture-admission/app-log-durable-resource.ts:20` readAppLogStoredCompletion | 110.0 | cyclomatic 10 | 0% |

Cause — caused by this PR, real. Round 2 gave app-log a terminal-replay reader but only wrote a kind-level test for screen recording, so every one of the reader's 8 branches went unmeasured. No threshold was lowered and no .fallowrc.json exemption was added.

Fix. packages/capture-kit/src/capture-admission/app-log-durable-resource-replay.test.ts (test-only, 9 cases) drives the reader through the resource that owns it: a disposal queued behind a committed logs stop replays that completion and signals the stream once; a record committed by forced cleanup keeps the typed refusal instead of inventing a log path; and seven damaged-journal cases (no journal, no/unknown backend, missing or empty path, missing or non-finite stop time) each keep resource-already-completed. The first case fails at be20b30c (promise rejected "App-log resource record is already completed" instead of resolving), so it is regression coverage, not a probe.

Gate receipt. Same Istanbul map shape as the lane, written by a full pass over this tree (2062 files / 18046 tests green): readAppLogStoredCompletion now measures 8/8 branches, and pnpm run check:coverage-crap --all reports no function(s) at or above 30. The blocking --base form refuses this local verdict by design because that pass recorded runSelection = project, so CI's own full pass is the authority. pnpm check:affected --run --head daf7555f1 passed every runnable check, exactCommit=true — receipt .tmp/validation/run-0Vldnz/receipt.json.

Smoke Tests verdict — flake, not this PR. Job 124871249998 failed in test/integration/smoke-android-emulator.test.ts on a snapshot-diff assertion (expected added snapshot line containing Open automation lab) after early adb: device offline churn (The process '.../adb' failed with exit code 1 ×6 before the app under test was even installed). Evidence: this branch's immediately prior head eaeb2d20 passed the same workflow (41312094533), the diff eaeb2d20..83d7b4d7 touches only fence.ts, durable-capture/index.ts and one daemon test — no Android, snapshot or platform production code — and 1 of the last 100 Android workflow runs failed at all. Rerun once as agreed: gh run rerun 41314388156 --failed.

A finish that reached a committed screen-recording record was answered
with the stored stop response whatever had since happened to the clip,
so a deleted export came back as a successful stop naming a path nothing
can download. The surviving-file check now belongs to the shared
completion reader, the one owner both the stop-recovery route and the
terminal replay answer from; a gone export keeps the typed
already-completed refusal that teardown settles.

@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 ce5d76e. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Defect fix — new head ce5d76e829be77eaec346e05924a2dfa90f8eb48

Thread discussion_r4436107321: fixed and resolved. The screen-recording terminal replay served readStoredCompletion's stored path without the surviving-file guard, so a finish queued behind a stop whose clip had since been deleted resolved with a path nothing can download.

Fix (one owner). The fs.existsSync(outPath) check moved into the shared reader readStoredCompletion (screen-recording-stop-recovery.ts), and resolveScreenRecordingStopRecovery's own copy of the check was removed; both consumers — the stop-recovery route and completionFromMetadata's terminal replay — now get one verdict. With the export gone the reader answers undefined, so the replay throws the typed resource-already-completed that settleCommittedRecordStop already settles. capture-kit owns node:fs at this layer already (app-log-files.ts), so no port was introduced.

Test first. New case in screen-recording-session-resource.test.ts: adopt → commit a stop → fs.rmSync the clip → queued disposal must reject with details.reason: 'resource-already-completed' and signal the recorder once. It fails at daf7555f (promise resolved "{ backend: 'android', … }" instead of rejecting, with the deleted path in the payload) and passes on head. The existing replay case (file present) still replays the whole export. The pre-existing stop-recovery suite (serves nothing for a deleted video, all damaged-manifest cases) is unchanged and green.

Validation. pnpm check:affected --run --head ce5d76e829 passed all runnable checks, exactCommit=true — receipt .tmp/validation/run-cYdTQb/receipt.json. Typecheck, lint, check:production-exports clean. No live device run: the record stop route and its response shape are unchanged; the change only narrows what a replay may serve. All 36 review threads are now resolved; no open correctness defect remains at this head.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Coordinator check of the post-approval commits (83d7b4d7..ce5d76e8):

  • daf7555f adds tests only: replay coverage for readAppLogStoredCompletion (Coverage CRAP fix). No threshold change.
  • ce5d76e8: the surviving-file check moves into readStoredCompletion, its single owner. Terminal replay of a deleted export now answers resource-already-completed, and teardown settles that answer. One side effect for stop recovery: a missing export now emits the screen_recording_completed_manifest_unreplayable warning before it returns none, where it used to return none silently. The warning describes the condition accurately.

No blocking findings. The round-2 approval (5728514760) still holds.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

I reviewed ce5d76e. The earlier findings are fixed. The three open threads named below still apply and need an answer before merge.

A completed durable record is now terminal, and recovery and the in-flight close replay the committed result without cleanup. All 19 checks pass at this commit. The earlier Smoke failure was on the Android snapshot route, which this diff does not touch, and it passed on rerun. There are no conflicts.

Not blocking, and you can take or leave these: the released-lease guard at fence.ts:76 is never reached, because both escaped-lease tests commit the record first and stop at the completed check on line 73. One test could escape a lease from run A, start run B on an open record, call escape.transition('open', ...), and assert reason ownership-fence-lost, the 'released with its run' message, and an unchanged record. Also, the PR body calls the device run "this build" under head ce5d76e, but that run used 83d7b4d, so naming that build would be accurate.

I did not run unit tests or mutations; the fail-at-base results come from reading the code. The live device run is your report on 83d7b4d, iOS Simulator only, and it does not show whether the in-flight close reached the onCompleted replay rather than stop recovery. Unit tests cover that path.

On the open inline threads: these three still apply: #4139 (comment), #4139 (comment), #4139 (comment) (replay assertion, revocation guard test, teardown assertion). Five lower-priority nits still apply (the hand-copied LOG_BACKENDS list and the silent swallow in teardown). These do not apply and can be resolved. Fixed at head: #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment). Answered or benign, with no change needed: #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment), #4139 (comment).

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Review follow-up — head 5221af1c26c7652acffe1d5ac6803525740636b2

  • r4435766931: the recording fixture wraps the public handle's finish method before its internal memoization and requires exactly one invocation. A mutation adding a handle call in terminal replay fails with 2 calls, even though native signaling is still memoized.
  • r4435766934, including the maintainer's released-lease coverage request: run A escapes its lease; run B holds the still-open record; A's attempted write must report ownership-fence-lost and exactly “Screen recording resource fence was released with its run.” The entire record must remain unchanged, after which B commits successfully. Removing only the released-lease guard fails this test. This covers existing behavior; no production bug fix was needed.
  • r4435766938: ordinary close must return { ok: true }; daemon teardown must return undefined. A mutation returning { ok: false } fails the response assertion.

Mutation evidence. Disposable checkout at ce5d76e8 plus the test patch, verified identical to the committed tests: the same focused command passed 3 files / 18 tests unmodified. Each of the three independent mutations produced 1 failed / 17 passed, at its intended assertion. Production mutations stayed in the disposable checkout, which was removed. Logs are retained under .tmp/pr4139-review-followup/.

Focused command:

pnpm exec vitest run --project unit-core packages/capture-kit/src/durable-capture/fence.test.ts packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts packages/daemon/src/sessions/__tests__/session-teardown-resource-order.test.ts

Five optional nits. Each received an explicit current reply:

  • Backend-list threads r4435782217, r4435889046, r4436107338, r4436231268: deferred from this test-only round. The set matches today's five-member union and this PR adds no backend. The exhaustiveness gap is real; a local exhaustive Record would address it without a contracts-shape change, correcting the earlier reply's rationale. The existing flat journal is retained.
  • r4436107352: declined in this round. The catch settles only the typed terminal refusal; other errors still propagate and terminal state persists. A step/session diagnostic remains an observability improvement to pair with the finish-side diagnostic.

The other 34 threads named as fixed or answered/benign received one-line dispositions. All 42 listed threads are settled.

Validation. pnpm install --frozen-lockfile && pnpm build, pnpm format, and AGENT_DEVICE_VITEST_MAX_WORKERS=1 pnpm check:affected --run --head 5221af1c26c7652acffe1d5ac6803525740636b2. All runnable checks passed: 304 related tests; 16,770 unit tests passed / 1 skipped. Receipt .tmp/validation/run-8YUVdt/receipt.json records exactCommit=true, checkoutChanged=false.

The first four-worker gate failed in the Apple record timeout and the remote staging assertion. The identical related-test command on unchanged ce5d76e8 reproduced the Apple timeout (303 passed / 1 failed); staging passed there. At one worker, both base and head passed 71 files / 304 tests. This establishes the timeout predates this round, not a proven cause for either intermittent failure. The original failed receipt remains .tmp/validation/run-QkX9ex/receipt.json.

Scope and live attribution. Parent-relative: 3 test files, +24/-7; production/docs +0/-0. Whole PR: 19 files; production +318/-84, tests/fixtures +869/-4, docs +0/-0. The body now names the actual historical iOS Simulator build 83d7b4d7, not this head. That reported run does not distinguish in-flight terminal replay from stop recovery. No new live run for this test-only round.

Cold self-review of ce5d76e8..5221af1c26 found no further issue. No new independent review was run for this test-only follow-up; previous approvals predate it. New-head Coverage, Integration Tests and native lanes remain GitHub-authoritative. No merge-readiness claim while those checks are pending.

@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: 3 new findings on 5221af1. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

I found no new problems at 5221af1, and the findings from the earlier review (#4139 (comment)) are now fixed. CI is green: 19 checks, 0 failing, and the change since the last review is test-only. There are no conflicts.

I did not run the focused tests or the three mutations, so my fail-without-fix judgments come from reading the code. The live device evidence is still your earlier run on 83d7b4d (iOS Simulator only). It does not show whether an in-flight close reached the onCompleted replay or stop recovery. The newest change is test-only, so I do not need a new run.

Three of the open inline threads no longer apply, so please resolve them: the recording finish call count (#4139 (comment)), the escaped-lease fence test (#4139 (comment)), and the teardown result capture (#4139 (comment)). Each is fixed at this commit. Three other open threads still stand, all non-blocking. One asks for a PR-body note that a duplicate interactive stop on audio-probe or perf-capture now gets resource-already-completed (#4139 (comment)). One is a naming nit on readStoredCompletion (#4139 (comment)). One asks whether a second live handle can share the fence in one daemon, which I did not trace through recovery reattach or replacement-start adoption (#4139 (comment)).

After you answer those three threads, this can merge on green CI.

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

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Captured-handle follow-up — head 7246214dc0e38695456c1683e76e90a7940a0eb7

  • r4437572389: answered, policy unchanged. The round-2 independent review already traced this boundary. Sequential audio stops return the inactive result; sequential perf stops already returned INVALID_ARGS. The session execution lock prevents ordinary requests from both capturing the old slot. Only a direct/shared finish that already captured it receives the new terminal refusal, which teardown settles. No memoized-finish fallback or expanded result journal was added.
  • r4437572391: fixed. A completed record does not establish that every captured handle object's resources were released. The generic handle is AsyncDisposable; concrete app-log disposal, for example, owns background-process and output-sink cleanup. The terminal finish branch now awaits params.handle[Symbol.asyncDispose]() before replay or refusal. It never calls finish again or overwrites the completed record. A disposal error propagates as its actual error, so teardown cannot settle it as resource-already-completed.
  • r4437572394: fixed locally. Renamed the reader to readReplayableStoredCompletion in two files / three references. Its metadata validation and surviving-file check are unchanged.

Fail-first evidence. New cases adopt one handle, commit through a different handle using the real fence, then finish the originally captured slot. Replay and refusal both require its disposal hook and cleanup exactly once, forbid another finish, and preserve the complete record. A third case requires the actual disposal failure. Same command on 5221af1c plus the new tests: 3 failed / 4 passed (two missing disposal calls; cleanup error replaced by terminal refusal). On this head: 7 passed.

pnpm exec vitest run --project unit-core packages/capture-kit/src/durable-capture/transitions-terminal.test.ts

The durable-capture and capture-admission suites also passed: 19 files / 95 tests. Logs: .tmp/pr4139-terminal-disposal-{red,green}.log.

Gate. pnpm format, pnpm build, and AGENT_DEVICE_VITEST_MAX_WORKERS=1 pnpm check:affected --run --head 7246214dc0e38695456c1683e76e90a7940a0eb7. All runnable checks passed: 304 related tests; 16,773 unit tests passed / 1 skipped. Receipt .tmp/validation/run-2yeCd6/receipt.json: exactCommit=true, checkoutChanged=false.

Live worktree build. Bound local / 96A36322-100B-4464-9745-AF1130348773 with device_open and drove every action through its returned command. That client connected to an isolated daemon built from this worktree: 0.21.36-dev, codeOrigin=checkout, source head above. On iOS 26.2 / Settings, with AGENT_DEVICE_RETAIN_ARTIFACTS=1: start 2.841s → stop 1.236s → second stop 0.258s → close 0.303s, all exit 0. Both stops served the same 767,846-byte export; the manifest remained completed. This serial smoke does not claim to exercise the competing-handle terminal branch; the tests above do.

The initial HTTP attempt returned INVALID_ARGS on its second stop because download consumed the server-side export (client copy survived, server copy absent). The supported retention setting made the replay input survive; no production artifact policy was changed. Both attempts' outputs are retained under .tmp/pr4139-live-7246214/. Both sessions were closed. The first daemon's automatic cleanup refused with identity-unverified; only the verified daemon processes spawned for these runs were signaled and exited cleanly. The temporary client token file was removed; the simulator stayed booted.

Accounting. This round versus 5221af1c: four files, production +4/-3, tests +78/-2, docs +0/-0. Whole PR: 19 files, production +320/-85, tests/fixtures +945/-4, docs +0/-0. Cold self-review found no further issue; no new independent review was run. New-head CI, including Coverage and Integration Tests, remains authoritative. No merge was performed.

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

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

Fixed Cubic P1 r4437703614 at 2d27d0755e45bb3236162dcef4fcaa652a60c015: failed terminal cleanup preserves the committed replay, retains its binding, reports typed cleanup-pending diagnostics, and permits cleanup retry. Replied and resolved.

Fail-first regressions reproduced the defect. Exact-head check:affected --run --head passed (exactCommit=true, checkoutChanged=false; 1,212 related and 16,782 unit tests passed, one skipped). Bound iOS start → stop → second stop → close passed with artifact retention enabled. Cold self-review completed; CI at this head remains authoritative.

@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 2d27d07. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

The fixes from the earlier review at 5221af1 are in at 2d27d07: a completed record now disposes the captured handle before replay or refusal, and the binding stays when cleanup is pending. I found no correctness defect, but I did not run any tests. The checks pass (19 checks, none failing), and there are no conflicts.

What is still missing is live evidence for the failure path. The iOS start, stop, second stop and close run is reported by the author, and that run cannot induce a terminal disposal failure. I also could not find a production caller that commits a record through a second handle while the daemon holds the live binding, so I am not sure the teardown case in the thread below can happen. Can you show the route that reaches it, or explain why the narrow trigger needs no handling? With intent 'disposal' in transitions.ts, a replay with a failed disposal resolves the teardown step, and the retained binding retires with the session.

Not blocking, and you can take or leave these: onCompleted decodes the cleanup outcome from a thrown error through a hand-kept reason table and a cast, although the handle's forceCleanup() already returns the typed outcome. The same memoize-until-confirmed block also appears in four live handles (starting at app-log-live-handle.ts). The screen recording dispose test accepts any rejection instead of checking the typed reason.

Could this be simpler? In onCompleted, call forceCleanup() and treat a rejection as cleanup-unconfirmed, which removes the reason table and the cast. One capture-kit helper that takes the clean thunk and the confirm assertion could build all four handles. The finish intent could then decide retention: 'capture' keeps the binding and replays, and 'disposal' reports the pending cleanup as the teardown step's typed failure. The helper can sit beside isConfirmedCleanup.

Open threads: the teardown retirement and four retry copies threads still stand, and one lower-priority thread (the finishPromise nit) still applies. These do not apply and can be resolved: #4139 (comment) (the unreplayable phase name is accurate, and the existsSync move predates this change), #4139 (comment) (the swallowed error is thrown only when no serveable export exists), #4139 (comment) and #4139 (comment) (both fixed at 2d27d07), and #4139 (comment) (the rename is done and has no stale callers).

Before merge, please answer the design question above.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 2, 2026
@thymikee

thymikee commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Addressed the maintainer design review at 8c436df63fce0a77dc3ee96773387e8fcdea1da5:

  1. Typed terminal cleanup: onCompleted calls handle.forceCleanup(); a rejection becomes cleanup-unconfirmed. Returned outcomes retain their typed reasons. The reason table and cast are removed.
  2. One cleanup cache: capture-kit's createRetryableCleanup(clean, assertConfirmed) reuses the contract's isConfirmedCleanup predicate. App-log, audio, native perf and screen recording all use it. It shares in-flight work and caches confirmed cleanup; failures remain retryable. The helper sits beside the predicate in the existing durable-resource contract module and is exposed through capture-kit, preserving the existing eager module closures.
  3. Intent decides reporting (r4437837639): capture retains the binding and replays a serveable committed result, with typed diagnostics for pending cleanup. Disposal throws typed cleanup-pending before replay, so teardown aggregation reports the step's failure. Confirmed cleanup still allows replay/refusal and clears the binding. The completed record stays unchanged.
  4. Production route audit: I found no ordinary same-daemon record route that commits through a second handle while retaining the original live slot. resolveRecordPlan selects live stop when the slot exists; resolveScreenRecordingRuntimePlan selects recovery only without it. Requests take session/device execution locks. Adoption calls binding.assertAdoptable() before writing the record, rejecting a replacement in an occupied slot. Startup app-log recovery precedes listening. The production recovery chain is finishRecoveredScreenRecording → finishRecoveredDurableCapture → reattach → finishDurableCaptureHandle, selected only without the live slot. Stale-claim reconciliation can separately settle an old owner's records after ownership/reboot settlement; that does not establish two authoritative live handles in the same serving session. The two-handle tests deliberately pin the shared boundary; they do not demonstrate a routed daemon race. No additional daemon retry loop, replacement-start path, or failure-injection device route was added. The defensive disposal contract now reports uncertainty if that boundary is reached.
  5. Nits: the screen-recording disposal test asserts COMMAND_FAILED and typed transport-failed. For r4437837655, app-log owners remove successful cleanup steps/resources from their pending set, so a retry touches only failed steps.
  6. Other dispositions: replied in one line and resolved r4437837642 (accurate unreplayable phase; file-check move predates this round), r4437837647 (no serveable export on the swallowed refusal), r4437703614 and r4437572391 (cleanup/binding fixes preserved), and r4437572394 (reader rename complete). The two design threads and finish retry nit are also answered and resolved.

Fail-first command on 2d27d0755e plus tests: 4 failed / 26 passed—two disposal calls resolved instead of reporting pending cleanup, and Apple/provider repeated confirmed cleanup. The same command at this head: 30 passed.

pnpm exec vitest run --project unit-core packages/capture-kit/src/durable-capture/transitions-terminal.test.ts packages/capture-kit/src/provider-app-log-poller.test.ts packages/platform-apple/src/logs/start.test.ts

At this head, the eager-closure plus regression run passed 4 files / 912 tests. The first gate at f4a801c950 failed four eager-import assertions because the separate helper file added one eagerly loaded module to three entries. Co-locating the helper with the existing predicate fixed that invariant; no budget or allowlist was changed. pnpm format, typecheck and exact-head AGENT_DEVICE_VITEST_MAX_WORKERS=1 pnpm check:affected --run --head 8c436df63fce0a77dc3ee96773387e8fcdea1da5 passed; receipt .tmp/validation/run-XkPtW7/receipt.json has exactCommit=true, checkoutChanged=false. Related tests: 1,212 passed. Unit tests: 16,784 passed / 1 skipped. Provider integration was covered by the related run.

Parent-relative: 14 files; production +81/-134; tests +72/-22; docs +0/-0. Whole PR: 34 files; production +439/-159; tests/fixtures +1219/-11; docs +0/-0. Cold self-review completed; no independent reviewer was started in this implementation round. Record routing is unchanged, so no new live run was needed under the coordinator instruction. Historical simulator evidence remains attributed to 2d27d0755e. New-head Coverage, Integration Tests and native lanes remain GitHub-authoritative. No merge or label change.

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