11import { identityKey , type PlanIdentity } from '../core/identity.ts' ;
22import { captureInvocation , type InvocationHandle , type InvocationInput , type InvocationResult , type StopReason , type TaskClone , type UnreleasedResource } from '../agents/contract.ts' ;
3- import type { AttemptRecord , Store } from './store.ts' ;
3+ import type { AttemptRecord , LedgerEntry , Store } from './store.ts' ;
44import { ATTEMPT_PHASES , GuardRefusal , ShuttingDownError , WRITABLE_KINDS , bounded , sameContext , type AttemptKind , type Classification , type FirstReason , type ShutdownCapability } from './lifecycle.ts' ;
55
66/** What F's host-side preparation hands to D's start call. */
77export interface PreparedAttempt {
88 readonly clone : TaskClone ;
99 readonly vendor : 'claude' | 'codex' ;
1010 readonly approvedArgv : readonly ( readonly string [ ] ) [ ] ;
11+ /** Opaque data the deps keep for their own finish/release steps (for example the task workspace). */
12+ readonly private ?: unknown ;
1113}
14+ /** The ledger record saved with `completed` in the same transaction (runner-lifecycle.md, publication step 3). */
15+ export interface HistoryRecord { readonly base : string ; readonly head : string ; readonly entries : readonly LedgerEntry [ ] }
16+ /** A finish step's failure with its own actionable diagnostic (for example a safety violation). */
17+ export class FinishFailure extends Error { }
1218export interface RunnerDeps {
1319 /**
1420 * The runner token D labels every resource with (32 lowercase hex characters). `prepare` must allocate task storage
@@ -29,6 +35,14 @@ export interface RunnerDeps {
2935 start ( input : InvocationInput , prepared : PreparedAttempt ) : InvocationHandle ;
3036 /** Validate a clean result; throw with an actionable reason if it is invalid. Returns the value to persist. */
3137 validate ( attempt : AttemptRecord , result : InvocationResult ) : unknown ;
38+ /**
39+ * Optional asynchronous replacement for validate, used by writable attempts: audit, make the runner commit inside
40+ * task storage, and return the value plus the ledger record. Nothing is written to the Store here; the record is
41+ * saved with `completed` in one transaction. Throw FinishFailure with an actionable diagnostic to fail the attempt.
42+ */
43+ finish ?( attempt : AttemptRecord , result : InvocationResult , prepared : PreparedAttempt , signal : AbortSignal ) : Promise < { value : unknown ; history ?: HistoryRecord } > ;
44+ /** Optional: remove task storage after the terminal write and before the slot is freed. A failure keeps the slot under a marker. */
45+ release ?( attempt : AttemptRecord , prepared : PreparedAttempt ) : Promise < void > ;
3246 now ?( ) : number ;
3347}
3448export interface SlotLimits { readonly writable : number ; readonly readOnly : number }
@@ -246,27 +260,27 @@ export class RunnerCoordinator {
246260 let prepared : PreparedAttempt ;
247261 try { prepared = await this . #deps. prepare ( attempt , job . controller . signal ) ; }
248262 catch ( error ) { return await this . #endBeforeLaunch( job , attempt , this . #preparationDetail( job , error ) ) ; }
249- if ( job . firstReason || job . preparationTimedOut ) return await this . #endBeforeLaunch( job , attempt , this . #preparationDetail( job ) ) ;
263+ if ( job . firstReason || job . preparationTimedOut ) return await this . #endBeforeLaunch( job , attempt , this . #preparationDetail( job ) , prepared ) ;
250264 // Launch check: one synchronous turn, no await between the checks and D's start call.
251265 const now = this . #now( ) , row = this . #store. getAttempt ( job . identity , attempt . id ) , task = this . #store. getTask ( job . identity ) ;
252266 if ( row . firstReason && ! job . firstReason ) job . firstReason = row . firstReason ;
253- if ( row . state !== 'pending' || job . firstReason ) return await this . #endBeforeLaunch( job , attempt , { } ) ;
267+ if ( row . state !== 'pending' || job . firstReason ) return await this . #endBeforeLaunch( job , attempt , { } , prepared ) ;
254268 // A context change comes before both time checks, as in the settlement order and startup recovery.
255269 if ( ! sameContext ( row . context , this . #store. currentContext ( job . identity ) ) ) {
256270 // Recorded like any stale stop, so the row keeps it even if a cancel task lands during cleanup.
257271 this . #requestStop( job , 'stale' ) ;
258- return await this . #endBeforeLaunch( job , attempt , { } ) ;
272+ return await this . #endBeforeLaunch( job , attempt , { } , prepared ) ;
259273 }
260- if ( task . budgetDeadline !== null && now >= task . budgetDeadline ) { this . #requestStop( job , 'time-limit' ) ; return await this . #endBeforeLaunch( job , attempt , { } ) ; }
261- if ( now >= attempt . deadline ) { job . preparationTimedOut = true ; return await this . #endBeforeLaunch( job , attempt , { detail : PREPARATION_TIMEOUT } ) ; }
274+ if ( task . budgetDeadline !== null && now >= task . budgetDeadline ) { this . #requestStop( job , 'time-limit' ) ; return await this . #endBeforeLaunch( job , attempt , { } , prepared ) ; }
275+ if ( now >= attempt . deadline ) { job . preparationTimedOut = true ; return await this . #endBeforeLaunch( job , attempt , { detail : PREPARATION_TIMEOUT } , prepared ) ; }
262276 // Fail closed: once D reported resources it could not remove, no new invocation starts, even one already admitted.
263- if ( this . #unreleased) return await this . #endBeforeLaunch( job , attempt , { detail : NOT_STARTED_UNRELEASED } ) ;
277+ if ( this . #unreleased) return await this . #endBeforeLaunch( job , attempt , { detail : NOT_STARTED_UNRELEASED } , prepared ) ;
264278 let handle : InvocationHandle ;
265279 try {
266280 const input = captureInvocation ( { clone : prepared . clone , phase : ATTEMPT_PHASES [ attempt . kind ] , vendor : prepared . vendor ,
267281 approvedArgv : prepared . approvedArgv , deadline : attempt . deadline , attemptId : attempt . id , runnerOwner : this . #deps. runnerOwner , context : attempt . context } , now ) ;
268282 handle = this . #deps. start ( input , prepared ) ;
269- } catch ( error ) { return await this . #endBeforeLaunch( job , attempt , { detail : `Launch failed: ${ message ( error ) } ` } ) ; }
283+ } catch ( error ) { return await this . #endBeforeLaunch( job , attempt , { detail : `Launch failed: ${ message ( error ) } ` } , prepared ) ; }
270284 job . handle = handle ;
271285 let running : boolean | undefined ;
272286 try { running = this . #write( ( ) => this . #store. markRunning ( job . identity , attempt . id ) ) ; } catch { running = undefined ; }
@@ -291,20 +305,27 @@ export class RunnerCoordinator {
291305 // Accept only the result of this exact invocation, as the question path does. Anything else is never validated
292306 // or saved: the attempt fails closed.
293307 if ( result . attemptId !== attempt . id || ! result . context || ! sameContext ( result . context , attempt . context ) ) {
294- this . #settle( job , { stopReason : 'capture-failure' , exitCode : null , signal : null , valid : false ,
308+ const foreignSaved = this . #settle( job , { stopReason : 'capture-failure' , exitCode : null , signal : null , valid : false ,
295309 detail : job . firstReason === 'stale' ? job . staleCause : FOREIGN_RESULT } ) ;
296310 job . decided = true ;
311+ // Task storage waits for the terminal write, as on every other path.
312+ if ( foreignSaved ) await this . #release( job , attempt , prepared ) ;
297313 return ;
298314 }
299- let valid = false , value : unknown , detail = result . stderr ? bounded ( result . stderr ) : undefined ;
315+ let valid = false , value : unknown , history : HistoryRecord | undefined , detail = result . stderr ? bounded ( result . stderr ) : undefined ;
300316 if ( ! job . firstReason && result . exitCode === 0 && ! result . stopReason ) {
301- try { value = this . #deps. validate ( attempt , result ) ; valid = true ; }
302- catch ( error ) { detail = `Invalid output: ${ message ( error ) } ` ; }
317+ try {
318+ if ( this . #deps. finish ) { const done = await this . #deps. finish ( attempt , result , prepared , job . controller . signal ) ; value = done . value ; history = done . history ; }
319+ else value = this . #deps. validate ( attempt , result ) ;
320+ valid = true ;
321+ } catch ( error ) { detail = error instanceof FinishFailure ? bounded ( error . message ) : `Invalid output: ${ message ( error ) } ` ; }
303322 }
304323 // A stale stop keeps its own cause; the agent's stderr is not a reason the attempt went stale.
305324 if ( job . firstReason === 'stale' ) detail = job . staleCause ;
306- const saved = this . #settle( job , { stopReason : result . stopReason , exitCode : result . exitCode , signal : result . signal , valid, result : value , detail } ) ;
325+ const saved = this . #settle( job , { stopReason : result . stopReason , exitCode : result . exitCode , signal : result . signal , valid, result : value , detail, history } ) ;
307326 job . decided = true ;
327+ // Task storage goes after the terminal write too; a failed removal holds the slot under a marker.
328+ if ( saved ) await this . #release( job , attempt , prepared ) ;
308329 // Host-side preparation files go after the terminal write, so a failed write leaves them for startup recovery.
309330 if ( saved && ! ( await this . #removePreparation( job , attempt ) ) ) this . #holdForPreparation( job ) ;
310331 } catch ( error ) {
@@ -327,11 +348,13 @@ export class RunnerCoordinator {
327348 return error === undefined || job . firstReason ? { } : { detail : `Preparation failed: ${ message ( error ) } ` } ;
328349 }
329350 /** Ending without a handle: host-side cleanup, then the terminal write from the first reason. */
330- async #endBeforeLaunch( job : Job , attempt : AttemptRecord , s : { detail ?: string } ) : Promise < void > {
351+ async #endBeforeLaunch( job : Job , attempt : AttemptRecord , s : { detail ?: string } , prepared ?: PreparedAttempt ) : Promise < void > {
331352 // Stops that land while preparation finishes are taken into account; once the job is ending, the outcome is fixed.
332353 job . decided = true ;
333354 const removed = await this . #removePreparation( job , attempt ) ;
334- this . #settle( job , { exitCode : null , signal : null , valid : false , detail : job . firstReason === 'stale' ? job . staleCause : s . detail } ) ;
355+ const saved = this . #settle( job , { exitCode : null , signal : null , valid : false , detail : job . firstReason === 'stale' ? job . staleCause : s . detail } ) ;
356+ // Task storage (if preparation allocated it) waits for the terminal write, like every other path.
357+ if ( saved && prepared ) await this . #release( job , attempt , prepared ) ;
335358 if ( ! removed ) this . #holdForPreparation( job ) ;
336359 }
337360 /**
@@ -349,7 +372,13 @@ export class RunnerCoordinator {
349372 #holdForPreparation( job : Job ) : void {
350373 if ( ! this . #markers. has ( job . key ) ) this . #markers. set ( job . key , { group : job . group , attemptId : job . attemptId , reason : 'preparation-not-removed' } ) ;
351374 }
352- #settle( job : Job , s : { stopReason ?: StopReason ; exitCode : number | null ; signal : string | null ; valid : boolean ; result ?: unknown ; detail ?: string } ) : Classification | undefined {
375+ /** After the terminal write: remove task storage, then the slot is freed. A failure keeps the slot under a marker. */
376+ async #release( job : Job , attempt : AttemptRecord , prepared : PreparedAttempt ) : Promise < void > {
377+ if ( ! this . #deps. release ) return ;
378+ try { await this . #deps. release ( attempt , prepared ) ; }
379+ catch { if ( ! this . #markers. has ( job . key ) ) this . #markers. set ( job . key , { group : job . group , attemptId : job . attemptId , reason : 'result-not-saved' } ) ; }
380+ }
381+ #settle( job : Job , s : { stopReason ?: StopReason ; exitCode : number | null ; signal : string | null ; valid : boolean ; result ?: unknown ; detail ?: string ; history ?: HistoryRecord } ) : Classification | undefined {
353382 try {
354383 return this . #write( ( ) => this . #store. settleAttempt ( job . identity , job . attemptId , { ...s , firstReason : job . firstReason } ) ) ;
355384 } catch {
0 commit comments