@@ -37,6 +37,7 @@ import {
3737 runSandboxAgent ,
3838 runTurn ,
3939 shouldPark ,
40+ type ParkedApproval ,
4041 type ResumeApprovalInput ,
4142 type RunTurnOptions ,
4243 type SessionEnvironment ,
@@ -678,15 +679,12 @@ export async function runWithKeepalive(
678679 // history fingerprint (an edited transcript must not continue wrongly), and a hard mount-expiry
679680 // bound — if the parked session's mount credentials are past expiry, its durable cwd can no
680681 // longer be written, so evict to cold.
681- // The turn may hold several parked gates (parallel gated tool calls). Build one resume
682- // decision per parked gate, keyed by tool-call id. The whole turn resumes live ONLY when the
683- // request answers EVERY parked gate — the frontend's all-settled rule guarantees a resume /run
684- // is sent only after the human answered all cards, so a partial set is a mismatch that
685- // degrades to cold (which re-raises and multiplexes whatever subset). `parked` (the first
686- // gate) is the representative for logging and the per-turn-uniform checks.
682+ // Split parallel parked gates by tool-call id. Any non-empty answer set resumes live; untouched
683+ // gates stay pending in the same process and are carried into the next approval park.
687684 const parked = existing . environment . parkedApproval ;
688685 const parkedList = [ ...existing . environment . parkedApprovals . values ( ) ] ;
689686 const resumeDecisions : ResumeApprovalInput [ ] = [ ] ;
687+ const carriedForward : ParkedApproval [ ] = [ ] ;
690688 let mismatch : string | undefined ;
691689 if ( parkedList . length === 0 ) {
692690 mismatch = "no-parked-gate" ;
@@ -703,9 +701,8 @@ export async function runWithKeepalive(
703701 }
704702 const decision = approvalDecisionForToolCall ( request , gate . toolCallId ) ;
705703 if ( ! decision ) {
706- // A gate with no matching reply: fresh user text, or the human answered only some cards.
707- mismatch = "no-matching-approval" ;
708- break ;
704+ carriedForward . push ( gate ) ;
705+ continue ;
709706 }
710707 resumeDecisions . push ( {
711708 permissionId : gate . permissionId ,
@@ -746,7 +743,8 @@ export async function runWithKeepalive(
746743 ) . length ;
747744 const rejectCount = resumeDecisions . length - approveCount ;
748745 klog (
749- `resume key=${ key } gates=${ resumeDecisions . length } ` +
746+ `resume key=${ key } gates=${ parkedList . length } answered=${ resumeDecisions . length } ` +
747+ `carried=${ carriedForward . length } ` +
750748 `approve=${ approveCount } reject=${ rejectCount } tool=${ parked ?. toolName ?? "?" } ` ,
751749 ) ;
752750 let result : AgentRunResult ;
@@ -761,7 +759,7 @@ export async function runWithKeepalive(
761759 signal ,
762760 {
763761 approvalParkMode : true ,
764- resume : { decisions : resumeDecisions } ,
762+ resume : { decisions : resumeDecisions , carriedForward } ,
765763 } ,
766764 ) ;
767765 } catch ( err ) {
@@ -839,8 +837,8 @@ const runAgent: RunAgent = (request, emit, signal, options) => {
839837} ;
840838
841839/**
842- * The durable interaction token of a parked approval gate this request answers in-band, if
843- * any. The turn-start cancel-stale sweep must spare it: the gate belongs to the PREVIOUS turn
840+ * The durable interaction tokens of parked approval gates this request answers in-band, if
841+ * any. The turn-start cancel-stale sweep must spare them: each gate belongs to the PREVIOUS turn
844842 * (so the sweep's own `turn_id` exemption misses it), an in-band answer never transitions the
845843 * row off `pending` (only the interactions-plane respond endpoint does), and the resume
846844 * resolves the token after consuming the decision. Swept first, the granted gate's record
@@ -855,18 +853,13 @@ function inBandAnswerTokens(request: AgentRunRequest): string[] | undefined {
855853 keepalivePools [ provider ] . awaitingApproval ( sessionId ) ?. environment
856854 . parkedApprovals ;
857855 if ( ! parked || parked . size === 0 ) return undefined ;
858- // A turn can park several gates. Spare tokens from the stale sweep ONLY when this request
859- // answers EVERY parked gate — that is exactly when the dispatch resumes live and resolves them.
860- // If any gate is unanswered the turn degrades to cold (the all-or-cold resume rule), and sparing
861- // an answered subset would strand those interaction rows as pending forever (no live resume ever
862- // resolves them). In that case spare nothing: the cold path re-raises and the sweep correctly
863- // cancels the old rows.
856+ // A partial resume resolves only the answered rows. Carried-forward rows stay pending and receive
857+ // a fresh approval park, so only matching answer tokens are exempt from stale cancellation.
864858 const tokens : string [ ] = [ ] ;
865859 for ( const gate of parked . values ( ) ) {
866- if ( approvalDecisionForToolCall ( request , gate . toolCallId ) = == undefined ) {
867- return undefined ;
860+ if ( approvalDecisionForToolCall ( request , gate . toolCallId ) ! == undefined ) {
861+ tokens . push ( gate . interactionToken ) ;
868862 }
869- tokens . push ( gate . interactionToken ) ;
870863 }
871864 return tokens . length > 0 ? tokens : undefined ;
872865}
0 commit comments