@@ -233,34 +233,56 @@ export async function reconcileIdempotencyLeasesOnBoot(
233233) : Promise < Set < string > > {
234234 const now = Date . now ( ) ;
235235 const quarantined = new Set < string > ( ) ;
236- for ( const { file, record } of idempotencyStore . listAll ( ) ) {
236+ // Fail-closed: any lease we cannot PROVE converged (terminal not durable, or a
237+ // corrupt/unreadable lease we can't reason about) makes the whole reconcile
238+ // throw — the daemon must then abort this bot's startup rather than bind and
239+ // let a poller hang `running` or an orphan re-attach. We finish the sweep to
240+ // converge everything we can, but remember the first hard failure and rethrow.
241+ let hardFailure : Error | undefined ;
242+ const leases = idempotencyStore . listAll ( { throwOnCorrupt : true } ) ; // corrupt → throw (unprovable)
243+ for ( const { file, record } of leases ) {
244+ // Owner scoping (fail-closed) + skip current boot's own in-flight leases.
245+ if ( record . ownerLarkAppId !== ownerLarkAppId ) continue ;
246+ if ( record . ownerBootId === currentBootId ) continue ;
237247 try {
238- // Owner scoping (fail-closed) + skip current boot's own in-flight leases.
239- if ( record . ownerLarkAppId !== ownerLarkAppId ) continue ;
240- if ( record . ownerBootId === currentBootId ) continue ;
241248 const outcome = asyncTriggerStore . lookup ( record . sessionId , record . triggerId ) ?. result . status ;
242- if ( outcome === 'completed' || outcome === 'failed' ) continue ; // already converged
249+ if ( outcome === 'completed' ) continue ; // converged good; retry reuses + polls
250+ if ( outcome === 'failed' ) {
251+ // Already durable-failed, but a PREVIOUS boot may have crashed after
252+ // writing failed and before closing → always re-quarantine and re-attempt
253+ // close, so restore never re-attaches a session the caller already saw failed.
254+ quarantined . add ( record . sessionId ) ;
255+ if ( getSession ( record . sessionId ) ) await closeSession ( record . sessionId ) ;
256+ continue ;
257+ }
243258 if ( record . state === 'attempting' ) {
244- // Authoritative terminal FIRST (throws on failure → surfaced to caller,
245- // which fails boot readiness rather than silently leaving `running`) .
259+ // Write the authoritative terminal FIRST (throws on I/O failure), THEN
260+ // quarantine + close. Quarantine happens regardless of close success .
246261 asyncTriggerStore . recordFailedStrict ( record . sessionId , record . triggerId , now , ownerLarkAppId , 'dispatch_unknown' ) ;
247262 quarantined . add ( record . sessionId ) ;
248- if ( getSession ( record . sessionId ) ) {
249- try { await closeSession ( record . sessionId ) ; } catch ( e ) { logger . warn ( `[idempotency] reconcile close ${ record . sessionId } failed (terminal already durable): ${ ( e as Error ) . message } ` ) ; }
250- }
263+ if ( getSession ( record . sessionId ) ) await closeSession ( record . sessionId ) ;
251264 continue ;
252265 }
253- // reserved: provably never dispatched → drop the lease (by enumerated path,
254- // under its own lock) + close the empty session.
255- idempotencyStore . removeByPathLocked ( file ) ;
266+ // reserved: provably never dispatched → CAS-remove by path (only if the
267+ // on-disk record is still this exact reserved snapshot — never delete a
268+ // fence that advanced to attempting), + close the empty session.
269+ idempotencyStore . compareAndRemoveByPath ( file , record ) ;
256270 quarantined . add ( record . sessionId ) ;
257- if ( getSession ( record . sessionId ) ) {
258- try { await closeSession ( record . sessionId ) ; } catch ( e ) { logger . warn ( `[idempotency] reconcile close ${ record . sessionId } failed: ${ ( e as Error ) . message } ` ) ; }
259- }
271+ if ( getSession ( record . sessionId ) ) await closeSession ( record . sessionId ) ;
260272 } catch ( err ) {
261- logger . warn ( `[idempotency] boot reconcile skipped a lease: ${ ( err as Error ) . message } ` ) ;
273+ // This lease could not be converged (strict-failed write threw, CAS-remove
274+ // threw on EIO, or close threw). Do NOT skip-and-continue as "handled":
275+ // record it and keep the session quarantined so restore can't revive it,
276+ // then fail the whole reconcile after the sweep.
277+ quarantined . add ( record . sessionId ) ;
278+ const e = err as Error ;
279+ logger . error ( `[idempotency] reconcile could not converge lease for ${ record . sessionId } : ${ e . message } ` ) ;
280+ if ( ! hardFailure ) hardFailure = e ;
262281 }
263282 }
283+ if ( hardFailure ) {
284+ throw new Error ( `idempotency boot reconcile failed to converge at least one lease: ${ hardFailure . message } ` ) ;
285+ }
264286 return quarantined ;
265287}
266288
@@ -1027,11 +1049,33 @@ export async function triggerSessionTurn(
10271049 ? triggerId
10281050 : { turnId : triggerId , dispatchAttempt } ) ;
10291051 } catch ( err ) {
1052+ // The ONLY thing that lets us honestly report a terminal `failed` is a
1053+ // DURABLE failed record (that is what trigger-result reads). If the strict
1054+ // write itself fails (disk full/EIO), we must NOT claim `state:failed` —
1055+ // the caller could never observe it and would see `running` forever. In
1056+ // that double-failure case return a 5xx so the caller treats it as an
1057+ // unknown hard error (and the next boot's reconcile will converge the
1058+ // still-`attempting` lease). Only on a successful durable write do we
1059+ // return the terminal failed. (finding: double storage failure must be a
1060+ // fail-closed 5xx, not a phantom `failed`.)
1061+ let terminalDurable = false ;
10301062 if ( idempotencyKey ) {
1031- try { asyncTriggerStore . recordFailedStrict ( session . sessionId , triggerId , Date . now ( ) , larkAppId , 'dispatch_unknown' ) ; }
1032- catch ( e ) { logger . error ( `[idempotency] failed to record dispatch_unknown after dispatch throw: ${ ( e as Error ) . message } ` ) ; }
1063+ try {
1064+ asyncTriggerStore . recordFailedStrict ( session . sessionId , triggerId , Date . now ( ) , larkAppId , 'dispatch_unknown' ) ;
1065+ terminalDurable = true ;
1066+ } catch ( e ) {
1067+ logger . error ( `[idempotency] dispatch threw AND recordFailedStrict failed — lease stays attempting for next-boot reconcile: ${ ( e as Error ) . message } ` ) ;
1068+ }
1069+ }
1070+ try { await closeSession ( session . sessionId ) ; } catch { /* best-effort; terminal already durable if terminalDurable */ }
1071+ if ( idempotencyKey && ! terminalDurable ) {
1072+ return {
1073+ ok : false , errorCode : 'trigger_failed' ,
1074+ error : `dispatch failed and terminal outcome could not be persisted: ${ ( err as Error ) . message } ` ,
1075+ target : { kind : 'turn' , sessionId : session . sessionId , chatId } ,
1076+ idempotencyKey,
1077+ } ;
10331078 }
1034- try { await closeSession ( session . sessionId ) ; } catch { /* best-effort */ }
10351079 return {
10361080 ok : false , state : 'failed' , triggerId,
10371081 errorCode : 'no_output' , error : `dispatch failed with unknown outcome: ${ ( err as Error ) . message } ` ,
0 commit comments