Skip to content

fix(record): keep a failed capture's evidence for the retry - #2591

Merged
thymikee merged 16 commits into
mainfrom
t3code/preserve-failed-capture-artifacts
Sep 14, 2026
Merged

thymikee merged 16 commits into
mainfrom
t3code/preserve-failed-capture-artifacts

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Implements wave 0 of docs/adr/0024-screen-recording-provable-signal.md rule 6: an export failure is no longer a disposal. A durable kind now says what its retry needs with failedFinishPolicy, and the shared coordinator stops force-cleaning on a failed finish for preserving kinds (screen-recording, perf-capture); app-log and audio-probe say dispose-on-failed-finish explicitly.

A finish also says why it was asked for. capture may preserve retry material; disposal — session teardown, the only such caller — disposes whatever a failed finish left. Recording and perf-capture would otherwise have left a closing session unable to hand its recorder back.

createCaptureHandle.finish no longer memoizes a refused finish, so a second record stop re-enters the recorder. 29 files, one command family plus the coordinator it sits on.

agent-device record stop   # adb pull failed: error returned, device artifact + manifest kept
agent-device record stop   # same session: exports the MP4 the first stop left behind

Validation

Tested at 4e1e17084b. pnpm check:affected --run passed locally (372 files, 2,560 tests), along with pnpm typecheck, pnpm lint and pnpm check:layering.

Live proof on Android emulator emulator-5554 (Pixel 7 CI) with the pull path patched to fail its first three attempts: record start / record stop returned the pull error and wrote no local file, while adb -s emulator-5554 shell ls -l /sdcard/agent-device-recording-* showed the 90,338-byte chunk and agent-device-recording-active.json intact and the session manifest open. A second record stop exported those bytes and cleared the device. Patch reverted before the pushed head.

Known follow-up, not fixed here: on that retry Android infers "stopped before record stop, likely the 180s limit" from its own earlier stop — ADR step 3 recorder observation owns it.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.53 MB 4.53 MB +2.2 kB
Package (unpacked) 4.53 MB 4.53 MB +2.2 kB
Package (download) 1.34 MB 1.34 MB +692 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.1 ms 27.4 ms +1.3 ms
CLI --help 74.7 ms 77.4 ms +2.7 ms

@thymikee

Copy link
Copy Markdown
Member Author

Keeping a failed stop's evidence fixes the Android adb pull case you proved. But the preserve policy now applies to every screen-recording backend, and on the Apple backends the retry cannot succeed.

In packages/platform-apple/src/recording/runtime.ts, stopRunner stores its promise with runnerStop ??=. After one rejected runner stop, every later record stop gets the same rejection without calling the runner again. The simulator path has the same shape: the simctl recordVideo process has already exited, so the next finish reads the same non-zero exit code and throws again. In both cases record stop fails every time, and record start keeps refusing with "recording already in progress" until the session closes. Before this change the failed stop cleaned up, so a new record start worked. Please make the retry real, or dispose when nothing can be retried: clear runnerStop when it rejects, and dispose (or point the user at closing the session) when the recorder process already exited with an error. A test with a repeated Apple stop failure would cover it.

The iOS and macOS recording paths change behavior here, but the only live run is on Android. A simulator run of a failed and then retried record stop is still needed. Smoke Tests were still running at 03cfd29, and there are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Update: after #2587 landed, the branch conflicts with main in CHANGELOG.md. Please resolve it when you address the Apple stop-failure test and the simulator run.

ADR 0024 rule 6, shipped first. `record stop` that failed to produce its export
ran compensating forced cleanup anyway. On Android that reaches
`cleanupVerifiedAndroidEvidence`, which stops the recorders and removes the
remote chunks and the device-side manifest: an `adb pull` that failed once under
load left nothing to pull twice, and the manifest the next stop would re-drive
from was gone with it.

