@@ -17,6 +17,7 @@ import type {
1717 ContextStore ,
1818 InboundMessage ,
1919} from "@intx/types/runtime" ;
20+ import { isDeepStrictEqual } from "node:util" ;
2021import { type } from "arktype" ;
2122
2223import { getLogger } from "@intx/log" ;
@@ -54,6 +55,60 @@ export interface ApprovalResume {
5455 handle : ( result : SendResult ) => Promise < boolean > ;
5556}
5657
58+ export interface SuspendedApprovalRecovery {
59+ capture : (
60+ result : Extract < SendResult , { type : "suspended" } > ,
61+ stillCurrent : ( ) => boolean ,
62+ ) => void ;
63+ clear : ( ) => void ;
64+ tryResumeOnce : ( ) => Promise < boolean > ;
65+ }
66+
67+ export function createSuspendedApprovalRecovery ( args : {
68+ storage : ( ) => Pick < ContextStore , "load" > | undefined ;
69+ resume : ApprovalResume ;
70+ } ) : SuspendedApprovalRecovery {
71+ let candidate :
72+ | {
73+ readonly result : Extract < SendResult , { type : "suspended" } > ;
74+ readonly stillCurrent : ( ) => boolean ;
75+ attempted : boolean ;
76+ }
77+ | undefined ;
78+ return {
79+ capture : ( result , stillCurrent ) => {
80+ candidate = { result, stillCurrent, attempted : false } ;
81+ } ,
82+ clear : ( ) => {
83+ candidate = undefined ;
84+ } ,
85+ tryResumeOnce : async ( ) => {
86+ const current = candidate ;
87+ if (
88+ current === undefined ||
89+ current . attempted ||
90+ ! current . stillCurrent ( )
91+ ) {
92+ candidate = undefined ;
93+ return false ;
94+ }
95+ current . attempted = true ;
96+ const storage = args . storage ( ) ;
97+ if ( storage === undefined ) return false ;
98+ try {
99+ const verified = await resolveSuspendedApprovalFromStore (
100+ storage ,
101+ current . result ,
102+ ) ;
103+ if ( ! verified . ok || ! current . stillCurrent ( ) ) return false ;
104+ return await args . resume . handle ( current . result ) ;
105+ } catch {
106+ return false ;
107+ }
108+ } ,
109+ } ;
110+ }
111+
57112// Rebuild the operator-facing request from the persisted snapshot. The parked
58113// name is canonicalized first so resume matches live decide() (file scopes for
59114// write_file, not a generic tool grant for default.write_file). Scopes come
@@ -103,6 +158,62 @@ export async function resolveParkedCallIdFromStore(
103158 return matches . length === 1 ? matches [ 0 ] ?. suspendedCall ?. id : undefined ;
104159}
105160
161+ export type SuspendedApprovalResolution =
162+ | { readonly ok : true ; readonly parkedCallId : string }
163+ | {
164+ readonly ok : false ;
165+ readonly code :
166+ | "not-pending"
167+ | "ambiguous-pending"
168+ | "snapshot-invalid"
169+ | "snapshot-mismatch"
170+ | "settled" ;
171+ } ;
172+
173+ /**
174+ * Proves that the suspended result still identifies the one persisted call.
175+ * Failure codes are intentionally content-free so watchdog diagnostics cannot
176+ * disclose approval or tool payloads.
177+ */
178+ export async function resolveSuspendedApprovalFromStore (
179+ storage : Pick < ContextStore , "load" > ,
180+ result : Extract < SendResult , { type : "suspended" } > ,
181+ ) : Promise < SuspendedApprovalResolution > {
182+ const { pendingOperations } = await storage . load ( ) ;
183+ const matches = pendingOperations . filter (
184+ ( operation ) =>
185+ operation . kind === "approval" &&
186+ operation . correlationId === result . correlationId ,
187+ ) ;
188+ if ( matches . length === 0 ) return { ok : false , code : "not-pending" } ;
189+ if ( matches . length !== 1 ) return { ok : false , code : "ambiguous-pending" } ;
190+ const operation = matches [ 0 ] ;
191+ if ( operation === undefined ) return { ok : false , code : "not-pending" } ;
192+ if ( operation . timeoutAt !== undefined && operation . timeoutAt <= Date . now ( ) )
193+ return { ok : false , code : "settled" } ;
194+ const parked = operation . suspendedCall ;
195+ const persisted = operation . approvalSnapshot ;
196+ const captured = result . approvalSnapshot ;
197+ const parsedPersisted = persisted && ApprovalSnapshotShape ( persisted ) ;
198+ const parsedCaptured = ApprovalSnapshotShape ( captured ) ;
199+ if (
200+ parked === undefined ||
201+ parked . id . length === 0 ||
202+ ! parsedPersisted ||
203+ parsedPersisted instanceof type . errors ||
204+ parsedCaptured instanceof type . errors
205+ )
206+ return { ok : false , code : "snapshot-invalid" } ;
207+ if (
208+ parked . name !== parsedPersisted . name ||
209+ parked . name !== parsedCaptured . name ||
210+ ! isDeepStrictEqual ( parked . arguments , parsedPersisted . arguments ?? { } ) ||
211+ ! isDeepStrictEqual ( parked . arguments , parsedCaptured . arguments ?? { } )
212+ )
213+ return { ok : false , code : "snapshot-mismatch" } ;
214+ return { ok : true , parkedCallId : parked . id } ;
215+ }
216+
106217function timeoutResult (
107218 turns : Awaited < ReturnType < Agent [ "history" ] > > ,
108219 parkedCallId : string ,
0 commit comments