Skip to content

Commit 03a565a

Browse files
committed
fix: recover codex-app threads after stale session errors
1 parent cf34674 commit 03a565a

3 files changed

Lines changed: 134 additions & 6 deletions

File tree

src/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ const agentRunner = createAgentRunner({
333333
resolveThreadId,
334334
resolveCwd: (chatId, topicId) =>
335335
getProjectOverride(projectOverrides, chatId, topicId) || defaultProjectDir,
336+
restartSessionBackedServer: async (options) => {
337+
if (options.agentId !== AGENT_CODEX_APP) {
338+
throw new Error(`Unsupported session-backed agent: ${options.agentId}`);
339+
}
340+
await codexAppServerClient.shutdown();
341+
},
336342
runSessionBackedChatTurn: async (options) => {
337343
if (options.agentId !== AGENT_CODEX_APP) {
338344
throw new Error(`Unsupported session-backed agent: ${options.agentId}`);

src/services/agent-runner.js

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function createAgentRunner(options) {
2121
resolveEffectiveAgentId,
2222
resolveThreadId,
2323
resolveCwd,
24+
restartSessionBackedServer,
2425
runSessionBackedChatTurn,
2526
runSessionBackedOneShot,
2627
setSessionBackedThreadTitle,
@@ -74,6 +75,19 @@ function createAgentRunner(options) {
7475
return message.includes('no rollout found for thread id ');
7576
}
7677

78+
function isRecoverableSessionBackedThreadError(err) {
79+
const message = String(err?.message || '').toLowerCase();
80+
return (
81+
isMissingSessionBackedThreadError(err)
82+
|| message.includes(' is archived.')
83+
|| message.includes('invalid cwd:')
84+
|| (
85+
message.includes('failed to load configuration')
86+
&& message.includes('operation not permitted')
87+
)
88+
);
89+
}
90+
7791
async function clearStaleThreadBinding(threads, threadKey) {
7892
if (!threadKey || !threads.has(threadKey)) return false;
7993
threads.delete(threadKey);
@@ -650,13 +664,24 @@ function createAgentRunner(options) {
650664
result = await runSessionTurn(threadId);
651665
} catch (err) {
652666
if (
653-
threadId
654-
&& isMissingSessionBackedThreadError(err)
655-
&& await clearStaleThreadBinding(threads, threadKey)
667+
isRecoverableSessionBackedThreadError(err)
668+
&& (
669+
!threadId
670+
|| await clearStaleThreadBinding(threads, threadKey)
671+
)
656672
) {
657-
console.warn(
658-
`Cleared stale session-backed thread binding for ${threadKey}; retrying with a new thread`
659-
);
673+
if (threadId) {
674+
console.warn(
675+
`Cleared stale session-backed thread binding for ${threadKey}; retrying with a new thread`
676+
);
677+
} else {
678+
console.warn(
679+
`Recovering session-backed app-server error for ${threadKey}; retrying with a new thread`
680+
);
681+
}
682+
if (typeof restartSessionBackedServer === 'function') {
683+
await restartSessionBackedServer({ agentId: agent.id });
684+
}
660685
result = await runSessionTurn(undefined);
661686
} else {
662687
throw err;

test/services/agent-runner.test.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,103 @@ test('runAgentForChat clears a stale codex-app thread binding and retries once',
448448
);
449449
});
450450

451+
test('runAgentForChat clears codex-app binding after configuration permission errors', async () => {
452+
const calls = [];
453+
const restarts = [];
454+
const { runner, threads, persistedThreadSnapshots } = buildRunner({
455+
getGlobalAgent: () => 'codex-app',
456+
resolveEffectiveAgentId: (_chatId, _topicId, overrideAgentId) =>
457+
overrideAgentId || 'codex-app',
458+
resolveThreadId: (_threads, chatId, topicId, agentId) => ({
459+
threadKey: `${chatId}:${topicId || 'root'}:${agentId}`,
460+
threadId: threads.get(`${chatId}:${topicId || 'root'}:${agentId}`),
461+
migrated: false,
462+
}),
463+
runSessionBackedChatTurn: async (options) => {
464+
calls.push(options);
465+
if (calls.length === 1) {
466+
throw new Error(
467+
'failed to load configuration: Operation not permitted (os error 1)'
468+
);
469+
}
470+
return {
471+
text: 'respuesta recuperada',
472+
threadId: 'fresh-config-thread',
473+
turnId: 'turn-fresh',
474+
};
475+
},
476+
restartSessionBackedServer: async (options) => {
477+
restarts.push(options);
478+
},
479+
});
480+
481+
threads.set('31:root:codex-app', 'permission-thread');
482+
const response = await runner.runAgentForChat(31, 'recupera esto', {
483+
agentId: 'codex-app',
484+
});
485+
486+
assert.equal(response, 'respuesta recuperada');
487+
assert.equal(calls.length, 2);
488+
assert.deepEqual(restarts, [{ agentId: 'codex-app' }]);
489+
assert.equal(calls[0].threadId, 'permission-thread');
490+
assert.equal(calls[1].threadId, undefined);
491+
assert.equal(threads.get('31:root:codex-app'), 'fresh-config-thread');
492+
assert.equal(persistedThreadSnapshots.length, 2);
493+
assert.equal(
494+
persistedThreadSnapshots[0].has('31:root:codex-app'),
495+
false
496+
);
497+
});
498+
499+
test('runAgentForChat clears archived codex-app thread bindings and retries once', async () => {
500+
const calls = [];
501+
const restarts = [];
502+
const { runner, threads, persistedThreadSnapshots } = buildRunner({
503+
getGlobalAgent: () => 'codex-app',
504+
resolveEffectiveAgentId: (_chatId, _topicId, overrideAgentId) =>
505+
overrideAgentId || 'codex-app',
506+
resolveThreadId: (_threads, chatId, topicId, agentId) => ({
507+
threadKey: `${chatId}:${topicId || 'root'}:${agentId}`,
508+
threadId: threads.get(`${chatId}:${topicId || 'root'}:${agentId}`),
509+
migrated: false,
510+
}),
511+
runSessionBackedChatTurn: async (options) => {
512+
calls.push(options);
513+
if (calls.length === 1) {
514+
throw new Error(
515+
'session archived-thread is archived. Run `codex unarchive archived-thread` to unarchive it first.'
516+
);
517+
}
518+
return {
519+
text: 'respuesta tras archivar',
520+
threadId: 'fresh-archived-thread',
521+
turnId: 'turn-fresh',
522+
};
523+
},
524+
restartSessionBackedServer: async (options) => {
525+
restarts.push(options);
526+
},
527+
});
528+
529+
threads.set('42:6484:codex-app', 'archived-thread');
530+
const response = await runner.runAgentForChat(42, 'reserva', {
531+
agentId: 'codex-app',
532+
topicId: 6484,
533+
});
534+
535+
assert.equal(response, 'respuesta tras archivar');
536+
assert.equal(calls.length, 2);
537+
assert.deepEqual(restarts, [{ agentId: 'codex-app' }]);
538+
assert.equal(calls[0].threadId, 'archived-thread');
539+
assert.equal(calls[1].threadId, undefined);
540+
assert.equal(threads.get('42:6484:codex-app'), 'fresh-archived-thread');
541+
assert.equal(persistedThreadSnapshots.length, 2);
542+
assert.equal(
543+
persistedThreadSnapshots[0].has('42:6484:codex-app'),
544+
false
545+
);
546+
});
547+
451548
test('runAgentForChat assigns a title when a new codex-app thread is created', async () => {
452549
const titleCalls = [];
453550
const { runner } = buildRunner({

0 commit comments

Comments
 (0)