Skip to content

Commit 3585be3

Browse files
authored
fix(record): keep a failed capture's evidence for the retry (#2591)
* fix(record): keep a failed recording's evidence for the next stop 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. * test(app-log): state what an app-log retry needs from a failed finish 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. * test(audio-probe): state what an audio-probe retry needs from a failed 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. * fix(perf): let a perf stop re-pull the trace its failed stop preserved 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. * docs(record): say what a record stop that fails keeps 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. * refactor(capture-kit): keep the failed-finish policy type at its declaration 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. * fix(capture): make session teardown a disposal finish 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. * test(record): state what a failed record stop owes the retry 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. * docs(changelog): a failed record stop keeps what its retry reads back (#2591) * fix(record): make a refused Apple record stop retryable 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. * fix(record): say how to clear a recording that never became playable 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. * docs(changelog): the Apple paths a preserved recording depends on to be retryable * fix(record): say which side of a simulator stop failed 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. * fix(daemon): label the finishes that make room as disposals 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. * docs(changelog): the simulator exit a lost recording now names * fix(record): let only an unreadable file end the recording 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.
1 parent b6fdbb1 commit 3585be3

37 files changed

Lines changed: 1196 additions & 53 deletions

‎CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@
4141
lock-policy caller, the exact audience that avoids naming sessions, would land on the wrong leaf.
4242
Explicit `--session <name>` is untouched and still addresses one session verbatim.
4343

44+
- Fixed: a `record stop` that could not produce its export no longer destroys what its own retry reads
45+
back (#2591). The shared coordinator compensated every failed finish by force-cleaning the resource,
46+
which on Android deleted the device-side MP4 and the native manifest the hint just told you to re-pull:
47+
the second `record stop` had nothing left to collect and the recording was gone. Each durable kind now
48+
declares what its retry needs. `screen-recording` and `perf-capture` preserve, so a failed stop keeps
49+
the device artifact, keeps the manifest `open`, returns its own error, and the next `record stop` in
50+
that session exports it — proven on an emulator whose `adb pull` failed its first attempts and then
51+
exported the 90 KB the first stop left on the device. `app-log` and `audio-probe` say
52+
`dispose-on-failed-finish` themselves, because their retries re-read a log file and a status file that
53+
cleanup never touches. A finish also states why it was asked for: `capture` may preserve, while
54+
`disposal` — session teardown, the one caller that owns forced cleanup — disposes whatever a failed
55+
finish left. Two Apple paths had to become genuinely retryable for that to be true: the runner path
56+
memoized its refused stop and handed the same rejection to every later `record stop` without asking
57+
the runner again, and the simulator path rejected a recording purely because `simctl recordVideo` had
58+
exited non-zero — discarding a video the recorder had already written and leaving an exit code no
59+
retry could change. A stopped recorder is now an observation rather than a failure: the exit is
60+
disclosed on the completion and the file is collected, so those recordings export instead of erroring.
61+
Expect one trade: while a preserved recording is open, `record start` on that device is refused until
62+
its session runs `record stop` or closes. A simulator recording no retry can rescue now names the
63+
exit that made it unreadable — `simctl recordVideo exited with code 1`, `was killed by SIGKILL`, plus
64+
the recorder's stderr — drops its retriable flag, and points at closing the session, while a recorder
65+
still finalizing its file keeps the retry hint. `perf stop` no longer memoizes a refused finish, so its
66+
second attempt re-pulls the trace the first one preserved.
67+
4468
- Changed: `record stop` no longer carries a start-trim step no recorder could arm. The trim cut the
4569
interval between recorder start and target-app readiness, but the runner's `recordStart` answer has
4670
never carried either timing and the simulator path supplies none, so every built-in recording took

‎packages/capture-kit/src/durable-capture/definition.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,31 @@ export type DurableCaptureSessionSlot<K extends string, H extends AsyncDisposabl
2929
replace(session: S, resource: DurableCaptureSessionResource<K, H> | undefined): S;
3030
}>;
3131

