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 {
@@ -53,10 +53,15 @@ export class RunnerCoordinator {
5353 #store: Store ; #deps: RunnerDeps ; #limits: SlotLimits ;
5454 #jobs = new Map < string , Job > ( ) ; #markers = new Map < string , Marker > ( ) ;
5555 #closing = false ;
56- constructor ( store : Store , deps : RunnerDeps , limits : SlotLimits = { writable : 1 , readOnly : 1 } ) {
56+ /** Settlement writes run with the shutdown capability, so they still land after the write gate closes. */
57+ #write: < T > ( fn : ( ) => T ) => T ;
58+ constructor ( store : Store , deps : RunnerDeps , limits : SlotLimits = { writable : 1 , readOnly : 1 } , capability ?: ShutdownCapability ) {
5759 if ( ! [ limits . writable , limits . readOnly ] . every ( n => Number . isSafeInteger ( n ) && n >= 1 ) ) throw new Error ( 'Slot limits must be positive integers.' ) ;
5860 this . #store = store ; this . #deps = deps ; this . #limits = limits ;
61+ this . #write = capability ? fn => capability . run ( fn ) : fn => fn ( ) ;
5962 }
63+ /** Shutdown step 1: reject admission synchronously, in the same turn as the server flag and the Store gate. */
64+ rejectAdmission ( ) : void { this . #closing = true ; }
6065 get closing ( ) : boolean { return this . #closing; }
6166 #now( ) : number { return this . #deps. now ?.( ) ?? Date . now ( ) ; }
6267 #used( group : Group ) : number {
@@ -70,7 +75,7 @@ export class RunnerCoordinator {
7075 * a refused transaction releases the reservation in the same turn.
7176 */
7277 start ( identity : PlanIdentity , request : StartRequest ) : AttemptRecord {
73- if ( this . #closing) throw new GuardRefusal ( 'The runner is shutting down.' ) ;
78+ if ( this . #closing) throw new ShuttingDownError ( ) ;
7479 const key = identityKey ( identity ) ;
7580 if ( this . #jobs. has ( key ) ) throw new GuardRefusal ( 'An attempt is already active for this task.' ) ;
7681 const marker = this . #markers. get ( key ) ;
@@ -85,7 +90,8 @@ export class RunnerCoordinator {
8590 catch ( error ) { this . #jobs. delete ( key ) ; throw error ; }
8691 job . attemptId = attempt . id ; job . attempt = attempt ;
8792 this . #arm( job , attempt ) ;
88- job . done = this . #run( job , attempt ) . catch ( error => this . #unexpected( job , error ) ) ;
93+ // Start after the caller's transaction commits: a rolled-back admission must not leave a job running.
94+ job . done = Promise . resolve ( ) . then ( ( ) => this . #run( job , attempt ) ) . catch ( error => this . #unexpected( job , error ) ) ;
8995 return attempt ;
9096 }
9197 /** Retry is a new attempt bound to the latest failed or cancelled one. */
@@ -131,7 +137,7 @@ export class RunnerCoordinator {
131137 if ( job . firstReason ) { job . handle ?. cancel ( D_REASON [ job . firstReason ] ) ; return false ; }
132138 job . firstReason = reason ;
133139 try {
134- if ( job . attemptId && ! this . #store. recordFirstReason ( job . identity , job . attemptId , reason ) ) {
140+ if ( job . attemptId && ! this . #write ( ( ) => this . # store. recordFirstReason ( job . identity , job . attemptId , reason ) ) ) {
135141 // Another writer (for example cancel task) recorded a reason first; adopt the durable one.
136142 const durable = this . #store. getAttempt ( job . identity , job . attemptId ) . firstReason ;
137143 if ( durable ) job . firstReason = durable ;
@@ -158,6 +164,7 @@ export class RunnerCoordinator {
158164 }
159165 async #run( job : Job , attempt : AttemptRecord ) : Promise < void > {
160166 try {
167+ if ( ! this . #store. getAttempts ( job . identity ) . some ( row => row . id === attempt . id ) ) return ; // admission was rolled back
161168 let prepared : PreparedAttempt ;
162169 try { prepared = await this . #deps. prepare ( attempt , job . controller . signal ) ; }
163170 catch ( error ) { return await this . #endBeforeLaunch( job , attempt , this . #preparationDetail( job , error ) ) ; }
@@ -177,7 +184,7 @@ export class RunnerCoordinator {
177184 } catch ( error ) { return await this . #endBeforeLaunch( job , attempt , { detail : `Launch failed: ${ message ( error ) } ` } ) ; }
178185 job . handle = handle ;
179186 let running : boolean | undefined ;
180- try { running = this . #store. markRunning ( job . identity , attempt . id ) ; } catch { running = undefined ; }
187+ try { running = this . #write ( ( ) => this . # store. markRunning ( job . identity , attempt . id ) ) ; } catch { running = undefined ; }
181188 if ( running === undefined ) {
182189 // A storage error, not a stop: keep ownership until D settles, then hold the slot under a marker.
183190 handle . cancel ( 'capture-failure' ) ;
@@ -214,7 +221,7 @@ export class RunnerCoordinator {
214221 }
215222 #settle( job : Job , s : { stopReason ?: StopReason ; exitCode : number | null ; signal : string | null ; valid : boolean ; result ?: unknown ; detail ?: string } ) : Classification | undefined {
216223 try {
217- return this . #store. settleAttempt ( job . identity , job . attemptId , { ...s , firstReason : job . firstReason } ) ;
224+ return this . #write ( ( ) => this . # store. settleAttempt ( job . identity , job . attemptId , { ...s , firstReason : job . firstReason } ) ) ;
218225 } catch {
219226 // The row's outcome is unknown: hold the slot until startup recovery reconciles it.
220227 this . #markers. set ( job . key , { group : job . group , attemptId : job . attemptId , reason : 'result-not-saved' } ) ;
0 commit comments