11import { identityKey , type PlanIdentity } from '../core/identity.ts' ;
22import { captureInvocation , type InvocationHandle , type InvocationInput , type InvocationResult , type StopReason , type TaskClone } from '../agents/contract.ts' ;
33import type { AttemptRecord , Store } from './store.ts' ;
4- import { ATTEMPT_PHASES , GuardRefusal , WRITABLE_KINDS , bounded , sameContext , type AttemptKind , type Classification , type FirstReason } from './lifecycle.ts' ;
4+ import { 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 {
@@ -58,10 +58,15 @@ export class RunnerCoordinator {
5858 #store: Store ; #deps: RunnerDeps ; #limits: SlotLimits ;
5959 #jobs = new Map < string , Job > ( ) ; #markers = new Map < string , Marker > ( ) ;
6060 #closing = false ;
61- constructor ( store : Store , deps : RunnerDeps , limits : SlotLimits = { writable : 1 , readOnly : 1 } ) {
61+ /** Settlement writes run with the shutdown capability, so they still land after the write gate closes. */
62+ #write: < T > ( fn : ( ) => T ) => T ;
63+ constructor ( store : Store , deps : RunnerDeps , limits : SlotLimits = { writable : 1 , readOnly : 1 } , capability ?: ShutdownCapability ) {
6264 if ( ! [ limits . writable , limits . readOnly ] . every ( n => Number . isSafeInteger ( n ) && n >= 1 ) ) throw new Error ( 'Slot limits must be positive integers.' ) ;
6365 this . #store = store ; this . #deps = deps ; this . #limits = limits ;
66+ this . #write = capability ? fn => capability . run ( fn ) : fn => fn ( ) ;
6467 }
68+ /** Shutdown step 1: reject admission synchronously, in the same turn as the server flag and the Store gate. */
69+ rejectAdmission ( ) : void { this . #closing = true ; }
6570 get closing ( ) : boolean { return this . #closing; }
6671 #now( ) : number { return this . #deps. now ?.( ) ?? Date . now ( ) ; }
6772 #used( group : Group ) : number {
@@ -75,7 +80,7 @@ export class RunnerCoordinator {
7580 * a refused transaction releases the reservation in the same turn.
7681 */
7782 start ( identity : PlanIdentity , request : StartRequest ) : AttemptRecord {
78- if ( this . #closing) throw new GuardRefusal ( 'The runner is shutting down.' ) ;
83+ if ( this . #closing) throw new ShuttingDownError ( ) ;
7984 const key = identityKey ( identity ) ;
8085 if ( this . #jobs. has ( key ) ) throw new GuardRefusal ( 'An attempt is already active for this task.' ) ;
8186 const marker = this . #markers. get ( key ) ;
@@ -90,7 +95,8 @@ export class RunnerCoordinator {
9095 catch ( error ) { this . #jobs. delete ( key ) ; throw error ; }
9196 job . attemptId = attempt . id ; job . attempt = attempt ;
9297 this . #arm( job , attempt ) ;
93- job . done = this . #run( job , attempt ) . catch ( error => this . #unexpected( job , error ) ) ;
98+ // Start after the caller's transaction commits: a rolled-back admission must not leave a job running.
99+ job . done = Promise . resolve ( ) . then ( ( ) => this . #run( job , attempt ) ) . catch ( error => this . #unexpected( job , error ) ) ;
94100 return attempt ;
95101 }
96102 /** Retry is a new attempt bound to the latest failed or cancelled one. */
@@ -136,7 +142,7 @@ export class RunnerCoordinator {
136142 if ( job . firstReason ) { job . handle ?. cancel ( D_REASON [ job . firstReason ] ) ; return false ; }
137143 job . firstReason = reason ;
138144 try {
139- if ( job . attemptId && ! this . #store. recordFirstReason ( job . identity , job . attemptId , reason ) ) {
145+ if ( job . attemptId && ! this . #write ( ( ) => this . # store. recordFirstReason ( job . identity , job . attemptId , reason ) ) ) {
140146 // Another writer (for example cancel task) recorded a reason first; adopt the durable one.
141147 const durable = this . #store. getAttempt ( job . identity , job . attemptId ) . firstReason ;
142148 if ( durable ) job . firstReason = durable ;
@@ -163,6 +169,7 @@ export class RunnerCoordinator {
163169 }
164170 async #run( job : Job , attempt : AttemptRecord ) : Promise < void > {
165171 try {
172+ if ( ! this . #store. getAttempts ( job . identity ) . some ( row => row . id === attempt . id ) ) return ; // admission was rolled back
166173 let prepared : PreparedAttempt ;
167174 try { prepared = await this . #deps. prepare ( attempt , job . controller . signal ) ; }
168175 catch ( error ) { return await this . #endBeforeLaunch( job , attempt , this . #preparationDetail( job , error ) ) ; }
@@ -182,7 +189,7 @@ export class RunnerCoordinator {
182189 } catch ( error ) { return await this . #endBeforeLaunch( job , attempt , { detail : `Launch failed: ${ message ( error ) } ` } ) ; }
183190 job . handle = handle ;
184191 let running : boolean | undefined ;
185- try { running = this . #store. markRunning ( job . identity , attempt . id ) ; } catch { running = undefined ; }
192+ try { running = this . #write ( ( ) => this . # store. markRunning ( job . identity , attempt . id ) ) ; } catch { running = undefined ; }
186193 if ( running === undefined ) {
187194 // A storage error, not a stop: keep ownership until D settles, then hold the slot under a marker.
188195 handle . cancel ( 'capture-failure' ) ;
@@ -219,7 +226,7 @@ export class RunnerCoordinator {
219226 }
220227 #settle( job : Job , s : { stopReason ?: StopReason ; exitCode : number | null ; signal : string | null ; valid : boolean ; result ?: unknown ; detail ?: string } ) : Classification | undefined {
221228 try {
222- return this . #store. settleAttempt ( job . identity , job . attemptId , { ...s , firstReason : job . firstReason } ) ;
229+ return this . #write ( ( ) => this . # store. settleAttempt ( job . identity , job . attemptId , { ...s , firstReason : job . firstReason } ) ) ;
223230 } catch {
224231 // The row's outcome is unknown: hold the slot until startup recovery reconciles it.
225232 this . #markers. set ( job . key , { group : job . group , attemptId : job . attemptId , reason : 'result-not-saved' } ) ;
0 commit comments