32+
/**
33+
* What a failed capture finish may do to a kind's material (ADR 0024 rule 6). A kind declares here
34+
* whether the shared coordinator's failed capture finish still earns a forced cleanup; a disposal
35+
* finish ignores this and always disposes.
36+
*/
37+
export type DurableCaptureFailedFinishPolicy =
38+
/**
39+
* The retry needs material forced cleanup would destroy, so a failed finish keeps every artifact
40+
* and leaves the record open for the next attempt.
41+
*/
42+
| 'preserve-retry-material'
43+
/**
44+
* The retry needs nothing forced cleanup destroys, so a failed finish still disposes. A kind that
45+
* wants this says so where its definition is built.
46+
*/
47+
| 'dispose-on-failed-finish';
48+
49+
/**
50+
* Why a finish is asked for. A `capture` finish tries to produce the resource's export and may keep
51+
* retry material under a preserving kind's policy; a `disposal` finish hands the resource back for
52+
* good — session teardown, or a start that replaces the resource — and disposes whatever a failed
53+
* finish left, because whatever follows expects the record settled rather than waiting for a retry.
54+
*/
55+
export type DurableCaptureFinishIntent = 'capture' | 'disposal';
56+
3257
/**
3358
* The session-free half of a definition. Recovery reattaches and terminalizes a persisted
3459
* record with no session in hand, so it names this and never the session type.
@@ -38,6 +63,7 @@ export type DurableCaptureRecordDefinition<K extends string, C> = Readonly<{
3863
displayName: string;
3964
store: DurableCaptureResourceStore<K>;
4065
completionMetadata(result: C): JsonObject;
66+
failedFinishPolicy: DurableCaptureFailedFinishPolicy;
4167
messages: Readonly<{
4268
noActive: string;
4369
cleanupPendingHint: string;

‎packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createDurableResourceEnvelope } from '../durable-resource-envelope.ts';
1212
import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts';
1313
import type {
1414
DurableCaptureCleanupOutcome,
15+
DurableCaptureFailedFinishPolicy,
1516
DurableCaptureResourceDefinition,
1617
DurableCaptureSessionResource,
1718
DurableCaptureSessionStore,
@@ -47,6 +48,7 @@ export const testCaptureStore = createDurableCaptureResourceStore({
4748

4849
export function createTestCaptureDefinition(
4950
store: DurableCaptureResourceStore<typeof TEST_CAPTURE_KIND> = testCaptureStore,
51+
failedFinishPolicy: DurableCaptureFailedFinishPolicy = 'dispose-on-failed-finish',
5052
): DurableCaptureResourceDefinition<
5153
typeof TEST_CAPTURE_KIND,
5254
TestCaptureHandle,
@@ -57,6 +59,7 @@ export function createTestCaptureDefinition(
5759
resourceKind: TEST_CAPTURE_KIND,
5860
displayName: 'test capture',
5961
store,
62+
failedFinishPolicy,
6063
sessionSlot: {
6164
read: (session) => session.capture,
6265
replace: (session, capture) => ({ ...session, capture }),

‎packages/capture-kit/src/durable-capture/finish-recovered.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export async function finishRecoveredDurableCapture<
7070
handle: authority.reattached.handle,
7171
fence: envelope.fence,
7272
resourcePath: params.resourcePath,
73+
intent: 'capture',
7374
});
7475
case 'completed': {
7576
const result = authority.reattached.result;

‎packages/capture-kit/src/durable-capture/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { createDurableCaptureResourceStore } from './store.ts';
1010
export { finishLiveDurableCapture, forceCleanupLiveDurableCapture } from './transitions.ts';
1111
export type {
1212
AdoptStartedDurableCaptureParams,
13+
DurableCaptureFinishIntent,
1314
DurableCaptureRecordDefinition,
1415
DurableCaptureResourceDefinition,
1516
DurableCaptureSessionStore,

‎packages/capture-kit/src/durable-capture/transitions.test.ts‎

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ test('finish failure remains primary when cleanup and cleanup-pending persistenc
3838
await expect(
3939
finishLiveDurableCapture(
4040
definition,
41-
{ session: active, sessionName: context.sessionName, sessionStore: context.sessionStore },
41+
{
42+
session: active,
43+
sessionName: context.sessionName,
44+
sessionStore: context.sessionStore,
45+
intent: 'capture',
46+
},
4247
context.resourcePath,
4348
),
4449
).rejects.toBe(finishError);
@@ -64,7 +69,12 @@ test('an uncertain finish preserves its error after confirmed compensating clean
6469
await expect(
6570
finishLiveDurableCapture(
6671
testCaptureDefinition,
67-
{ session: active, sessionName: context.sessionName, sessionStore: context.sessionStore },
72+
{
73+
session: active,
74+
sessionName: context.sessionName,
75+
sessionStore: context.sessionStore,
76+
intent: 'capture',
77+
},
6878
context.resourcePath,
6979
),
7080
).rejects.toMatchObject({ details: { reason: 'cleanup-unconfirmed' } });
@@ -95,7 +105,12 @@ test('an uncertain finish retains live evidence when compensating cleanup is unc
95105
await expect(
96106
finishLiveDurableCapture(
97107
testCaptureDefinition,
98-
{ session: active, sessionName: context.sessionName, sessionStore: context.sessionStore },
108+
{
109+
session: active,
110+
sessionName: context.sessionName,
111+
sessionStore: context.sessionStore,
112+
intent: 'capture',
113+
},
99114
context.resourcePath,
100115
),
101116
).rejects.toBe(finishError);
@@ -111,3 +126,108 @@ test('an uncertain finish retains live evidence when compensating cleanup is unc
111126
},
112127
});
113128
});
129+
130+
test('a preserved finish leaves the record open without disposing what its retry reads back', async () => {
131+
const context = makeDurableCaptureContext();
132+
const finishError = new Error('collect failed');
133+
const definition = createTestCaptureDefinition(testCaptureStore, 'preserve-retry-material');
134+
const start = makeDurableCaptureStartResult(context, { finishError });
135+
await adoptStartedDurableCapture(
136+
definition,
137+
{ ...context, ...start, throwIfCanceled: () => {} },
138+
context.resourcePath,
139+
);
140+
const active = context.sessions.get(context.sessionName);
141+
if (!active) throw new Error('Expected adopted test capture session');
142+
143+
await withDiagnosticsScope({ command: 'record' }, async () => {
144+
await expect(
145+
finishLiveDurableCapture(
146+
definition,
147+
{
148+
session: active,
149+
sessionName: context.sessionName,
150+
sessionStore: context.sessionStore,
151+
intent: 'capture',
152+
},
153+
context.resourcePath,
154+
),
155+
).rejects.toBe(finishError);
156+
expect(countDiagnosticEventsByPhase(['test_capture_finish_evidence_preserved'])).toBe(1);
157+
});
158+
expect(start.forceCleanup).not.toHaveBeenCalled();
159+
expect(context.sessions.get(context.sessionName)?.capture?.handle).toBe(start.handle);
160+
expect(testCaptureStore.read(context.resourcePath)).toMatchObject({
161+
status: 'decoded',
162+
envelope: { lifecycle: 'open', metadata: { phase: 'completing' } },
163+
});
164+
});
165+
166+
test('a preserved finish that reports uncertainty still leaves the record retryable', async () => {
167+
const context = makeDurableCaptureContext();
168+
const definition = createTestCaptureDefinition(testCaptureStore, 'preserve-retry-material');
169+
const start = makeDurableCaptureStartResult(context, {
170+
finish: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' },
171+
});
172+
await adoptStartedDurableCapture(
173+
definition,
174+
{ ...context, ...start, throwIfCanceled: () => {} },
175+
context.resourcePath,
176+
);
177+
const active = context.sessions.get(context.sessionName);
178+
if (!active) throw new Error('Expected adopted test capture session');
179+
180+
await expect(
181+
finishLiveDurableCapture(
182+
definition,
183+
{
184+
session: active,
185+
sessionName: context.sessionName,
186+
sessionStore: context.sessionStore,
187+
intent: 'capture',
188+
},
189+
context.resourcePath,
190+
),
191+
).rejects.toMatchObject({ details: { reason: 'cleanup-unconfirmed' } });
192+
expect(start.forceCleanup).not.toHaveBeenCalled();
193+
expect(testCaptureStore.read(context.resourcePath)).toMatchObject({
194+
status: 'decoded',
195+
envelope: {
196+
lifecycle: 'open',
197+
metadata: { phase: 'cleanup-pending', cleanupPendingReason: 'cleanup-unconfirmed' },
198+
},
199+
});
200+
});
201+
202+
test('a disposal finish disposes a preserving kind’s material too', async () => {
203+
const context = makeDurableCaptureContext();
204+
const finishError = new Error('teardown collect failed');
205+
const definition = createTestCaptureDefinition(testCaptureStore, 'preserve-retry-material');
206+
const start = makeDurableCaptureStartResult(context, { finishError });
207+
await adoptStartedDurableCapture(
208+
definition,
209+
{ ...context, ...start, throwIfCanceled: () => {} },
210+
context.resourcePath,
211+
);
212+
const active = context.sessions.get(context.sessionName);
213+
if (!active) throw new Error('Expected adopted test capture session');
214+
215+
await expect(
216+
finishLiveDurableCapture(
217+
definition,
218+
{
219+
session: active,
220+
sessionName: context.sessionName,
221+
sessionStore: context.sessionStore,
222+
intent: 'disposal',
223+
},
224+
context.resourcePath,
225+
),
226+
).rejects.toBe(finishError);
227+
expect(start.forceCleanup).toHaveBeenCalledOnce();
228+
expect(context.sessions.get(context.sessionName)?.capture).toBeUndefined();
229+
expect(testCaptureStore.read(context.resourcePath)).toMatchObject({
230+
status: 'decoded',
231+
envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } },
232+
});
233+
});

‎packages/capture-kit/src/durable-capture/transitions.ts‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { AppError } from '@agent-device/kernel/errors';
88
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
99
import { withDurableCaptureResourceFence, type DurableCaptureResourceFenceLease } from './fence.ts';
1010
import type {
11+
DurableCaptureFinishIntent,
1112
DurableCaptureRecordDefinition,
1213
DurableCaptureResourceDefinition,
1314
DurableCaptureSessionStore,
1415
} from './definition.ts';
15-
import { capitalizeDurableCaptureLabel } from './labels.ts';
16+
import { capitalizeDurableCaptureLabel, durableCaptureDiagnosticPrefix } from './labels.ts';
1617

1718
export async function finishLiveDurableCapture<
1819
K extends string,
@@ -21,7 +22,12 @@ export async function finishLiveDurableCapture<
2122
S,
2223
>(
2324
definition: DurableCaptureResourceDefinition<K, H, C, S>,
24-
params: { session: S; sessionName: string; sessionStore: DurableCaptureSessionStore<S> },
25+
params: {
26+
session: S;
27+
sessionName: string;
28+
sessionStore: DurableCaptureSessionStore<S>;
29+
intent: DurableCaptureFinishIntent;
30+
},
2531
resourcePath: string,
2632
): Promise<C> {
2733
const active = definition.sessionSlot.read(params.session);
@@ -31,6 +37,7 @@ export async function finishLiveDurableCapture<
3137
handle: active.handle,
3238
fence: active.envelope.fence,
3339
resourcePath,
40+
intent: params.intent,
3441
});
3542
clearLiveSlot(definition, params);
3643
return result;
@@ -53,6 +60,7 @@ export async function finishDurableCaptureHandle<
5360
handle: H;
5461
fence: DurableCaptureResourceFenceLease<K>['envelope']['fence'];
5562
resourcePath: string;
63+
intent: DurableCaptureFinishIntent;
5664
},
5765
): Promise<C> {
5866
return await withDurableCaptureResourceFence({
@@ -65,7 +73,7 @@ export async function finishDurableCaptureHandle<
6573
try {
6674
finishOutcome = await params.handle.finish();
6775
} catch (finishError) {
68-
await compensateFailedFinish(definition, lease, params, finishError);
76+
await respondToFailedFinish(definition, lease, params, finishError, params.intent);
6977
throw finishError;
7078
}
7179
if (finishOutcome.status === 'completed') {
@@ -74,18 +82,23 @@ export async function finishDurableCaptureHandle<
7482
}
7583
transitionFinishOutcome(definition, lease, finishOutcome);
7684
const finishError = cleanupPendingError(definition, finishOutcome);
77-
await compensateFailedFinish(definition, lease, params, finishError);
85+
await respondToFailedFinish(definition, lease, params, finishError, params.intent);
7886
throw finishError;
7987
},
8088
});
8189
}
8290

83-
async function compensateFailedFinish<K extends string, H extends LiveResourceHandle<C>, C>(
91+
async function respondToFailedFinish<K extends string, H extends LiveResourceHandle<C>, C>(
8492
definition: DurableCaptureRecordDefinition<K, C>,
8593
lease: DurableCaptureResourceFenceLease<K>,
8694
params: { handle: H; resourcePath: string },
8795
finishError: unknown,
96+
intent: DurableCaptureFinishIntent,
8897
): Promise<void> {
98+
if (intent === 'capture' && definition.failedFinishPolicy === 'preserve-retry-material') {
99+
emitFailedFinishPreservedDiagnostic(definition, params, finishError);
100+
return;
101+
}
89102
let cleanup: CleanupOutcome;
90103
try {
91104
cleanup = await params.handle.forceCleanup();
@@ -134,6 +147,22 @@ function persistCleanupPendingBestEffort<K extends string>(
134147
}
135148
}
136149

150+
function emitFailedFinishPreservedDiagnostic<K extends string, C>(
151+
definition: DurableCaptureRecordDefinition<K, C>,
152+
params: { resourcePath: string },
153+
finishError: unknown,
154+
): void {
155+
emitDiagnostic({
156+
level: 'error',
157+
phase: `${durableCaptureDiagnosticPrefix(definition.resourceKind)}_finish_evidence_preserved`,
158+
data: {
159+
resourcePath: params.resourcePath,
160+
finishError: errorMessage(finishError),
161+
failedFinishPolicy: definition.failedFinishPolicy,
162+
},
163+
});
164+
}
165+
137166
function emitFailedFinishCleanupDiagnostic<K extends string, C>(
138167
definition: DurableCaptureRecordDefinition<K, C>,
139168
params: { resourcePath: string },
@@ -143,7 +172,7 @@ function emitFailedFinishCleanupDiagnostic<K extends string, C>(
143172
): void {
144173
emitDiagnostic({
145174
level: 'error',
146-
phase: `${definition.resourceKind.replaceAll('-', '_')}_finish_cleanup_failed`,
175+
phase: `${durableCaptureDiagnosticPrefix(definition.resourceKind)}_finish_cleanup_failed`,
147176
data: {
148177
resourcePath: params.resourcePath,
149178
finishError: errorMessage(finishError),

‎packages/contracts/src/screen-recording-runtime.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import type { ResourceOwnershipFence } from './platform-runtime.ts';
99

1010
export const SCREEN_RECORDING_RESOURCE_KIND = 'screen-recording' as const;
1111

12+
/** The recording's file exists and is not a video, which only a still-finalizing recorder can undo. */
13+
export const RECORDING_OUTPUT_UNPLAYABLE_REASON = 'recording-output-unplayable';
14+
1215
type RecordingTelemetryBase = Readonly<{
1316
tMs: number;
1417
x: number;

‎packages/host-kit/src/internal/exec.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -646,10 +646,11 @@ export function requireExecSuccess(
646646
/**
647647
* COMMAND_FAILED details for a non-zero exec result. `processExitError: true`
648648
* lets normalizeError surface the first meaningful stderr line as the user-facing
649-
* message instead of the generic wrap message.
649+
* message instead of the generic wrap message. A process killed by a signal reports
650+
* no exit code, so the raw child_process null is accepted as well.
650651
*/
651652
export function execFailureDetails(
652-
result: Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode'>,
653+
result: Pick<ExecResult, 'stdout' | 'stderr'> & Readonly<{ exitCode: number | null }>,
653654
extra?: Record<string, unknown>,
654655
): Record<string, unknown> {
655656
return {

0 commit comments

Comments
 (0)