The shared coordinator now asks the kind's own definition what a failed finish
may do (`failedFinishPolicy`), and never infers disposal from "finish threw".
Screen recording declares `preserve-retry-material`, so a failed collect leaves
the record `open`, keeps the live handle with the session, and lets the next stop
re-collect from the artifacts still on the device. Forced cleanup stays exactly
where rule 6 puts it: session teardown and failed-start rollback.

App-log, audio-probe, and perf-capture declare `dispose-on-failed-finish` and
keep today's behavior unchanged until their own failed-finish test states what
their retry needs.
ADR 0024 rule 6 earns its scope per kind. App-log's compensating cleanup is not a
disposal of anything a retry reads: `createAppLogLiveHandleFromFinish` derives it
from the same idempotent finish transaction, and the captured log file at the
canonical artifact path is never on its delete path.

So app-log declares `dispose-on-failed-finish` and this test names why: a stop
whose first finish attempt fails is retried by that very cleanup (two finish
calls, one error returned to the caller), the stream's bytes stay on disk, and the
record terminalizes so a replacement stream may start on the same device.
…d finish

ADR 0024 rule 6, per kind. Audio-probe's whole capture is the status file its
helper publishes; the kind's cleanup terminates the sampler by exact identity and
has no path that removes that file. A helper that died before publishing its
terminal checkpoint cannot be revived by keeping it alive, so nothing a retry
reads is destroyed by disposing here.

Audio-probe therefore declares `dispose-on-failed-finish`, and this test drives the
real host capture operations through a failed finish to show what that guarantees:
the sampler is terminated exactly once by the compensating cleanup, the last
checkpoint stays on disk for `audio probe status` and recovery to read, and the
record terminalizes so a replacement probe may start.
ADR 0024 rule 6, per kind. Perf-capture's retry needs the profiler artifact:
`stopAndroidNativePerfSession` pulls the device trace and only then removes it, and
the refusal it raises on a failed pull tells the caller to retry stop with the same
session. The compensating cleanup was the `rm -f` that emptied that promise.

Perf-capture declares `preserve-retry-material`, and the live handle stops
memoizing a refused finish, mirroring the recording handle: a stop the profiler
refused has to be re-driven by the next `perf stop`, which is what re-pulls the
trace the record now keeps. The test drives a pull failure through the daemon's
own perf record and shows the trace surviving the first stop, the record staying
open, and the second stop collecting and releasing it.
Versioned help owned nothing about the failed-stop path, so an operator reading
`agent-device help record` after a lost export had no signal that the Android
artifact and its native manifest were still on the device, that the session's
recording manifest stayed open, or that `record stop` in the owning session is the
retry — and that only closing the session disposes them.
…aration

The package-boundary re-export had no consumer: every durable kind names the
literal where it builds its definition, and the one type-level consumer — the
capture-kit fixture that builds a preserving definition — resolves the declaration
module directly. The dead-code audit on the changed diff flagged the export.
A finish now says why it was asked for. A `capture` finish tries to produce the
export and may keep retry material under a preserving kind's policy; a `disposal`
finish is session teardown handing the resource back for good, and disposes what a
failed finish left no matter what the kind preserves. Without the distinction, the
policy I just added to recording and perf-capture reached teardown and left a
closing session unable to give its recorder back — the coordinator skipped the forced
cleanup that ADR 0024 rule 6 assigns to teardown, and the two teardown tests that
watch for it failed.

`finishLiveDurableCapture` and `finishDurableCaptureHandle` require the intent rather
than defaulting it, so every caller states which authority it acts for; the three
teardown wrappers in `session-teardown.ts` are the only disposal callers.
Three tests watched the coordinator compensate a failed `record stop` by force-cleaning
the recording and terminalizing its manifest, and one allowed a replacement `record start`
over the top of it. Those were the behavior ADR 0024 rule 6 retired, so they now assert
the rule: the finish error stays primary, the live handle and the open manifest survive,
phase rests at `completing` rather than `cleanup-pending`, and the next `record stop`
re-enters the recorder through the same handle — twice called, zero forced cleanups.

