@@ -236,37 +236,53 @@ async function runSessionCloseTeardown(params: {
236236 // succeed. Only an ordinary (non-repair) session records `close` + writes its
237237 // session log here, and — per #1225 — a failed platform close is not recorded
238238 // as `Closed`.
239- let saveScriptError : AppError | undefined ;
240- if ( ! repairArmed ) {
241- const actionsBeforeClose = session . actions . length ;
242- if ( ! platformCloseError ) {
243- recordSessionAction ( sessionStore , session , req , 'close' , {
244- session : session . name ,
245- ...successText ( `Closed: ${ session . name } ` ) ,
246- } ) ;
247- }
248- if ( req . flags ?. saveScript ) {
249- session . recordSession = true ;
250- }
251- try {
252- sessionStore . writeSessionLog ( session , {
253- force : resolveEffectiveSaveScriptForce ( req , session ) ,
254- } ) ;
255- } catch ( error ) {
256- // #1391: never let a failed script publish leak the session/device claim
257- // (item 1) or leave an unrecorded `close` action for a later successful
258- // write to duplicate (item 2) — roll back exactly like the repair path's
259- // `commitRepairBeforeClose` does, then let teardown continue below.
260- session . actions . length = actionsBeforeClose ;
261- saveScriptError = toOrdinaryCloseSaveScriptFailure ( error ) ;
262- }
263- }
239+ const saveScriptError = repairArmed
240+ ? undefined
241+ : finalizeOrdinaryCloseScript ( { req, session, sessionStore, platformCloseError } ) ;
264242 await attemptCleanup ( 'materialized_paths' , ( ) =>
265243 cleanupRetainedMaterializedPathsForSession ( sessionName ) ,
266244 ) ;
267245 return { platformCloseError, saveScriptError } ;
268246}
269247
248+ /**
249+ * ADR 0012 decision 6 (BLOCKER 2): only an ordinary (non-repair) session
250+ * records its finalize `close` and writes its session log here — see the
251+ * call site's own comment for why a repair-armed session skips this entirely.
252+ *
253+ * #1391: the write can refuse to publish (no-clobber target-exists, or any
254+ * other fs `AppError`). Roll back the just-recorded `close` action on failure
255+ * — mirroring `commitRepairBeforeClose`'s own rollback — so neither the
256+ * session/device claim leaks (item 1) nor an unrecorded `close` survives for a
257+ * later successful write to duplicate (item 2); the caller still completes
258+ * teardown regardless of the outcome returned here.
259+ */
260+ function finalizeOrdinaryCloseScript ( params : {
261+ req : DaemonRequest ;
262+ session : SessionState ;
263+ sessionStore : SessionStore ;
264+ platformCloseError : unknown ;
265+ } ) : AppError | undefined {
266+ const { req, session, sessionStore, platformCloseError } = params ;
267+ const actionsBeforeClose = session . actions . length ;
268+ if ( ! platformCloseError ) {
269+ recordSessionAction ( sessionStore , session , req , 'close' , {
270+ session : session . name ,
271+ ...successText ( `Closed: ${ session . name } ` ) ,
272+ } ) ;
273+ }
274+ if ( req . flags ?. saveScript ) {
275+ session . recordSession = true ;
276+ }
277+ try {
278+ sessionStore . writeSessionLog ( session , { force : resolveEffectiveSaveScriptForce ( req , session ) } ) ;
279+ return undefined ;
280+ } catch ( error ) {
281+ session . actions . length = actionsBeforeClose ;
282+ return toOrdinaryCloseSaveScriptFailure ( error ) ;
283+ }
284+ }
285+
270286/**
271287 * #1391: normalizes an ordinary (non-repair) session's close-time script-write
272288 * failure. `SessionScriptWriter.write()` only ever throws a genuine `AppError`
@@ -521,10 +537,68 @@ export async function handleCloseCommand(params: {
521537 // identity it was recorded under, so the platform close runs (again).
522538 const repair = await prepareRepairClose ( { req, session, logPath, sessionStore } ) ;
523539 if ( 'response' in repair ) return repair . response ;
524- // Resource teardown is failure-isolated: a rejected step is collected instead of
525- // short-circuiting the rest, so every subsequent resource (and the runner stop)
526- // is still attempted. The provider lease is committed only after that teardown,
527- // and a failed provider release keeps the session retryable.
540+ const closed = await runCloseTeardownAndRelease ( {
541+ req,
542+ session,
543+ sessionName,
544+ logPath,
545+ sessionStore,
546+ leaseRegistry,
547+ leaseLifecycleProvider,
548+ repairArmed : repair . repairArmed ,
549+ } ) ;
550+ if ( closed . kind === 'response' ) return closed . response ;
551+ const shutdownResult = await maybeShutdownSessionTarget ( {
552+ device : session . device ,
553+ shutdownRequested : req . flags ?. shutdown ,
554+ } ) ;
555+ return buildCloseSuccessResponse ( {
556+ session,
557+ repair,
558+ requestedSaveScript : Boolean ( req . flags ?. saveScript ) ,
559+ shutdownResult,
560+ providerData : closed . providerData ,
561+ } ) ;
562+ }
563+
564+ type SessionCloseFinalization =
565+ | { kind : 'response' ; response : DaemonResponse }
566+ | { kind : 'closed' ; providerData ?: Record < string , unknown > } ;
567+
568+ /**
569+ * Everything between a settled repair decision and a success response: the
570+ * failure-isolated resource teardown, the provider lease release, and — only
571+ * once both have run — the device-claim clear and session delete. A rejected
572+ * cleanup step is collected instead of short-circuiting the rest, so every
573+ * subsequent resource (and the runner stop) is still attempted; the provider
574+ * lease is released only after that teardown, and a failed release keeps the
575+ * session retryable (returned as `{kind:'response'}`, mirroring the
576+ * repair-commit-failure path above it). The platform-close failure is thrown
577+ * as the primary error with its original code/details/hint intact; the
578+ * cleanup aggregate has already been emitted as a diagnostic by this point so
579+ * per-resource failures stay visible; a failed script save (#1391) is thrown
580+ * last since — unlike the two above it — the session has already ended and
581+ * the device is already released by the time it surfaces.
582+ */
583+ async function runCloseTeardownAndRelease ( params : {
584+ req : DaemonRequest ;
585+ session : SessionState ;
586+ sessionName : string ;
587+ logPath : string ;
588+ sessionStore : SessionStore ;
589+ leaseRegistry : LeaseRegistry ;
590+ leaseLifecycleProvider : LeaseLifecycleProvider | undefined ;
591+ repairArmed : boolean ;
592+ } ) : Promise < SessionCloseFinalization > {
593+ const {
594+ req,
595+ session,
596+ sessionName,
597+ logPath,
598+ sessionStore,
599+ leaseRegistry,
600+ leaseLifecycleProvider,
601+ } = params ;
528602 const cleanupFailures : SessionCleanupFailure [ ] = [ ] ;
529603 const { platformCloseError, saveScriptError } = await runSessionCloseTeardown ( {
530604 req,
@@ -533,17 +607,17 @@ export async function handleCloseCommand(params: {
533607 logPath,
534608 sessionStore,
535609 cleanupFailures,
536- repairArmed : repair . repairArmed ,
610+ repairArmed : params . repairArmed ,
537611 // The platform close for a repair-armed session already ran (and was
538612 // confirmed to succeed) above, before the commit — never dispatch it twice.
539- skipPlatformClose : repair . repairArmed ,
613+ skipPlatformClose : params . repairArmed ,
540614 } ) ;
541615 const leaseRelease = await releaseProviderLeaseForClose ( {
542616 session,
543617 leaseRegistry,
544618 leaseLifecycleProvider,
545619 } ) ;
546- if ( leaseRelease . response ) return leaseRelease . response ;
620+ if ( leaseRelease . response ) return { kind : 'response' , response : leaseRelease . response } ;
547621 const cleanupAggregate = reportSessionCleanupFailures ( {
548622 sessionName,
549623 phase : 'session_close_cleanup_failed' ,
@@ -556,25 +630,21 @@ export async function handleCloseCommand(params: {
556630 await clearAdvisoryDeviceClaim ( session . deviceClaim ) ;
557631 }
558632 sessionStore . delete ( sessionName ) ;
559- // The platform-close failure is the primary error: rethrow it with its original
560- // code/details/hint intact. The cleanup aggregate has already been emitted as a
561- // diagnostic above so per-resource failures stay visible.
562633 if ( platformCloseError ) throw platformCloseError ;
563634 if ( cleanupAggregate ) throw cleanupAggregate ;
564- // #1391: surfaced last — the session already ended and the device is already
565- // released above, unlike the two failures above it. Nothing left to retry in
566- // place; `toOrdinaryCloseSaveScriptFailure`'s hint reflects that.
567635 if ( saveScriptError ) throw saveScriptError ;
568- const shutdownResult = await maybeShutdownSessionTarget ( {
569- device : session . device ,
570- shutdownRequested : req . flags ?. shutdown ,
571- } ) ;
572- // ADR 0012 decision 6 (BLOCKER 2a): positively report the committed healed
573- // artifact path so the agent learns the repair published (and where) without
574- // an extra round-trip.
575- const savedScript = repair . healedScriptPath ? { savedScript : repair . healedScriptPath } : { } ;
576- const text = `Closed: ${ session . name } ` ;
577- if ( repair . aborted && req . flags ?. saveScript ) {
636+ return { kind : 'closed' , providerData : leaseRelease . providerData } ;
637+ }
638+
639+ function buildCloseSuccessResponse ( params : {
640+ session : SessionState ;
641+ repair : Extract < RepairClosePreparation , { repairArmed : boolean } > ;
642+ requestedSaveScript : boolean ;
643+ shutdownResult : DeviceTargetShutdownResult | undefined ;
644+ providerData : Record < string , unknown > | undefined ;
645+ } ) : DaemonResponse {
646+ const { session, repair, requestedSaveScript, shutdownResult, providerData } = params ;
647+ if ( repair . aborted && requestedSaveScript ) {
578648 return {
579649 ok : false ,
580650 error : {
@@ -585,29 +655,24 @@ export async function handleCloseCommand(params: {
585655 } ,
586656 } ;
587657 }
588-
658+ // ADR 0012 decision 6 (BLOCKER 2a): positively report the committed healed
659+ // artifact path so the agent learns the repair published (and where) without
660+ // an extra round-trip.
661+ const savedScript = repair . healedScriptPath ? { savedScript : repair . healedScriptPath } : { } ;
662+ const provider = providerData ? { provider : providerData } : { } ;
663+ const text = `Closed: ${ session . name } ` ;
589664 if ( shutdownResult ) {
590665 return {
591666 ok : true ,
592667 data : withSuccessText (
593- {
594- session : session . name ,
595- shutdown : shutdownResult ,
596- ...savedScript ,
597- ...( leaseRelease . providerData ? { provider : leaseRelease . providerData } : { } ) ,
598- } ,
668+ { session : session . name , shutdown : shutdownResult , ...savedScript , ...provider } ,
599669 text ,
600670 ) ,
601671 } ;
602672 }
603673 return {
604674 ok : true ,
605- data : {
606- session : session . name ,
607- ...successText ( text ) ,
608- ...savedScript ,
609- ...( leaseRelease . providerData ? { provider : leaseRelease . providerData } : { } ) ,
610- } ,
675+ data : { session : session . name , ...successText ( text ) , ...savedScript , ...provider } ,
611676 } ;
612677}
613678
0 commit comments