@@ -3,6 +3,7 @@ import os from "node:os";
33import path from "node:path" ;
44import { afterEach , describe , expect , it } from "vitest" ;
55import { AgentRunQueue , BackpressureError , CodexJobManager } from "../src/jobs.js" ;
6+ import { SessionStateStore , type DurableSessionState } from "../src/session-state.js" ;
67import { CodexSessionManager } from "../src/sessions.js" ;
78
89const fakeCodex = path . resolve ( "test/fixtures/fake-codex.mjs" ) ;
@@ -132,6 +133,24 @@ describe("app-server hardening", () => {
132133 manager . cancel ( session . id ) ;
133134 } ) ;
134135
136+ it ( "preserves timeout status when a timed-out turn completes as interrupted" , async ( ) => {
137+ const manager = new CodexSessionManager ( ) ;
138+ const projectDir = await tempDir ( "codex-subagents-app-timeout-interrupted-project-" ) ;
139+
140+ const { result, session } = await manager . start ( {
141+ prompt : "timeout interrupted probe DELAY_MS=500" ,
142+ projectDir,
143+ codexBin : fakeCodex ,
144+ timeoutMs : 30 ,
145+ terminateGraceMs : 100 ,
146+ } ) ;
147+
148+ expect ( result . ok ) . toBe ( false ) ;
149+ expect ( result . status ) . toBe ( "timeout" ) ;
150+ expect ( result . timeoutReason ) . toBe ( "timeout" ) ;
151+ manager . cancel ( session . id ) ;
152+ } ) ;
153+
135154 it ( "terminates the app-server child process when a session is cancelled" , async ( ) => {
136155 const manager = new CodexSessionManager ( ) ;
137156 const projectDir = await tempDir ( "codex-subagents-app-cancel-project-" ) ;
@@ -229,6 +248,53 @@ describe("app-server hardening", () => {
229248 manager . cancel ( session . id ) ;
230249 } ) ;
231250
251+ it ( "does not fall back to exec after turn/start may have been accepted" , async ( ) => {
252+ const manager = new CodexSessionManager ( ) ;
253+ const projectDir = await tempDir ( "codex-subagents-app-turn-timeout-project-" ) ;
254+ const recordDir = await tempDir ( "codex-subagents-app-turn-timeout-record-" ) ;
255+
256+ const { session, turn } = manager . startAsync ( {
257+ prompt : "TURN_START_NO_RESPONSE" ,
258+ projectDir,
259+ codexBin : fakeCodex ,
260+ spawnTimeoutMs : 50 ,
261+ env : {
262+ FAKE_CODEX_RECORD_DIR : recordDir ,
263+ } ,
264+ } ) ;
265+
266+ const waited = await manager . wait ( session . id , 2_000 , turn . id ) ;
267+ expect ( waited . completed ) . toBe ( true ) ;
268+ expect ( waited . turn ?. status ) . toBe ( "failed" ) ;
269+ expect ( waited . session ?. protocol ) . toBe ( "app-server" ) ;
270+
271+ const calls = await recordedCalls ( recordDir ) ;
272+ expect ( calls . some ( ( call ) => call . protocol === "app-server" && call . method === "turn/start" ) ) . toBe ( true ) ;
273+ expect ( calls . some ( ( call ) => call . protocol === "exec" ) ) . toBe ( false ) ;
274+ manager . cancel ( session . id ) ;
275+ } ) ;
276+
277+ it ( "merges durable session state instead of overwriting unknown sessions" , async ( ) => {
278+ const stateDir = await tempDir ( "codex-subagents-state-merge-" ) ;
279+ const store = new SessionStateStore ( path . join ( stateDir , "sessions.json" ) ) ;
280+ const now = new Date ( ) . toISOString ( ) ;
281+ const state = ( id : string ) : DurableSessionState => ( {
282+ id,
283+ status : "active" ,
284+ createdAt : now ,
285+ updatedAt : now ,
286+ codexThreadId : `thread-${ id } ` ,
287+ protocol : "app-server" ,
288+ turns : 1 ,
289+ baseOptions : { projectDir : stateDir } ,
290+ } ) ;
291+
292+ store . save ( [ state ( "external" ) ] , { replaceIds : [ "external" ] } ) ;
293+ store . save ( [ state ( "local" ) ] , { replaceIds : [ "local" ] } ) ;
294+
295+ expect ( store . load ( ) . map ( ( session ) => session . id ) . sort ( ) ) . toEqual ( [ "external" , "local" ] ) ;
296+ } ) ;
297+
232298 it ( "marks live steering unsupported when turn/steer fails" , async ( ) => {
233299 const manager = new CodexSessionManager ( ) ;
234300 const projectDir = await tempDir ( "codex-subagents-app-steer-fail-project-" ) ;
0 commit comments