Skip to content

Commit bdc21e8

Browse files
committed
refactor(daemon): model repair close retry as receipt
1 parent 1f0df4c commit bdc21e8

5 files changed

Lines changed: 20 additions & 153 deletions

File tree

scripts/layering/session-state.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly<Record<string, readonly string
8181
saveScriptCommitted: ['src/daemon/session-script-writer.ts'],
8282
repairSourcePath: ['src/daemon/handlers/session-replay-runtime.ts'],
8383
pendingRecordAndHeal: ['src/daemon/handlers/session-replay-resume.ts'],
84-
repairPlatformCloseSucceeded: ['src/daemon/handlers/session-close.ts'],
85-
repairPlatformCloseIdentity: ['src/daemon/handlers/session-close.ts'],
84+
repairPlatformCloseReceipt: ['src/daemon/handlers/session-close.ts'],
8685

8786
trace: ['src/daemon/handlers/record-trace.ts'],
8887
recording: ['src/daemon/handlers/record-trace-recording.ts'],

src/daemon/handlers/__tests__/session-device-claims.test.ts

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -314,30 +314,13 @@ test('#1391: a close-time script save failure still clears the advisory claim an
314314
assert.deepEqual(inspectDeviceClaims({ serial: android.id }), []);
315315
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'pre-existing\n');
316316

317-
// No rollback: the durable `action.recorded: close` event this close wrote
318-
// to events.ndjson (before the script write failed) survives — teardown
319-
// never withholds it, since there is no surviving session for a later
320-
// write to duplicate it against. Guards against a future rollback or
321-
// event-order change silently recreating the in-memory/durable mismatch
322-
// this test's own name warns about.
323-
await store.flushEvents('close-save-script-failure');
324-
const events = store.readEvents('close-save-script-failure').events;
325-
const closeEvent = events.find(
326-
(event) => event.kind === 'action.recorded' && event.command === 'close',
327-
);
328-
assert.ok(
329-
closeEvent,
330-
'expected a durable action.recorded:close event to survive the failed save',
331-
);
332-
// Same assertion, in-memory: reinstating `session.actions.length =
333-
// actionsBeforeClose` would still pass the durable-event check above (that
334-
// event was already queued before the write failed) while silently
335-
// dropping this to zero — assert directly on the retained session object,
336-
// not just the store, so that specific regression is caught.
337-
const inMemoryCloseActions = session.actions.filter((action) => action.command === 'close');
338-
assert.equal(inMemoryCloseActions.length, 1);
339-
assert.equal(
340-
inMemoryCloseActions.length,
341-
events.filter((event) => event.command === 'close').length,
317+
assert.deepEqual(
318+
session.actions.map((action) => action.command),
319+
['close'],
342320
);
321+
await store.flushEvents('close-save-script-failure');
322+
const durableCloseEvents = store
323+
.readEvents('close-save-script-failure')
324+
.events.filter((event) => event.kind === 'action.recorded' && event.command === 'close');
325+
assert.equal(durableCloseEvents.length, session.actions.length);
343326
});

src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,7 @@ test('BLOCKER 3 (second follow-up): a retry after a SUCCESSFUL platform close bu
984984
if (!closeResponse.ok) expect(closeResponse.error.message).toMatch(/already exists/);
985985
expect(sessionStore.get(sessionName)).toBeDefined();
986986
expect(fs.readFileSync(healedPath, 'utf8')).toBe(before);
987-
expect(sessionStore.get(sessionName)!.repairPlatformCloseSucceeded).toBe(true);
987+
expect(sessionStore.get(sessionName)!.repairPlatformCloseReceipt).toBeDefined();
988988

989989
// Retry with an explicit path: the ALREADY-SUCCEEDED platform close must
990990
// NEVER be dispatched again — a non-idempotent backend could fail (or
@@ -1043,14 +1043,6 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact
10431043
expect(fs.readFileSync(healedPath, 'utf8')).toBe(committed);
10441044
});
10451045