The two macOS provider scenarios keep their original point that an unfinalized recording
is never published as a completed artifact; only the retained state changed.
Two Apple facts made the retry my rule-6 change promises impossible, and the review
caught both.

The runner path memoized `stopRunner` with `??=`, so once the runner rejected a stop the
same rejection came back to every later `record stop` and the runner was never asked
again. Only an in-flight or completed stop is shared now, which is what the live handle
already does for a refused finish, so the next stop re-enters `recordStop`.

The simulator path threw on a non-zero `simctl recordVideo` exit, which made the settled
exit code the permanent answer and threw away a recording the recorder had already
written. An exit is an observation about the recorder, not about the export (ADR 0024
2.2), and the finalizer is what answers whether the file is a video: the exit is now
disclosed — `exited with code N`, or `was killed by SIGKILL` — and collection proceeds.

Simulator and macOS recordings that used to fail here now export; a refusal that really
stands is re-driven by the next stop instead of being remembered.
A preserved recording can reach a file no retry will ever turn into a video — the
simulator recorder SIGKILLed mid-write leaves zero bytes behind. That failure said only
what the file is not, with the generated "retry with --debug" hint, while the manifest
stays open and `record start` keeps refusing until the session closes. The error now
carries `recording-output-unplayable`, stays retriable for the case a retry does fix
(a recorder still finalizing its file), and names session close as the way out.
@thymikee
thymikee force-pushed the t3code/preserve-failed-capture-artifacts branch from 03cfd29 to 7466adb Compare September 14, 2026 15:06
@thymikee

Copy link
Copy Markdown
Member Author

