11import { logger , type IAdminForth } from "adminforth" ;
22import { randomUUID } from "crypto" ;
3- import { createSequenceDebugCollector } from "../llm/middleware/sequenceDebug.js" ;
43import { VegaLiteStreamBuffer } from "../domain/vegaLiteStreamBuffer.js" ;
54import { buildAgentTurnSystemPrompt } from "../domain/systemPrompt.js" ;
65import { getErrorMessage , isAbortError } from "../shared/errors.js" ;
@@ -13,15 +12,15 @@ import type {
1312 AgentTurnContext ,
1413 AgentTurnObservability ,
1514 BaseAgentTurnInput ,
15+ DebugSink ,
1616 HandleTurnInput ,
17+ PendingInterrupt ,
1718 RunAndPersistAgentResponseInput ,
1819 RunAndPersistAgentResponseResult ,
1920} from "../domain/turnTypes.js" ;
2021
2122type AgentMode = PluginOptions [ "modes" ] [ number ] ;
2223
23- type PendingInterrupt = { id : string ; count : number } ;
24-
2524type PreparedTurn = {
2625 prompt : string ;
2726 sessionId : string ;
@@ -41,28 +40,6 @@ function getApprovalDecision(input: BaseAgentTurnInput) {
4140 : undefined ;
4241}
4342
44- function getInterruptItems ( interrupt : unknown ) : unknown [ ] {
45- return Array . isArray ( interrupt ) ? interrupt : [ interrupt ] ;
46- }
47-
48- function getHitlInterrupts ( interrupt : unknown ) : PendingInterrupt [ ] {
49- return getInterruptItems ( interrupt ) . flatMap ( ( item ) => {
50- const value = item && typeof item === "object" && "value" in item
51- ? ( item as { value : unknown } ) . value
52- : item ;
53- const actionRequests = value && typeof value === "object"
54- ? ( value as { actionRequests ?: unknown } ) . actionRequests
55- : undefined ;
56- const interruptId = item && typeof item === "object"
57- ? ( item as { id ?: unknown } ) . id
58- : undefined ;
59-
60- return typeof interruptId === "string" && Array . isArray ( actionRequests )
61- ? [ { id : interruptId , count : actionRequests . length } ]
62- : [ ] ;
63- } ) ;
64- }
65-
6643function buildHitlDecision ( decision : "approve" | "reject" , prompt ?: string ) {
6744 if ( decision === "approve" ) {
6845 return { type : "approve" as const } ;
@@ -129,6 +106,10 @@ export type RunTurnUseCaseDeps = {
129106 modes : PluginOptions [ "modes" ] ;
130107 getAdminforth : ( ) => IAdminForth ;
131108 getAgentSystemPrompt : ( ) => Promise < string > ;
109+ /** True when a persistent checkpointer is configured (not the in-memory MemorySaver). */
110+ hasPersistentCheckpointer : boolean ;
111+ /** Factory for the per-turn debug sink; the concrete impl lives in the llm layer. */
112+ createDebugSink : ( ) => DebugSink ;
132113} ;
133114
134115/**
@@ -161,23 +142,32 @@ export class RunTurnUseCase {
161142 }
162143
163144 /**
164- * Resolve the pending HITL interrupts for a resume. Prefers the in-process cache
165- * (populated when the interrupt fired this run) and falls back to the persisted
166- * checkpoint via the LLM port when the cache is empty (restart / other instance ).
145+ * Resolve the pending HITL interrupts for a resume. With a persistent checkpointer the
146+ * checkpoint is authoritative (correct across instances and restarts); with the in-memory
147+ * MemorySaver the in-process cache is authoritative (single process, cannot be stale ).
167148 */
168149 private async resolvePendingInterrupts ( sessionId : string , mode : AgentMode ) : Promise < PendingInterrupt [ ] > {
169- const cached = this . pendingInterrupts . get ( sessionId ) ;
170- if ( cached && cached . length > 0 ) {
171- return cached ;
150+ if ( this . deps . hasPersistentCheckpointer ) {
151+ // The in-process cache can be stale after a cross-instance resume, so it is intentionally
152+ // NOT consulted here. A failure to read the checkpoint is surfaced as a real error — never
153+ // masked as "no pending approval".
154+ try {
155+ return await this . deps . llm . getPendingInterrupts ( {
156+ completionAdapter : mode . completionAdapter ,
157+ sessionId,
158+ } ) ;
159+ } catch ( error ) {
160+ logger . error (
161+ `Failed to read pending approval state from checkpoint for session "${ sessionId } ": ${ getErrorMessage ( error ) } ` ,
162+ ) ;
163+ throw error ;
164+ }
172165 }
173- const raw = await this . deps . llm
174- . getPendingInterrupts ( { completionAdapter : mode . completionAdapter , sessionId } )
175- . catch ( ( ) => [ ] as unknown [ ] ) ;
176- return getHitlInterrupts ( raw ) ;
166+ return this . pendingInterrupts . get ( sessionId ) ?? [ ] ;
177167 }
178168
179169 private async prepareTurn ( input : RunAndPersistAgentResponseInput ) : Promise < PreparedTurn > {
180- const sequenceDebugSink = createSequenceDebugCollector ( ) ;
170+ const sequenceDebugSink = this . deps . createDebugSink ( ) ;
181171 const mode = this . resolveMode ( input . modeName ) ;
182172 const approvalDecision = getApprovalDecision ( input ) ;
183173 const shouldResume = Boolean ( approvalDecision ) ;
@@ -249,11 +239,14 @@ export class RunTurnUseCase {
249239 ] ;
250240 }
251241
252- private async handleInterrupt ( prepared : PreparedTurn , interrupt : unknown ) {
253- const interrupts = getHitlInterrupts ( interrupt ) ;
242+ private async handleInterrupt (
243+ prepared : PreparedTurn ,
244+ interrupt : unknown ,
245+ descriptors : PendingInterrupt [ ] ,
246+ ) {
254247 const existing = this . pendingInterrupts . get ( prepared . sessionId ) ?? [ ] ;
255248 const merged = new Map ( existing . map ( ( item ) => [ item . id , item . count ] ) ) ;
256- for ( const item of interrupts ) {
249+ for ( const item of descriptors ) {
257250 merged . set ( item . id , item . count ) ;
258251 }
259252 this . pendingInterrupts . set (
@@ -299,7 +292,7 @@ export class RunTurnUseCase {
299292
300293 if ( chunk . kind === "interrupt" ) {
301294 interrupted = true ;
302- await this . handleInterrupt ( prepared , chunk . interrupt ) ;
295+ await this . handleInterrupt ( prepared , chunk . interrupt , chunk . descriptors ) ;
303296 continue ;
304297 }
305298
0 commit comments