1046-
// --- ADR 0012 decision 6 (BLOCKER 3, third follow-up): `repairPlatformCloseSucceeded`
1047-
// was session-wide, not bound to WHICH close request actually succeeded. An
1048-
// untargeted close performs NO platform operation (`shouldDispatchPlatformClose`
1049-
// is false with no positional target), yet the prior implementation still set
1050-
// the marker as though a real close succeeded; a retry with a DIFFERENT
1051-
// identity (a target added, or a different target) then wrongly skipped the
1052-
// platform close entirely, committing as though it had run. ---
1053-
10541046
test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platform operation never lets a targeted retry skip the platform close', async () => {
10551047
const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup(
10561048
'agent-device-repair-transaction-close-identity-untargeted-',
@@ -1078,9 +1070,6 @@ test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platfor
10781070
expect(mockDispatchCommand).not.toHaveBeenCalled();
10791071
expect(sessionStore.get(sessionName)).toBeDefined();
10801072

1081-
// Retry WITH a target: the prior session-wide `repairPlatformCloseSucceeded`
1082-
// flag (set true even though nothing dispatched for the untargeted attempt)
1083-
// would wrongly skip the platform close here. It must actually run.
10841073
const retry = await handleCloseCommand({
10851074
req: {
10861075
token: 't',
@@ -1126,7 +1115,7 @@ test('BLOCKER 3 (third follow-up): a retry targeting a DIFFERENT app than the su
11261115
});
11271116
expect(first.ok).toBe(false);
11281117
expect(mockDispatchCommand).toHaveBeenCalledTimes(1);
1129-
expect(sessionStore.get(sessionName)!.repairPlatformCloseSucceeded).toBe(true);
1118+
expect(sessionStore.get(sessionName)!.repairPlatformCloseReceipt).toBeDefined();
11301119

11311120
// Retry targets a DIFFERENT app (app-b) — a genuinely different platform
11321121
// operation. The prior session-wide marker would wrongly treat app-b as

src/daemon/handlers/session-close.ts

Lines changed: 7 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,6 @@ function buildRepairCloseFailureResponse(session: SessionState, error: AppError)
159159
};
160160
}
161161

162-
/**
163-
* ADR 0012 decision 6 (BLOCKER 2, new): normalizes a repair-armed session's
164-
* FAILED platform close into a distinct, surfaceable AppError, mirroring
165-
* `toRepairCommitFailure` in `session-script-writer.ts`. An AppError from the
166-
* platform close (e.g. a device-unavailable failure) already carries its own
167-
* code/details/hint and passes through unchanged; anything else is wrapped
168-
* with a clear message so the agent can tell this apart from a write failure.
169-
*/
170162
function toRepairPlatformCloseFailure(error: unknown): AppError {
171163
if (error instanceof AppError) return error;
172164
const detail = error instanceof Error ? error.message : String(error);
@@ -189,10 +181,6 @@ type SessionCloseTeardownResult = {
189181
saveScriptError?: AppError;
190182
};
191183

192-
// Runs the failure-isolated resource teardown and the targeted platform close
193-
// (#1225). Returns the preserved platform-close error (if any); best-effort
194-
// cleanup failures are pushed into `cleanupFailures`. Never throws for a cleanup
195-
// step so the caller can make an explicit decision about lease/session commit.
196184
async function runSessionCloseTeardown(params: {
197185
req: DaemonRequest;
198186
session: SessionState;
@@ -201,11 +189,6 @@ async function runSessionCloseTeardown(params: {
201189
sessionStore: SessionStore;
202190
cleanupFailures: SessionCleanupFailure[];
203191
repairArmed: boolean;
204-
// ADR 0012 decision 6 (BLOCKER 2): a repair-armed session already dispatched
205-
// (and confirmed the success of) its platform close BEFORE this teardown —
206-
// see `handleCloseCommand`. Dispatching it again here would be redundant at
207-
// best and a double-close at worst, so it is skipped for that case only.
208-
skipPlatformClose: boolean;
209192
}): Promise<SessionCloseTeardownResult> {
210193
const { req, session, sessionName, logPath, sessionStore, cleanupFailures, repairArmed } = params;
211194
const attemptCleanup = async (step: string, run: () => Promise<void>): Promise<void> => {
@@ -215,27 +198,13 @@ async function runSessionCloseTeardown(params: {
215198
cleanupFailures.push({ step, error });
216199
}
217200
};
218-
// Decide runner retention from the PRE-teardown state: an active recording
219-
// defeats retention, and `stopBestEffortSessionResources` below finalizes (and
220-
// clears) that recording, so the decision must be captured before it runs.
221201
const retainAppleRunner = shouldRetainAppleRunnerAfterClose(req, session);
222202
await stopBestEffortSessionResources(session, sessionStore, attemptCleanup);
223-
// The targeted platform close is the primary operation, not best-effort cleanup:
224-
// its AppError (code/details/hint) is preserved and returned for the caller to
225-
// rethrow, and a failed close must not be recorded as `Closed`. Subsequent
226-
// resource cleanup still runs regardless.
227-
const platformCloseError = params.skipPlatformClose
203+
const platformCloseError = repairArmed
228204
? undefined
229205
: await dispatchTargetedPlatformClose({ req, session, logPath });
230206
await stopOrRetainAppleRunnerAfterClose(retainAppleRunner, session, attemptCleanup);
231207
await clearSessionRuntimeHints(session, sessionStore, sessionName);
232-
// ADR 0012 decision 6 (BLOCKER 2): a repair-armed session already recorded its
233-
// finalize `close` and committed (or aborted) its healed `.ad` BEFORE this
234-
// teardown (commit-state machine — the single commit path), and only AFTER
235-
// its platform close (dispatched above `handleCloseCommand`) was confirmed to
236-
// succeed. Only an ordinary (non-repair) session records `close` + writes its
237-
// session log here, and — per #1225 — a failed platform close is not recorded
238-
// as `Closed`.
239208
const saveScriptError = repairArmed
240209
? undefined
241210
: finalizeOrdinaryCloseScript({ req, session, sessionStore, platformCloseError });
@@ -328,11 +297,7 @@ async function stopBestEffortSessionResources(
328297
sessionStore: SessionStore,
329298
attemptCleanup: CleanupRunner,
330299
): Promise<void> {
331-
// Finalize any still-active recording first so closing a session mid-recording
332-
// (without an explicit `record stop`) cannot leak the recorder process; must
333-
// run before the Apple runner is stopped below since overlay finalization
334-
// consults the runner. `shouldRetainAppleRunnerAfterClose` then observes the
335-
// now-cleared `session.recording`.
300+
// Recording overlay finalization needs the Apple runner.
336301
await attemptCleanup('recording', () => stopSessionRecordingForTeardown(session));
337302
await attemptCleanup('app_log', () => stopSessionAppLog(session));
338303
await attemptCleanup('audio_probe', async () => {
@@ -346,22 +311,7 @@ async function stopBestEffortSessionResources(
346311
);
347312
}
348313

349-
/**
350-
* ADR 0012 decision 6 (BLOCKER 3, third follow-up): identifies WHICH close
351-
* request's platform close succeeded — not merely THAT one did. Only the
352-
* request's TARGET (`positionals`) can change what `dispatchTargetedPlatformClose`
353-
* actually does: `shouldDispatchPlatformClose` decides purely from
354-
* `hasCloseTarget(req)` (plus the `web` special case, constant for a given
355-
* session), and the dispatch itself is `dispatchCommand(device, 'close',
356-
* req.positionals, ...)`. `close`'s only other flags (`shutdown`, `saveScript`
357-
* — see `closeCliSchema`) feed the post-teardown shutdown and the commit path
358-
* respectively, never this call, so they carry no identity here. Binding the
359-
* marker to this identity means an untargeted close's "succeeded" (a no-op,
360-
* since `shouldDispatchPlatformClose` was false) can never be misread as "the
361-
* platform close for THIS target already ran" by a later retry that adds or
362-
* changes the target — that retry's identity differs, so it re-dispatches.
363-
*/
364-
function repairPlatformCloseIdentity(req: DaemonRequest): string {
314+
function buildRepairPlatformCloseReceipt(req: DaemonRequest): string {
365315
return JSON.stringify(req.positionals ?? []);
366316
}
367317

@@ -377,11 +327,8 @@ async function prepareRepairClose(params: {
377327
}): Promise<RepairClosePreparation> {
378328
const { req, session, logPath, sessionStore } = params;
379329
const repairArmed = session.saveScriptBoundary !== undefined;
380-
const closeIdentity = repairPlatformCloseIdentity(req);
381-
if (
382-
repairArmed &&
383-
!(session.repairPlatformCloseSucceeded && session.repairPlatformCloseIdentity === closeIdentity)
384-
) {
330+
const closeReceipt = buildRepairPlatformCloseReceipt(req);
331+
if (repairArmed && session.repairPlatformCloseReceipt !== closeReceipt) {
385332
const platformCloseError = await dispatchTargetedPlatformClose({ req, session, logPath });
386333
if (platformCloseError) {
387334
return {
@@ -391,15 +338,13 @@ async function prepareRepairClose(params: {
391338
),
392339
};
393340
}
394-
session.repairPlatformCloseSucceeded = true;
395-
session.repairPlatformCloseIdentity = closeIdentity;
341+
session.repairPlatformCloseReceipt = closeReceipt;
396342
}
397343
const repairCommit = commitRepairBeforeClose(sessionStore, session, req);
398344
if (repairCommit.kind === 'failed') {
399345
return { response: buildRepairCloseFailureResponse(session, repairCommit.error) };
400346
}
401-
session.repairPlatformCloseSucceeded = false;
402-
session.repairPlatformCloseIdentity = undefined;
347+
session.repairPlatformCloseReceipt = undefined;
403348
return {
404349
repairArmed,
405350
...(repairCommit.kind === 'committed' && repairCommit.path
@@ -531,24 +476,6 @@ export async function handleCloseCommand(params: {
531476
if (req.internal?.closeAppOnly === true) {
532477
return await closeAppWithoutEndingSession({ req, session, logPath });
533478
}
534-
// ADR 0012 decision 6 (BLOCKER 2): for a repair-armed session, the platform
535-
// close must run and SUCCEED before anything is committed or torn down —
536-
// otherwise a committed healed `.ad` could claim a successful `close` that
537-
// never actually happened on the device. On failure, return without
538-
// touching the session at all (mirrors the commit-failure path below): it
539-
// stays addressable so the agent can fix the cause and retry.
540-
//
541-
// BLOCKER 3: a PRIOR close attempt on this same session may already have
542-
// dispatched the platform close and confirmed its success, then failed to
543-
// commit (the session is retained for exactly that retry — see below). A
544-
// retry must never re-dispatch a (possibly non-idempotent) platform close
545-
// against an already-closed target; `repairPlatformCloseSucceeded` +
546-
// `repairPlatformCloseIdentity` together record that the platform-level
547-
// close already happened FOR THIS EXACT request identity, so a same-identity
548-
// retry consumes it and goes straight to the commit instead. A retry whose
549-
// identity DIFFERS (third follow-up: e.g. untargeted -> targeted, or a
550-
// changed target) never matches — the marker only ever attests to the
551-
// identity it was recorded under, so the platform close runs (again).
552479
const repair = await prepareRepairClose({ req, session, logPath, sessionStore });
553480
if ('response' in repair) return repair.response;
554481
const closed = await runCloseTeardownAndRelease({
@@ -622,9 +549,6 @@ async function runCloseTeardownAndRelease(params: {
622549
sessionStore,
623550
cleanupFailures,
624551
repairArmed: params.repairArmed,
625-
// The platform close for a repair-armed session already ran (and was
626-
// confirmed to succeed) above, before the commit — never dispatch it twice.
627-
skipPlatformClose: params.repairArmed,
628552
});
629553
const leaseRelease = await releaseProviderLeaseForClose({
630554
session,

src/daemon/types.ts

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -406,36 +406,8 @@ export type SessionState = {
406406
* (nothing to tombstone) from an aborted/reaped one.
407407
*/
408408
saveScriptCommitted?: boolean;
409-
/**
410-
* ADR 0012 decision 6 (BLOCKER 3, second follow-up): set the moment a
411-
* repair-armed `close`'s targeted platform close returns SUCCESS, cleared
412-
* once the transaction commits/tears down (never lingers past a single
413-
* close attempt's outcome). If the subsequent commit then FAILS (no-clobber
414-
* refusal, a bare-`@ref` failure, or an fs error) the session is retained
415-
* for retry — a later `close`/`close --save-script=<other>` on the SAME
416-
* session must consume this flag and skip re-dispatching the platform
417-
* close rather than invoking a (possibly non-idempotent) backend a second
418-
* time against an already-closed target.
419-
*
420-
* BLOCKER 3 (third follow-up): this flag alone is session-wide, not bound
421-
* to WHICH close request succeeded — see `repairPlatformCloseIdentity`,
422-
* which must ALSO match the retry's own request identity before a retry is
423-
* allowed to skip the platform close.
424-
*/
425-
repairPlatformCloseSucceeded?: boolean;
426-
/**
427-
* ADR 0012 decision 6 (BLOCKER 3, third follow-up): the identity
428-
* (`repairPlatformCloseIdentity` in session-close.ts — currently just the
429-
* request's target/positionals, the only thing that changes what the
430-
* platform close actually dispatches) of the close request whose platform
431-
* close last succeeded. A retry only gets to skip re-dispatching the
432-
* platform close when BOTH `repairPlatformCloseSucceeded` is true AND this
433-
* matches the retry's own identity — an untargeted close that performed NO
434-
* platform operation (so `repairPlatformCloseSucceeded` is trivially true)
435-
* must never let a later targeted (or differently-targeted) retry skip the
436-
* platform close it never actually ran.
437-
*/
438-
repairPlatformCloseIdentity?: string;
409+
/** Target identity of a successful repair close awaiting script commit. */
410+
repairPlatformCloseReceipt?: string;
439411
/**
440412
* ADR 0012 decision 6, R7 (C5a): the original replay input path of an armed
441413
* repair, stashed so an idle-reap tombstone can hand the agent an actionable

0 commit comments

Comments
 (0)