@@ -1446,9 +1446,10 @@ class OverseerImpl implements AgentHooks {
14461446 }
14471447 }
14481448
1449- // Maps chat ID to action numbers that were recently performed by that chat's agent. These are
1450- // added to the chat log after the tool call returns.
1451- #capturedActions = new Map < number , { actions : number [ ] , accessedGadget : boolean } > ( ) ;
1449+ // Maps chat ID to action numbers recently performed by that chat's agent. These are drained into
1450+ // the chat log after the tool returns. `awaitDecision` is true if any captured action needs it.
1451+ #capturedActions = new Map < number , { actions : number [ ] , accessedGadget : boolean ,
1452+ awaitDecision : boolean } > ( ) ;
14521453
14531454 // Maps chat ID to connectionRequest message bodies created by that chat's agent during the
14541455 // current step. Spliced into the chat log after the tool call returns (see
@@ -1458,7 +1459,7 @@ class OverseerImpl implements AgentHooks {
14581459 #getOrCreateCapturedActions( chatId : number ) {
14591460 let result = this . #capturedActions. get ( chatId ) ;
14601461 if ( ! result ) {
1461- result = { actions : [ ] , accessedGadget : false } ;
1462+ result = { actions : [ ] , accessedGadget : false , awaitDecision : false } ;
14621463 this . #capturedActions. set ( chatId , result ) ;
14631464 }
14641465 return result ;
@@ -1687,13 +1688,18 @@ class OverseerImpl implements AgentHooks {
16871688 this . storage . actions . put ( record ) ;
16881689 this . #associateAction( caller , actionId ) ;
16891690
1690- // If this action is auto-approvable and the user has opted in to auto-approving its action kind
1691- // on this gatekeeper, schedule a drain to apply it (and any earlier auto-eligible pending actions)
1692- // after submitAction returns. We defer via waitUntil rather than applying inline because
1693- // applying calls back into the gatekeeper facet, which is still awaiting this submitAction --
1694- // doing so inline risks a re-entrancy stall.
1695- if ( description . autoApprovable && description . actionKind &&
1696- this . storage . autoApproveTags . get ( `${ gatekeeperId } :${ description . actionKind . tag } ` ) !== undefined ) {
1691+ // Same auto-approval gate as before, named because awaitDecision uses it too. The drain is
1692+ // deferred because applying calls back into the gatekeeper facet still awaiting submitAction.
1693+ let willAutoApprove = ! ! ( description . autoApprovable && description . actionKind &&
1694+ this . storage . autoApproveTags . get ( `${ gatekeeperId } :${ description . actionKind . tag } ` ) !== undefined ) ;
1695+
1696+ // Only agent turns suspend on awaitDecision, and only when a manual decision is pending.
1697+ // Auto-approved actions keep the seamless behavior the user opted into.
1698+ if ( caller . from === "agent" && description . awaitDecision && ! willAutoApprove ) {
1699+ this . #getOrCreateCapturedActions( caller . chatId ) . awaitDecision = true ;
1700+ }
1701+
1702+ if ( willAutoApprove ) {
16971703 this . ctx . waitUntil ( this . drainAutoApprovals ( gatekeeperId ) ) ;
16981704 }
16991705 }
@@ -3054,7 +3060,8 @@ class OverseerImpl implements AgentHooks {
30543060 }
30553061 }
30563062
3057- consumeCapturedActions ( chatId : number ) : { actions : number [ ] , accessedGadget : boolean } | undefined {
3063+ consumeCapturedActions ( chatId : number )
3064+ : { actions : number [ ] , accessedGadget : boolean , awaitDecision : boolean } | undefined {
30583065 let result = this . #capturedActions. get ( chatId ) ;
30593066 this . #capturedActions. delete ( chatId ) ;
30603067 return result ;
@@ -4321,6 +4328,12 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
43214328 let profile = await this . #getClientProfile( ) ;
43224329 await this . impl . applyPendingAction ( action , profile , false ) ;
43234330
4331+ // If this was an awaited agent action, resume only after all awaited actions in the turn are
4332+ // approved. If applyPendingAction throws, the action stays pending and the turn stays suspended.
4333+ if ( action . caller . from === "agent" && action . description . awaitDecision ) {
4334+ await this . #maybeResumeAfterActionDecision( action . caller . chatId ) ;
4335+ }
4336+
43244337 // Clearing this manual gate may unblock later auto-eligible pending actions on the same
43254338 // gatekeeper, so cascade a drain (in-order) once this one is applied.
43264339 this . impl . ctx . waitUntil ( this . impl . drainAutoApprovals ( action . gatekeeperId ) ) ;
@@ -4402,6 +4415,47 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
44024415 }
44034416 }
44044417
4418+ // Resume a turn suspended on awaitDecision once all awaited actions from that turn are approved.
4419+ // Scoping to the current turn prevents older rejected actions from blocking future resumes.
4420+ async #maybeResumeAfterActionDecision( chatId : number ) : Promise < void > {
4421+ let awaited : ( ActionRecord & { type : "action" } ) [ ] = [ ] ;
4422+ for ( let msg of this . impl . storage . chats . list (
4423+ { prefix : `${ keyString ( chatId ) } .` , reverse : true } ) ) {
4424+ // Stop at whatever started the current turn: a user/gadget message or a gadget callback.
4425+ // (agentNudge is mid-turn, so it isn't a boundary.)
4426+ if ( msg . type === "agentCallback" ) break ;
4427+ if ( msg . type === "message" &&
4428+ ( msg . author . type === "user" || msg . author . type === "gadget" ) ) {
4429+ break ;
4430+ }
4431+ if ( msg . type === "action" ) {
4432+ let record = this . impl . storage . actions . get ( msg . actionId ) ;
4433+ if ( record && record . type === "action" &&
4434+ record . caller . from === "agent" && record . description . awaitDecision ) {
4435+ awaited . push ( record ) ;
4436+ }
4437+ }
4438+ }
4439+ awaited . reverse ( ) ; // Present titles chronologically.
4440+
4441+ // Only resume when every awaited action in the turn has been decided and all were approved.
4442+ if ( awaited . length === 0 ) return ; // No awaited action in current turn.
4443+ if ( awaited . some ( r => r . state === "pending" ) ) return ; // Still waiting on a decision.
4444+ if ( awaited . some ( r => r . state === "rejected" ) ) return ; // Denial leaves the turn ended.
4445+
4446+ // Persist one note for replay; raw action cards are not surfaced to the LLM. Concurrent
4447+ // approvals could both pass the gate above and append duplicate notes (the DO input gate is
4448+ // open across these awaits), but that's cosmetic — #resumeSuspendedAgent still starts one turn.
4449+ let titleList = awaited . map ( r => `"${ r . description . title } "` ) . join ( ", " ) ;
4450+ let summary =
4451+ `The changes you submitted have been approved and applied: ${ titleList } . ` +
4452+ `Reads now reflect them.` ;
4453+ let author = await this . #getClientProfile( ) ;
4454+ this . impl . addChatMessages ( chatId , author , [ { type : "message" , message : summary } ] ) ;
4455+
4456+ await this . #resumeSuspendedAgent( chatId ) ;
4457+ }
4458+
44054459 async rejectAction ( id : number ) : Promise < void > {
44064460 let action = this . impl . storage . actions . get ( id ) ;
44074461 if ( ! action ) {
@@ -4428,6 +4482,9 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
44284482 action . appliedAt = new Date ( ) ;
44294483 action . resolvedBy = profile ;
44304484 this . impl . storage . actions . put ( action ) ;
4485+
4486+ // Deny leaves the turn ended, like denyConnectionRequest. The rejected record also prevents a
4487+ // sibling approval from resuming this turn.
44314488 }
44324489
44334490 // Enable auto-approval of actions carrying `actionKind` on the gatekeeper identified by
@@ -4516,11 +4573,9 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
45164573 throw new Error ( `No such connection request: ${ requestId } ` ) ;
45174574 }
45184575
4519- // Resume the agent on the given chat so it can react to an accepted connection request (only
4520- // accept resumes; deny leaves the turn ended). Modeled on sendChatMessage: the accepted
4521- // connectionRequest message itself (read from history) supplies the outcome the agent sees, so we
4522- // don't append a separate user message here.
4523- async #resumeAgentAfterConnection( chatId : number ) : Promise < void > {
4576+ // Restart a suspended agent turn after its outcome is recorded in chat history (accepted
4577+ // connection, or all awaited actions approved). Denials intentionally don't call this.
4578+ async #resumeSuspendedAgent( chatId : number ) : Promise < void > {
45244579 let meta = this . impl . storage . chatMeta . get ( chatId ) ;
45254580 if ( ! meta ) return ; // Chat deleted.
45264581 if ( meta . activeAgent ) return ; // Already running; it'll pick up the change on its next read.
@@ -4567,7 +4622,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
45674622 msg . timestamp = this . impl . getChatTimestamp ( ) ;
45684623 this . impl . storage . chats . put ( msg ) ; // fires the subscriber update() → re-delivers the card
45694624
4570- await this . #resumeAgentAfterConnection ( msg . chatId ) ;
4625+ await this . #resumeSuspendedAgent ( msg . chatId ) ;
45714626 }
45724627
45734628 async denyConnectionRequest ( requestId : string ) : Promise < void > {
0 commit comments