1- import { permissionsFromRequest } from "../../permission-plan.ts" ;
1+ import {
2+ effectivePermission ,
3+ permissionsFromRequest ,
4+ } from "../../permission-plan.ts" ;
25import {
36 resolvePromptText ,
47 type AgentRunRequest ,
@@ -27,10 +30,14 @@ import {
2730 type RelayExecutionGuard ,
2831} from "../../tools/relay.ts" ;
2932import {
33+ APPROVED_EXECUTION_RESULT_UNKNOWN ,
3034 createSandboxAgentOtel ,
3135 TOOL_NOT_EXECUTED_PAUSED ,
3236} from "../../tracing/otel.ts" ;
33- import { attachPermissionResponder } from "./acp-interactions.ts" ;
37+ import {
38+ attachPermissionResponder ,
39+ buildGateDescriptor ,
40+ } from "./acp-interactions.ts" ;
3441import {
3542 buildClientToolRelay ,
3643 relayWritesPausedAnswer ,
@@ -114,8 +121,9 @@ export async function runTurn(
114121 // caller's teardown (`runSandboxAgent`'s `finally`, or the keep-alive dispatch's evict-on-failure)
115122 // reclaims the sandbox exactly as any other error does. Disposed in the `finally` on every path.
116123 // A human pause retires the deadlines (`notePaused`): a HITL wait is legitimate, not a wedge.
124+ const resolvedRunLimits = ( deps . resolveRunLimits ?? resolveRunLimits ) ( logger ) ;
117125 const runLimits = ( deps . createRunLimits ?? createRunLimits ) (
118- ( deps . resolveRunLimits ?? resolveRunLimits ) ( logger ) ,
126+ resolvedRunLimits ,
119127 { log : logger } ,
120128 ) ;
121129 let runLimitTrip : ( ( ) => void ) | undefined ;
@@ -176,8 +184,8 @@ export async function runTurn(
176184 // emit its own approval card. The orphan settle is deferred to the post-drain sweep below
177185 // (which runs on every paused turn, after `waitForEventDrain` lets every pending gate mark
178186 // its call paused) plus the in-band re-sweep in `handleUpdate` for a sibling announced after
179- // the pause. Both exclude paused (gated) calls , so every carded gate stays open for its
180- // resume and only a genuine orphan (announced, never gated) settles.
187+ // the pause. Both exclude paused gates and allowed executions , so each keeps its own
188+ // terminal outcome while only a genuine orphan settles.
181189 // Park mode: at least one parkable permission gate (Claude ACP or Pi ACP) recorded into
182190 // `env.parkedApprovals` BEFORE firing this pause (the onUserApprovalGate hook runs as each
183191 // gate resolves). Keep the live session — the gated tools run on the resume — so skip ONLY
@@ -196,6 +204,43 @@ export async function runTurn(
196204 // place every pause path converges, so the one place to retire the run-limits deadlines for good.
197205 void pause . signal . then ( ( ) => runLimits . notePaused ( ) ) ;
198206
207+ const openToolCallIds = ( ) : string [ ] => run . openToolCallIds ?.( ) ?? [ ] ;
208+ const bufferedPausedCompletedFrames = new Map < string , unknown > ( ) ;
209+ const toolCallClosureWaiters = new Map < string , Set < ( ) => void > > ( ) ;
210+ const notifyToolCallClosed = ( toolCallId : string ) : void => {
211+ if ( openToolCallIds ( ) . includes ( toolCallId ) ) return ;
212+ const waiters = toolCallClosureWaiters . get ( toolCallId ) ;
213+ if ( ! waiters ) return ;
214+ toolCallClosureWaiters . delete ( toolCallId ) ;
215+ for ( const waiter of waiters ) waiter ( ) ;
216+ } ;
217+ const waitForToolCallClosure = (
218+ toolCallId : string ,
219+ timeoutMs : number ,
220+ ) : Promise < boolean > => {
221+ if ( ! openToolCallIds ( ) . includes ( toolCallId ) ) {
222+ return Promise . resolve ( true ) ;
223+ }
224+ return new Promise < boolean > ( ( resolve ) => {
225+ let timeout : NodeJS . Timeout | undefined ;
226+ let finished = false ;
227+ const finish = ( closed : boolean ) : void => {
228+ if ( finished ) return ;
229+ finished = true ;
230+ if ( timeout ) clearTimeout ( timeout ) ;
231+ const waiters = toolCallClosureWaiters . get ( toolCallId ) ;
232+ waiters ?. delete ( onClosed ) ;
233+ if ( waiters ?. size === 0 ) toolCallClosureWaiters . delete ( toolCallId ) ;
234+ resolve ( closed ) ;
235+ } ;
236+ const onClosed = ( ) : void => finish ( true ) ;
237+ const waiters = toolCallClosureWaiters . get ( toolCallId ) ?? new Set ( ) ;
238+ waiters . add ( onClosed ) ;
239+ toolCallClosureWaiters . set ( toolCallId , waiters ) ;
240+ timeout = setTimeout ( ( ) => finish ( false ) , timeoutMs ) ;
241+ } ) ;
242+ } ;
243+
199244 // Publish this turn's sink so the session-lifetime listeners route into it. handleUpdate
200245 // reproduces the old per-event routing (suppress paused frames, handleUpdate, pause re-sweep).
201246 const turn : CurrentTurn = {
@@ -235,12 +280,35 @@ export async function runTurn(
235280 ) {
236281 env . lastTurnToolCallIds . push ( frame . toolCallId ) ;
237282 }
283+ const toolCallId =
284+ typeof rawFrame . toolCallId === "string"
285+ ? rawFrame . toolCallId
286+ : undefined ;
287+ if (
288+ pause . active &&
289+ ( rawFrame . sessionUpdate === "tool_call" ||
290+ rawFrame . sessionUpdate === "tool_call_update" ) &&
291+ rawFrame . status === "completed" &&
292+ toolCallId &&
293+ ! pause . isPausedToolCall ( toolCallId ) &&
294+ ! pause . isAllowedExecution ( toolCallId )
295+ ) {
296+ bufferedPausedCompletedFrames . set ( toolCallId , update ) ;
297+ return ;
298+ }
238299 run . handleUpdate ( update ) ;
239- // A non-gated sibling announced AFTER the pause can never execute this turn; settle it
240- // immediately so the client never holds an orphaned part (idempotent re-sweep).
300+ if (
301+ toolCallId &&
302+ ( rawFrame . status === "completed" || rawFrame . status === "failed" )
303+ ) {
304+ notifyToolCallClosed ( toolCallId ) ;
305+ }
306+ // A sibling announced after the pause with neither a gate nor an allow cannot execute;
307+ // the idempotent re-sweep closes it so the client never holds an orphaned part.
241308 if ( pause . active ) {
242309 run . settleOpenToolCalls (
243- ( id ) => pause . isPausedToolCall ( id ) ,
310+ ( id ) =>
311+ pause . isPausedToolCall ( id ) || pause . isAllowedExecution ( id ) ,
244312 TOOL_NOT_EXECUTED_PAUSED ,
245313 ) ;
246314 }
@@ -307,6 +375,62 @@ export async function runTurn(
307375 // The SAME name->spec index the relay execute loop hands to the relay execution guard, so
308376 // the approval card and the guard cannot disagree about a tool's permission/readOnly.
309377 const specsByName = toolSpecsByName ( plan . toolSpecs ) ;
378+ const settleBufferedPausedCompletions = ( ) : void => {
379+ for ( const [ toolCallId , update ] of [
380+ ...bufferedPausedCompletedFrames . entries ( ) ,
381+ ] ) {
382+ bufferedPausedCompletedFrames . delete ( toolCallId ) ;
383+ if ( pause . isPausedToolCall ( toolCallId ) ) continue ;
384+ if ( pause . isAllowedExecution ( toolCallId ) ) {
385+ run . handleUpdate ( update ) ;
386+ notifyToolCallClosed ( toolCallId ) ;
387+ continue ;
388+ }
389+ const frame = update as {
390+ sessionUpdate ?: unknown ;
391+ name ?: unknown ;
392+ title ?: unknown ;
393+ kind ?: unknown ;
394+ rawInput ?: unknown ;
395+ input ?: unknown ;
396+ } ;
397+ const { gate } = buildGateDescriptor (
398+ {
399+ toolCallId,
400+ name : frame . name ,
401+ title : frame . title ,
402+ kind : frame . kind ,
403+ rawInput : frame . rawInput ,
404+ input : frame . input ,
405+ } ,
406+ run ,
407+ serverPermissions ,
408+ specsByName ,
409+ ) ;
410+ const permission = effectivePermission ( gate , permissionPlan ) ;
411+ if ( permission === "allow" ) {
412+ run . handleUpdate ( update ) ;
413+ notifyToolCallClosed ( toolCallId ) ;
414+ continue ;
415+ }
416+ // Execution of an ask-policy call requires an answered allow; both harness gate paths fail
417+ // closed. A completed frame during a pause for an unanswered ask-policy call is therefore
418+ // a cancellation-closure artifact, not evidence of execution.
419+ if (
420+ frame . sessionUpdate === "tool_call" &&
421+ ! openToolCallIds ( ) . includes ( toolCallId )
422+ ) {
423+ run . handleUpdate ( {
424+ ...( update as Record < string , unknown > ) ,
425+ status : undefined ,
426+ } ) ;
427+ }
428+ run . settleOpenToolCalls (
429+ ( id ) => id !== toolCallId ,
430+ TOOL_NOT_EXECUTED_PAUSED ,
431+ ) ;
432+ }
433+ } ;
310434 // Build the per-turn permission handler WITHOUT attaching to the live session: the
311435 // session-lifetime `onPermissionRequest` (in acquireEnvironment) routes into it via
312436 // `currentTurn`. A capturing shim reuses attachPermissionResponder unchanged; its
@@ -325,6 +449,7 @@ export async function runTurn(
325449 log : logger ,
326450 onPause : ( ) => pause . pause ( ) ,
327451 onPausedToolCall : ( id ) => pause . markPausedToolCall ( id ) ,
452+ onAllowedExecution : ( id ) => pause . markAllowedExecution ( id ) ,
328453 onNonParkablePause : ( ) => {
329454 env . nonParkablePauseCount += 1 ;
330455 } ,
@@ -470,6 +595,7 @@ export async function runTurn(
470595 // confirm resolves) passes the relay guard. Claude resumes grant too — harmlessly, no
471596 // guard consults it.
472597 if ( decision . reply === "once" ) {
598+ pause . markAllowedExecution ( decision . toolCallId ) ;
473599 executionGrants . grant ( decision . toolName , decision . args ) ;
474600 }
475601 // A live-resume deny closes the seeded call as a failed tool call; flag it so the egress
@@ -508,15 +634,40 @@ export async function runTurn(
508634 }
509635 const stopReason =
510636 raced === PAUSED || pause . active ? "paused" : ( raced as any ) ?. stopReason ;
511- // Pause notification is immediate, but terminalization must wait for managed cancellation
512- // and already-queued ACP updates. Re-sweep after the drain so a sibling announced during
513- // cancellation receives exactly one deterministic terminal result before `done`.
637+ // Terminalization drains queued gates, classifies pause-time completions, and gives allowed
638+ // executions their original per-call bound before the orphan sweep closes the turn.
514639 if ( stopReason === "paused" ) {
515640 await pause . waitForEventDrain ( ) ;
641+ settleBufferedPausedCompletions ( ) ;
642+ const openAllowedExecutions = openToolCallIds ( )
643+ . filter ( ( id ) => pause . isAllowedExecution ( id ) ) ;
644+ await Promise . all (
645+ openAllowedExecutions . map ( async ( toolCallId ) => {
646+ const closed = await waitForToolCallClosure (
647+ toolCallId ,
648+ resolvedRunLimits . toolCallMs ,
649+ ) ;
650+ if ( closed ) return ;
651+ run . settleOpenToolCalls (
652+ ( id ) => id !== toolCallId ,
653+ APPROVED_EXECUTION_RESULT_UNKNOWN ,
654+ ) ;
655+ } ) ,
656+ ) ;
657+ settleBufferedPausedCompletions ( ) ;
516658 run . settleOpenToolCalls (
517- ( id ) => pause . isPausedToolCall ( id ) ,
659+ ( id ) =>
660+ pause . isPausedToolCall ( id ) || pause . isAllowedExecution ( id ) ,
518661 TOOL_NOT_EXECUTED_PAUSED ,
519662 ) ;
663+ const unexpectedOpenToolCallIds = openToolCallIds ( )
664+ . filter ( ( id ) => ! pause . isPausedToolCall ( id ) ) ;
665+ if ( unexpectedOpenToolCallIds . length > 0 ) {
666+ logger (
667+ "[HITL] paused-turn transcript invariant left non-gated calls open: " +
668+ unexpectedOpenToolCallIds . join ( "," ) ,
669+ ) ;
670+ }
520671 }
521672 const result = raced === PAUSED ? undefined : raced ;
522673 // A parkable pause this turn: hand the still-pending prompt promise to EVERY parked record so a
0 commit comments