@@ -123,6 +123,9 @@ export function createApprovalResume(args: {
123123 // TUI: operator-visible notice when an overlay decision is dropped after
124124 // a generation bump.
125125 onDropped ?: ( text : string ) => void ;
126+ // TUI: interrupt/clear bump this to reject the parked call on the old
127+ // agent before close/rebuild. Cleared when handle returns.
128+ registerParkedCancel ?: ( cancel : ( ( ) => void ) | undefined ) => void ;
126129 gate : PermissionGate ;
127130} ) : ApprovalResume {
128131 const { getAgent, gate } = args ;
@@ -139,59 +142,83 @@ export function createApprovalResume(args: {
139142 handle : async ( result ) => {
140143 if ( result . type !== "suspended" ) return false ;
141144 const stillCurrent = args . captureGeneration ?.( ) ?? ( ( ) => true ) ;
145+ const parkedAgent = requireAgent ( ) ;
142146 const { correlationId, approvalSnapshot } = result ;
143147
144- const deliverDecision = async ( message : InboundMessage ) : Promise < void > => {
145- if ( ! stillCurrent ( ) ) return ;
146- if ( args . deliver !== undefined ) {
147- await args . deliver ( message , stillCurrent ) ;
148- return ;
149- }
150- requireAgent ( ) . deliver ( message ) ;
148+ let cancelled = false ;
149+ const cancelParked = ( ) : void => {
150+ if ( cancelled ) return ;
151+ cancelled = true ;
152+ parkedAgent . deliver ( decisionMessage ( correlationId , "rejected" , APPROVAL_DROPPED_NOTICE ) ) ;
151153 } ;
154+ args . registerParkedCancel ?.( cancelParked ) ;
152155
153- // Turn-count watermark for the settled guard below: a "approval timed
154- // out" tool result appended after this point means the reactor settled
155- // this very correlation before our decision lands.
156- const turnsAtSuspend = ( await requireAgent ( ) . history ( ) ) . length ;
157- if ( ! stillCurrent ( ) ) return true ;
158-
159- if ( approvalSnapshot === undefined ) {
160- // A suspension without a snapshot cannot be surfaced; fail closed by
161- // rejecting the parked call so the run does not hang on an invisible
162- // gate.
163- await deliverDecision (
164- decisionMessage ( correlationId , "rejected" , "approval surface unavailable" ) ,
165- ) ;
166- return true ;
167- }
156+ const dropParked = ( ) : void => {
157+ args . onDropped ?.( APPROVAL_DROPPED_NOTICE ) ;
158+ cancelParked ( ) ;
159+ } ;
168160
169- const request = requestFromApprovalSnapshot ( approvalSnapshot , correlationId ) ;
170- if ( request === null ) {
171- await deliverDecision (
172- decisionMessage ( correlationId , "rejected" , "approval surface unavailable" ) ,
173- ) ;
174- return true ;
175- }
161+ try {
162+ const deliverDecision = async ( message : InboundMessage ) : Promise < void > => {
163+ if ( ! stillCurrent ( ) ) return ;
164+ if ( args . deliver !== undefined ) {
165+ await args . deliver ( message , stillCurrent ) ;
166+ return ;
167+ }
168+ requireAgent ( ) . deliver ( message ) ;
169+ } ;
170+
171+ // Turn-count watermark for the settled guard below: a "approval timed
172+ // out" tool result appended after this point means the reactor settled
173+ // this very correlation before our decision lands.
174+ const turnsAtSuspend = ( await parkedAgent . history ( ) ) . length ;
175+ if ( ! stillCurrent ( ) ) {
176+ dropParked ( ) ;
177+ return true ;
178+ }
176179
177- const outcome = await gate . resolveSuspended ( request , stillCurrent ) ;
178- if ( ! stillCurrent ( ) ) {
179- args . onDropped ?.( APPROVAL_DROPPED_NOTICE ) ;
180- return true ;
181- }
182- if ( settledAfterSuspend ( await requireAgent ( ) . history ( ) , turnsAtSuspend ) ) {
183- // The reactor already answered the parked call (its approval timeout
184- // fired while the surface was still up). Delivering now would append
185- // the raw decision JSON as an uncorrelated user turn — drop and log.
186- logger . warn `late approval decision dropped correlation=${ correlationId } outcome=${ outcome ?. allow === true ? "approved" : "rejected" } ` ;
187- return true ;
188- }
189- if ( outcome === undefined || ! outcome . allow ) {
190- await deliverDecision ( decisionMessage ( correlationId , "rejected" , outcome ?. message ) ) ;
180+ if ( approvalSnapshot === undefined ) {
181+ // A suspension without a snapshot cannot be surfaced; fail closed by
182+ // rejecting the parked call so the run does not hang on an invisible
183+ // gate.
184+ args . registerParkedCancel ?.( undefined ) ;
185+ await deliverDecision (
186+ decisionMessage ( correlationId , "rejected" , "approval surface unavailable" ) ,
187+ ) ;
188+ return true ;
189+ }
190+
191+ const request = requestFromApprovalSnapshot ( approvalSnapshot , correlationId ) ;
192+ if ( request === null ) {
193+ args . registerParkedCancel ?.( undefined ) ;
194+ await deliverDecision (
195+ decisionMessage ( correlationId , "rejected" , "approval surface unavailable" ) ,
196+ ) ;
197+ return true ;
198+ }
199+
200+ const outcome = await gate . resolveSuspended ( request , stillCurrent ) ;
201+ if ( ! stillCurrent ( ) ) {
202+ dropParked ( ) ;
203+ return true ;
204+ }
205+ args . registerParkedCancel ?.( undefined ) ;
206+ if ( settledAfterSuspend ( await requireAgent ( ) . history ( ) , turnsAtSuspend ) ) {
207+ // The reactor already answered the parked call (its approval timeout
208+ // fired while the surface was still up). Delivering now would append
209+ // the raw decision JSON as an uncorrelated user turn — drop and log.
210+ logger . warn `late approval decision dropped correlation=${ correlationId } outcome=${ outcome ?. allow === true ? "approved" : "rejected" } ` ;
211+ return true ;
212+ }
213+ if ( outcome === undefined || ! outcome . allow ) {
214+ await deliverDecision ( decisionMessage ( correlationId , "rejected" , outcome ?. message ) ) ;
215+ return true ;
216+ }
217+ await deliverDecision ( decisionMessage ( correlationId , "approved" ) ) ;
191218 return true ;
219+ } finally {
220+ args . registerParkedCancel ?.( undefined ) ;
192221 }
193- await deliverDecision ( decisionMessage ( correlationId , "approved" ) ) ;
194- return true ;
195222 } ,
196223 } ;
197224}
0 commit comments