@@ -13,6 +13,7 @@ import {
1313 replaceSessionEntry ,
1414} from "../config/sessions/session-accessor.js" ;
1515import { callGateway } from "../gateway/call.js" ;
16+ import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js" ;
1617import {
1718 getAgentEventLifecycleGeneration ,
1819 registerAgentRunContext ,
@@ -38,10 +39,10 @@ import {
3839 markRestartAbortedMainSessions ,
3940 markRestartAbortedMainSessionsFromLocks ,
4041 markStartupOrphanedMainSessionsForRecovery ,
41- recoverStartupOrphanedMainSessions ,
42- recoverRestartAbortedMainSessions ,
43- retryRestartAbortedMainSessionRecovery ,
44- scheduleRestartAbortedMainSessionRecovery ,
42+ recoverStartupOrphanedMainSessions as recoverStartupOrphanedMainSessionsBase ,
43+ recoverRestartAbortedMainSessions as recoverRestartAbortedMainSessionsBase ,
44+ retryRestartAbortedMainSessionRecovery as retryRestartAbortedMainSessionRecoveryBase ,
45+ scheduleRestartAbortedMainSessionRecovery as scheduleRestartAbortedMainSessionRecoveryBase ,
4546} from "./main-session-restart-recovery.js" ;
4647import type { SessionLockInspection } from "./session-write-lock.js" ;
4748
@@ -53,6 +54,32 @@ vi.mock("../gateway/call.js", () => ({
5354 callGateway : vi . fn ( async ( ) => ( { runId : "run-resumed" } ) ) ,
5455} ) ) ;
5556
57+ const mockRecoveryRuntime = {
58+ dispatchAgent : async < T > ( params : Record < string , unknown > , timeoutMs ?: number ) =>
59+ ( await callGateway ( { method : "agent" , params, timeoutMs } ) ) as T ,
60+ waitForAgent : async < T > ( params : Record < string , unknown > , timeoutMs ?: number ) =>
61+ ( await callGateway ( { method : "agent.wait" , params, timeoutMs } ) ) as T ,
62+ sendRecoveryNotice : async < T > ( params : Record < string , unknown > , timeoutMs ?: number ) =>
63+ ( await callGateway ( { method : "message.action" , params, timeoutMs } ) ) as T ,
64+ } ;
65+
66+ type RecoveryParams < T extends { gatewayRuntime : unknown } > = Omit < T , "gatewayRuntime" > &
67+ Partial < Pick < T , "gatewayRuntime" > > ;
68+
69+ const recoverRestartAbortedMainSessions = (
70+ params : RecoveryParams < Parameters < typeof recoverRestartAbortedMainSessionsBase > [ 0 ] > ,
71+ ) => recoverRestartAbortedMainSessionsBase ( { gatewayRuntime : mockRecoveryRuntime , ...params } ) ;
72+ const recoverStartupOrphanedMainSessions = (
73+ params : RecoveryParams < Parameters < typeof recoverStartupOrphanedMainSessionsBase > [ 0 ] > ,
74+ ) => recoverStartupOrphanedMainSessionsBase ( { gatewayRuntime : mockRecoveryRuntime , ...params } ) ;
75+ const retryRestartAbortedMainSessionRecovery = (
76+ params : RecoveryParams < Parameters < typeof retryRestartAbortedMainSessionRecoveryBase > [ 0 ] > ,
77+ ) => retryRestartAbortedMainSessionRecoveryBase ( { gatewayRuntime : mockRecoveryRuntime , ...params } ) ;
78+ const scheduleRestartAbortedMainSessionRecovery = (
79+ params : RecoveryParams < Parameters < typeof scheduleRestartAbortedMainSessionRecoveryBase > [ 0 ] > ,
80+ ) =>
81+ scheduleRestartAbortedMainSessionRecoveryBase ( { gatewayRuntime : mockRecoveryRuntime , ...params } ) ;
82+
5683vi . mock ( "../config/sessions/transcript.js" , async ( importOriginal ) => {
5784 const actual = await importOriginal < typeof import ( "../config/sessions/transcript.js" ) > ( ) ;
5885 transcriptMocks . appendAssistantMessageToSessionTranscript . mockImplementation (
@@ -1024,6 +1051,50 @@ describe("main-session-restart-recovery", () => {
10241051 expect ( settled ?. restartRecoveryDeliverySourceRunId ) . toBeUndefined ( ) ;
10251052 } ) ;
10261053
1054+ it ( "settles a reused recovery RPC after its dispatch wait times out" , async ( ) => {
1055+ const sessionsDir = await makeSessionsDir ( ) ;
1056+ const storePath = path . join ( sessionsDir , "sessions.json" ) ;
1057+ await writeStore ( sessionsDir , {
1058+ "agent:main:main" : {
1059+ sessionId : "main-session" ,
1060+ updatedAt : Date . now ( ) - 10_000 ,
1061+ status : "running" ,
1062+ abortedLastRun : true ,
1063+ restartRecoveryDeliveryRunId : "recovery-run" ,
1064+ restartRecoveryDeliverySourceRunId : "control-ui-run" ,
1065+ } ,
1066+ } ) ;
1067+ await writeTranscript ( sessionsDir , "main-session" , [
1068+ { role : "user" , content : "run the tool" } ,
1069+ { role : "assistant" , content : [ { type : "toolCall" , id : "call-1" , name : "exec" } ] } ,
1070+ { role : "toolResult" , content : "done" } ,
1071+ ] ) ;
1072+ vi . mocked ( callGateway )
1073+ . mockRejectedValueOnce ( new Error ( "gateway request timeout for agent" ) )
1074+ . mockResolvedValueOnce ( {
1075+ runId : "recovery-run" ,
1076+ status : "ok" ,
1077+ endedAt : Date . now ( ) ,
1078+ } ) ;
1079+
1080+ await expect ( recoverRestartAbortedMainSessions ( { stateDir : tmpDir } ) ) . resolves . toEqual ( {
1081+ recovered : 1 ,
1082+ failed : 0 ,
1083+ skipped : 0 ,
1084+ } ) ;
1085+
1086+ expect ( firstGatewayParams ( ) . idempotencyKey ) . toBe ( "recovery-run" ) ;
1087+ expect ( vi . mocked ( callGateway ) . mock . calls [ 1 ] ?. [ 0 ] ) . toMatchObject ( {
1088+ method : "agent.wait" ,
1089+ params : { runId : "recovery-run" , timeoutMs : 0 } ,
1090+ } ) ;
1091+ expect ( loadSessionEntry ( { sessionKey : "agent:main:main" , storePath } ) ) . toMatchObject ( {
1092+ abortedLastRun : false ,
1093+ restartRecoveryTerminalRunIds : [ "control-ui-run" ] ,
1094+ status : "done" ,
1095+ } ) ;
1096+ } ) ;
1097+
10271098 it ( "does not deliver restart recovery when session send policy denies sends" , async ( ) => {
10281099 const sessionsDir = await makeSessionsDir ( ) ;
10291100 await writeStore ( sessionsDir , {
@@ -1627,6 +1698,48 @@ describe("main-session-restart-recovery", () => {
16271698 } ) ;
16281699 } ) ;
16291700
1701+ it ( "dispatches an abandoned durable claim through its owning Gateway instance" , async ( ) => {
1702+ const sessionsDir = await makeSessionsDir ( ) ;
1703+ const storePath = path . join ( sessionsDir , "sessions.json" ) ;
1704+ await writeStore ( sessionsDir , {
1705+ "agent:main:main" : {
1706+ sessionId : "main-session" ,
1707+ updatedAt : Date . now ( ) - 10_000 ,
1708+ status : "running" ,
1709+ abortedLastRun : true ,
1710+ restartRecoveryDeliveryRunId : "recovery-main" ,
1711+ restartRecoveryDeliverySourceRunId : "source-main" ,
1712+ } ,
1713+ } ) ;
1714+ await writeTranscript ( sessionsDir , "main-session" , [
1715+ { role : "user" , content : "recover without a socket" } ,
1716+ ] ) ;
1717+ const dispatchAgent = vi . fn ( async ( ) => ( { runId : "recovery-main" , status : "accepted" } ) ) ;
1718+
1719+ const result = await retryRestartAbortedMainSessionRecovery ( {
1720+ expectedRecoveryRunId : "recovery-main" ,
1721+ expectedRecoverySourceRunId : "source-main" ,
1722+ expectedSessionId : "main-session" ,
1723+ sessionKey : "agent:main:main" ,
1724+ storePath,
1725+ gatewayRuntime : {
1726+ dispatchAgent : dispatchAgent as GatewayRecoveryRuntime [ "dispatchAgent" ] ,
1727+ waitForAgent : vi . fn ( ) ,
1728+ sendRecoveryNotice : vi . fn ( ) ,
1729+ } ,
1730+ } ) ;
1731+
1732+ expect ( result ) . toEqual ( { recovered : 1 , failed : 0 , skipped : 0 } ) ;
1733+ expect ( dispatchAgent ) . toHaveBeenCalledWith (
1734+ expect . objectContaining ( {
1735+ idempotencyKey : "recovery-main" ,
1736+ sessionKey : "agent:main:main" ,
1737+ } ) ,
1738+ 10_000 ,
1739+ ) ;
1740+ expect ( callGateway ) . not . toHaveBeenCalled ( ) ;
1741+ } ) ;
1742+
16301743 it ( "targets a legacy durable row through its canonical agent session key" , async ( ) => {
16311744 const sessionsDir = await makeSessionsDir ( ) ;
16321745 const storePath = path . join ( sessionsDir , "sessions.json" ) ;
@@ -2208,13 +2321,9 @@ describe("main-session-restart-recovery", () => {
22082321 | {
22092322 method ?: string ;
22102323 params ?: Record < string , unknown > ;
2211- clientName ?: string ;
2212- mode ?: string ;
22132324 }
22142325 | undefined ;
22152326 expect ( gatewayCall ?. method ) . toBe ( "message.action" ) ;
2216- expect ( gatewayCall ?. clientName ) . toBe ( "gateway-client" ) ;
2217- expect ( gatewayCall ?. mode ) . toBe ( "backend" ) ;
22182327 expect ( gatewayCall ?. params ) . toMatchObject ( {
22192328 channel : "discord" ,
22202329 action : "send" ,
0 commit comments