Rebased on main (CHANGELOG.md conflict resolved, #2587's entry kept) and made the Apple retry real. Head: 7466adbb.

Runner stopstopRunner's runnerStop ??= cached the rejection, so the runner was asked once and every later stop got that cached answer. Only an in-flight or completed stop is shared now, which is what createScreenRecordingLiveHandle already does for a refused finish. a refused runner stop is asked again by the next record stop asserts two stop requests and a completed second attempt.

simctl exit — you're right that a settled exit can't be re-driven, so I stopped treating it as a failure. An exit is an observation about the recorder; the finalizer is what answers whether the file is a video (ADR 0024 2.2). The exit is disclosed (exited with code 1, was killed by SIGTERM) and collection proceeds, so recordings the old code threw away now export. Covered by a simulator recorder that exited early is collected and its exit is disclosed and a refused simulator finish is re-driven by the next record stop (finalizer called twice).

When nothing can be retried — a recorder killed mid-write leaves zero bytes, and no retry fixes that. That error now carries recording-output-unplayable, stays retriable for the case a retry does fix, and names session close as the way out.

Simulator run (iPhone 16 Pro, booted):

  • finalizer patched to fail its first call: stop 1 returned the error with the 61,935-byte recording untouched and the manifest open/completing; stop 2 exported those bytes.
  • recorder killed by SIGTERM: recording was not finalized into a playable video + the new hint; the retry re-drove it; close terminalized the manifest (completed, cleanupStatus: cleaned) and reported the lost export.
  • healthy start/stop on this build: 119,935 bytes, no warning.

Killing the recorder left the CoreSimulator host recording lock (simctl exit 16) — a simulator shutdown/boot clears it. Nothing left behind: no recordVideo processes, no stale claims, throwaway state dir removed.

pnpm check:affected --run at 7466adbb: 405 files, 2,795 tests, plus typecheck, lint, check:layering.

@thymikee

Copy link
Copy Markdown
Member Author

The Apple retry works now at 7466adb. A refused runner stop is asked again, and a simulator recorder that exited early goes to the finalizer, which still refuses a file that is not a playable video. Two gaps remain in the simulator path.

When simctl exits with an error and the file is unplayable, the error no longer carries the exit code, signal or stderr. Before this change the error named the exit code. Now the user gets only the generic recording-output-unplayable hint and must guess whether a retry can help, and each retry waits again for a file that will not change. Please add the recorder's exit details to that error in the simulator finish in packages/platform-apple/src/recording/runtime.ts, and point the hint at closing the session when the recorder has already exited.

The new warning can blame the wrong side. When our own stop escalates from SIGINT to SIGTERM or SIGKILL, src/platform-runtime-screen-recording-apple-simulator-host.ts resets the exit code to 0 but keeps signal. describeSimulatorRecorderExit checks signal first, so it reports was killed by SIGTERM before record stop for a kill that record stop sent. Please skip the warning, or change its wording, when the owner terminated the recorder.

Also, a refused simulator finish is re-driven by the next record stop uses exitCode: 0, and main already re-drives that case, so the test passes without this change. A non-zero exit there would cover the new behavior. Your simulator run covers the retry path. Smoke Tests were still running, and there are no conflicts.

Two wrong blame assignments in the disclosure I added, both from reading the process result
as though the host had not already interpreted it.

`describeSimctlRecorderExit` checked `signal` first, and the simulator host normalizes a
termination record stop *asked for* to exit code 0 with the signal still attached — so our own
SIGINT-to-SIGTERM escalation came out as the recorder being "killed by SIGTERM before record
stop". The exit code is the normalized fact, so it answers first now.

And dropping the exit from an unreadable recording was a regression: the old error named the
exit code, the new one named only the file, and every retry waits again on bytes that cannot
change. The simulator finish now puts the exit in the error it raises, carries stderr and the
exit code/signal under `processExitError`, keeps the unplayable reason, clears a retriable flag
it no longer deserves, and points at closing the session.

The re-drive test used a clean exit, which main already re-drove, so it uses a non-zero one.
Three finishes ask for a completion nobody reads: `audio start` settles the probe it replaces,
`audio status` settles a sampler that already exited, and `logs clear --restart` settles the
stream whose files it deletes a line later. They are disposals — whatever runs next expects the
record settled, not left open for a retry — and labelling them `capture` made them a trap for
any kind that later decides its retry needs material. Audio-probe and app-log dispose on a
failed finish either way, so nothing changes today.

The intent's own doc said session teardown was the only disposal caller, which is the claim that
just narrowed.
@thymikee

Copy link
Copy Markdown
Member Author

All three addressed at c9ed057d.

Exit details — the simulator finish now wraps the export failure instead of replacing it:

recording was not finalized into a playable video: /tmp/x.mp4; simctl recordVideo exited with code 1: recordVideo lost its stream

stderr, exitCode and signal ride in details under processExitError, which is what appends the recorder's first stderr line, recording-output-unplayable is preserved, the retriable flag is cleared — a settled exit cannot be retried into new bytes — and the hint points at closing the session. retriable: true and the retry hint stay for the backends where the recorder may still be finalizing. Covered by an unreadable recording names the exit that made it permanent and the way out.

Wrong blame — you read the host right. src/platform-runtime-screen-recording-apple-simulator-host.ts:200 normalizes a termination record stop asked for to exitCode: 0 and keeps the signal, so checking signal first turned our SIGINT→SIGTERM escalation into "the recorder was killed". The exit code answers first now, and a recorder that record stop terminated itself is collected without a warning pins it.

Test — the re-drive test uses exitCode: 1, asserting the wrapped refusal on the first stop and the completed second one.

Went back over the whole change while doing this: three finishes that only make room were labelled captureaudio start over a running probe, audio status on a sampler that already exited, logs clear --restart before it deletes the log files. Nobody reads those completions and an open record left behind would refuse the start that follows, so they are disposal (ddeb9ebf). No behavior today — both kinds dispose on a failed finish — but the labels were a trap for any kind that later preserves.

pnpm check:affected --run at c9ed057d: 405 files, 2,797 tests, plus typecheck, lint, check:layering. One earlier run of the same head failed four provider-integration files at the 5s timeout under machine load; all four pass in isolation and on the clean re-run.

@thymikee

Copy link
Copy Markdown
Member Author

All three points from the last review are fixed at c9ed057: the unplayable error carries the recorder's exit, our own stop no longer gets blamed, and the re-drive test fails on main. Two small issues remain in the new wrap.

recorderExitLostTheExport wraps every error from completion, not only an unplayable file. A transient telemetry write or finalize failure also gets retriable: false and the "close this session" hint, and the original hint, diagnosticId and logPath are lost. So a failure that a retry would fix tells the user to give up. Please wrap only when reason === 'recording-output-unplayable', or keep the original details and add the exit facts to them.

The exit details are built by hand. execFailureDetails from @agent-device/host-kit/command already builds stderr, exitCode and processExitError, and core/devicectl.ts uses it.

Smoke Tests are still running, and there are no conflicts.

The wrap I added explained too much. It took every error the export path raised — a telemetry
write that failed once, a finalize hiccup — gave it `retriable: false` and the "close this
session" hint, and dropped the original `hint` along the way, so a failure a retry would fix told
the user to give up. It now fires only on `recording-output-unplayable`, keeps the details it was
handed and adds the exit facts to them; anything else the export path raises comes through exactly
as it was.

The reason the two layers match on is now one exported constant instead of a string each, and the
exit details come from `execFailureDetails`, whose parameter accepts the null exit code a process
killed by a signal reports — the shape `AppErrorDetails.exitCode` already allows.
@thymikee

Copy link
Copy Markdown
Member Author

Both fixed at 2a8e3306.

Only the unreadable file is final — the wrap is now gated on the reason, and keeps what it was handed:

const original = asAppError(exportError, 'COMMAND_FAILED');
if (original.details?.reason !== RECORDING_OUTPUT_UNPLAYABLE_REASON) return exportError;
return new AppError(original.code, `${original.message}; ${exit}`, execFailureDetails(result, {
  ...(original.details ?? {}),
  ...(result.signal === undefined ? {} : { signal: result.signal }),
  retriable: false,
  hint: '…Close this session to release the device, then record again.',
}));

Anything else the export path raised returns untouched, so a transient telemetry or finalize failure keeps its own code, retriable, hint and whatever diagnosticId/logPath it carried. an export failure the recorder did not cause keeps its own verdict asserts that with rejects.toBe(transientError), and the re-drive test now throws a realistic unplayable error instead of a bare Error — which is why it caught this.

The reason both layers match on is now one exported RECORDING_OUTPUT_UNPLAYABLE_REASON in packages/contracts/src/screen-recording-runtime.ts, used by the finalizer that raises it and the simulator that reads it.

execFailureDetails — used, and the hand-built block is gone. Its parameter needed one widening: exitCode: number | null, because a process killed by a signal reports no code — the null AppErrorDetails.exitCode already documents. Callers passing ExecResult are unaffected.

pnpm check:affected --run at 2a8e3306: 762 files, 5,753 tests, plus typecheck, lint, check:layering.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 2a8e330. The retry-path evidence issue from the c9ed057 review is fixed, and I found no new problems in the delta.

One question, not a blocker: execFailureDetails spreads original.details after the recorder's stdout, stderr and exitCode. If a later unplayable error carries its own stderr or exitCode, it will override the recorder's values instead of adding to them. Today the finalizer's error carries none of these fields. Is that order intended?

The iOS simulator smoke job fails with id="automation-longpress" did not become visible after scrolling. That step does not go through the record-stop path this PR changes, and #2491 tracks rotating iOS smoke failures on main, so it looks unrelated.

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

Copy link
Copy Markdown
Member Author

Reviewed at 2a8e330. Both points from the last review are addressed, and the delta raises nothing new. There are no conflicts.

Smoke Tests failed in the iOS simulator fixture run because automation-longpress did not become visible after scrolling. This change only shapes the error when the simulator recorder exits before record stop, which that scroll step does not reach, so the failure is likely unrelated. A rerun should confirm it.

@thymikee
thymikee merged commit 3585be3 into main Sep 14, 2026
17 of 18 checks passed
@thymikee
thymikee deleted the t3code/preserve-failed-capture-artifacts branch September 14, 2026 18:44
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-14 18:48 UTC

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant