feat(codex): import and resume sessions from runners (#1088)

* fix codex import resume flow

* fix hub restart session active state

* fix codex transcript workspace scoping

* Address Codex import review findings

* Fix Codex import machine selection

* Update Codex sessions error test

* Address Codex import review findings

* Preserve forked Codex session id on sync

* Make Codex duplicate cleanup source-aware

* Handle Codex archive failures

* Limit existing session flag to Codex

* Preserve Codex import machine binding

* fix: rebase runner Codex import onto current main

* fix: preserve runner-scoped Codex import behavior

---------

Co-authored-by: syy <815728149@qq.com>
This commit is contained in:
NightWatcher314
2026-07-19 14:14:42 +08:00
committed by GitHub
co-authored by syy
parent 651c83a1d9
commit 64834467e3
42 changed files with 2536 additions and 133 deletions
+50 -15
View File
@@ -20,6 +20,7 @@ import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerC
import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes';
import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard';
import { parseCodexSpecialCommand } from './codexSpecialCommands';
import { extractErrorInfo } from '@/utils/errorUtils';
import {
RemoteLauncherBase,
type RemoteLauncherDisplayContext,
@@ -80,6 +81,17 @@ const CODEX_SPAWN_AGENT_FULL_HISTORY_ARGUMENT_ERROR =
'Full-history forked agents inherit the parent agent type, model, and reasoning effort; ' +
'omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.';
function formatCodexResumeError(error: unknown): string {
const info = extractErrorInfo(error);
const message = info.message && info.message !== 'Unknown error' ? info.message : '';
const record = error && typeof error === 'object' ? error as Record<string, unknown> : null;
const name = error instanceof Error && error.name && error.name !== 'Error' ? error.name : '';
const cause = record?.cause instanceof Error ? record.cause.message : typeof record?.cause === 'string' ? record.cause : '';
const code = typeof record?.code === 'string' ? record.code : '';
const parts = [name, code, message, cause].filter((part) => part.trim().length > 0);
return parts.length > 0 ? Array.from(new Set(parts)).join(': ') : 'unknown resume error';
}
const SAME_THREAD_RETRYABLE_ERROR_PATTERNS = [
'selected model is at capacity',
'codex thread entered systemerror'
@@ -3513,10 +3525,19 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
while (!this.shouldExit) {
logActiveHandles('loop-top');
if (!pending && (recoveryInFlight || (turnInFlight && session.queue.size() === 0))) {
if (!pending && recoveryInFlight) {
await waitForTurnOrRecovery(this.abortController.signal);
if (this.abortController.signal.aborted && !this.shouldExit) {
logger.debug('[codex]: Internal wait aborted while turn/recovery was active; continuing');
logger.debug('[codex]: Internal wait aborted while recovery was active; continuing');
continue;
}
continue;
}
if (!pending && turnInFlight && session.queue.size() === 0) {
await waitForTurnOrRecovery(this.abortController.signal);
if (this.abortController.signal.aborted && !this.shouldExit) {
logger.debug('[codex]: Internal wait aborted while turn was active; continuing');
continue;
}
continue;
@@ -3579,20 +3600,34 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
if (resumeCandidate) {
try {
const resumeResponse = await appServerClient.resumeThread({
threadId: resumeCandidate,
...threadParams
}, {
signal: this.abortController.signal
});
const resumeRecord = asRecord(resumeResponse);
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
threadId = asString(resumeThread?.id) ?? resumeCandidate;
applyResolvedModel(resumeRecord?.model);
logger.debug(`[Codex] Resumed app-server thread ${threadId}`);
const shouldForkImportedSource = Boolean(
session.sourceSessionId
&& resumeCandidate === session.sourceSessionId
);
const response = shouldForkImportedSource
? await appServerClient.forkThread({
threadId: resumeCandidate,
...threadParams
}, {
signal: this.abortController.signal
})
: await appServerClient.resumeThread({
threadId: resumeCandidate,
...threadParams
}, {
signal: this.abortController.signal
});
const responseRecord = asRecord(response);
const responseThread = responseRecord ? asRecord(responseRecord.thread) : null;
threadId = asString(responseThread?.id) ?? resumeCandidate;
applyResolvedModel(responseRecord?.model);
logger.debug(shouldForkImportedSource
? `[Codex] Forked imported app-server thread ${resumeCandidate} -> ${threadId}`
: `[Codex] Resumed app-server thread ${threadId}`);
} catch (error) {
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}; preserving old conversation boundary`, error);
const failureMessage = `Task failed: Codex conversation ${resumeCandidate} could not be resumed; no new conversation was created`;
const resumeError = formatCodexResumeError(error);
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}; preserving old conversation boundary: ${resumeError}`, error);
const failureMessage = `Task failed: Codex conversation ${resumeCandidate} could not be resumed; no new conversation was created. Reason: ${resumeError}`;
messageBuffer.addMessage(failureMessage, 'status');
session.sendSessionEvent({ type: 'message', message: failureMessage });
pending = null;