fix: persist codexapp streaming state
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const ASSET_VERSION = '20260615-codexapp-steer-status';
|
const ASSET_VERSION = '20260615-codexapp-steer-status-session-menu';
|
||||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||||
const RENDER_DEBOUNCE = 100;
|
const RENDER_DEBOUNCE = 100;
|
||||||
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
||||||
@@ -2287,6 +2287,18 @@
|
|||||||
send({ type: 'set_session_pinned', sessionId: session.id, pinned: nextPinned });
|
send({ type: 'set_session_pinned', sessionId: session.id, pinned: nextPinned });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSessionActionMenuOpen(item, open) {
|
||||||
|
if (!item) return;
|
||||||
|
item.classList.toggle('menu-open', open);
|
||||||
|
item.querySelector('.session-item-btn.more')?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSessionActionMenus(exceptItem = null) {
|
||||||
|
document.querySelectorAll('.session-item.menu-open').forEach((item) => {
|
||||||
|
if (item !== exceptItem) setSessionActionMenuOpen(item, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createSessionListItem(session) {
|
function createSessionListItem(session) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
const isPinned = !!session.pinnedAt;
|
const isPinned = !!session.pinnedAt;
|
||||||
@@ -2305,7 +2317,7 @@
|
|||||||
<div class="session-item-actions">
|
<div class="session-item-actions">
|
||||||
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
|
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
|
||||||
<div class="session-item-more">
|
<div class="session-item-more">
|
||||||
<button class="session-item-btn more" type="button" title="更多操作" aria-label="更多操作" aria-haspopup="menu">⋯</button>
|
<button class="session-item-btn more" type="button" title="更多操作" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false">⋯</button>
|
||||||
<div class="session-item-menu" role="menu" aria-label="会话操作">
|
<div class="session-item-menu" role="menu" aria-label="会话操作">
|
||||||
<button class="session-item-menu-btn copy-id" type="button" role="menuitem">复制 ID</button>
|
<button class="session-item-menu-btn copy-id" type="button" role="menuitem">复制 ID</button>
|
||||||
<button class="session-item-menu-btn edit" type="button" role="menuitem">重命名</button>
|
<button class="session-item-menu-btn edit" type="button" role="menuitem">重命名</button>
|
||||||
@@ -2321,20 +2333,26 @@
|
|||||||
: null;
|
: null;
|
||||||
if (target?.classList.contains('more')) {
|
if (target?.classList.contains('more')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const nextOpen = !item.classList.contains('menu-open');
|
||||||
|
closeSessionActionMenus(item);
|
||||||
|
setSessionActionMenuOpen(item, nextOpen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target?.classList.contains('copy-id')) {
|
if (target?.classList.contains('copy-id')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
closeSessionActionMenus();
|
||||||
copyTextToClipboard(session.id, '会话 ID 已复制');
|
copyTextToClipboard(session.id, '会话 ID 已复制');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target?.classList.contains('pin')) {
|
if (target?.classList.contains('pin')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
closeSessionActionMenus();
|
||||||
toggleSessionPinned(session);
|
toggleSessionPinned(session);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target?.classList.contains('delete')) {
|
if (target?.classList.contains('delete')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
closeSessionActionMenus();
|
||||||
const doDelete = () => {
|
const doDelete = () => {
|
||||||
if (getLastSessionForAgent(currentAgent) === session.id) {
|
if (getLastSessionForAgent(currentAgent) === session.id) {
|
||||||
localStorage.removeItem(getAgentSessionStorageKey(currentAgent));
|
localStorage.removeItem(getAgentSessionStorageKey(currentAgent));
|
||||||
@@ -2355,6 +2373,7 @@
|
|||||||
}
|
}
|
||||||
if (target?.classList.contains('edit')) {
|
if (target?.classList.contains('edit')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
closeSessionActionMenus();
|
||||||
startEditSessionTitle(item, session);
|
startEditSessionTitle(item, session);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2362,6 +2381,7 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
closeSessionActionMenus();
|
||||||
openSession(session.id);
|
openSession(session.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5566,7 +5586,10 @@
|
|||||||
if (e.key === 'ArrowDown') { e.preventDefault(); navigateCmdMenu(1); return; }
|
if (e.key === 'ArrowDown') { e.preventDefault(); navigateCmdMenu(1); return; }
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); navigateCmdMenu(-1); return; }
|
if (e.key === 'ArrowUp') { e.preventDefault(); navigateCmdMenu(-1); return; }
|
||||||
if (e.key === 'Tab') { e.preventDefault(); selectCmdMenuItem(); return; }
|
if (e.key === 'Tab') { e.preventDefault(); selectCmdMenuItem(); return; }
|
||||||
if (e.key === 'Escape') { hideCmdMenu(); return; }
|
if (e.key === 'Escape') { hideCmdMenu(); closeSessionActionMenus(); return; }
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeSessionActionMenus();
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
if (isMobileInputMode()) {
|
if (isMobileInputMode()) {
|
||||||
@@ -5601,10 +5624,16 @@
|
|||||||
|
|
||||||
// Close cmd menu on outside click
|
// Close cmd menu on outside click
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!(e.target instanceof Element) || !e.target.closest('.session-item-more')) {
|
||||||
|
closeSessionActionMenus();
|
||||||
|
}
|
||||||
if (!cmdMenu.contains(e.target) && e.target !== msgInput) {
|
if (!cmdMenu.contains(e.target) && e.target !== msgInput) {
|
||||||
hideCmdMenu();
|
hideCmdMenu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeSessionActionMenus();
|
||||||
|
});
|
||||||
|
|
||||||
// --- Toast Notification ---
|
// --- Toast Notification ---
|
||||||
function showToast(text, sessionId) {
|
function showToast(text, sessionId) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
document.documentElement.dataset.dividerTime = dividerTime;
|
document.documentElement.dataset.dividerTime = dividerTime;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="style.css?v=20260615-codexapp-steer-status">
|
<link rel="stylesheet" href="style.css?v=20260615-codexapp-steer-status-session-menu">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -150,6 +150,6 @@
|
|||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
<script src="app.js?v=20260615-codexapp-steer-status"></script>
|
<script src="app.js?v=20260615-codexapp-steer-status-session-menu"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1204,6 +1204,9 @@ body.session-loading-active {
|
|||||||
.session-item:focus-within .session-item-actions {
|
.session-item:focus-within .session-item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
.session-item.menu-open .session-item-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.session-item-btn {
|
.session-item-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1242,7 +1245,7 @@ body.session-loading-active {
|
|||||||
.session-item-menu {
|
.session-item-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 4px);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: none;
|
display: none;
|
||||||
min-width: 104px;
|
min-width: 104px;
|
||||||
@@ -1252,8 +1255,7 @@ body.session-loading-active {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
box-shadow: 0 10px 24px rgba(61, 42, 26, 0.14);
|
box-shadow: 0 10px 24px rgba(61, 42, 26, 0.14);
|
||||||
}
|
}
|
||||||
.session-item-more:hover .session-item-menu,
|
.session-item.menu-open .session-item-menu {
|
||||||
.session-item-more:focus-within .session-item-menu {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,7 +468,41 @@ function startTurn(params) {
|
|||||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = /slow/i.test(text) ? 900 : 80;
|
const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;
|
||||||
|
if (/recover/i.test(text)) {
|
||||||
|
send({
|
||||||
|
method: 'item/agentMessage/delta',
|
||||||
|
params: {
|
||||||
|
threadId: thread.id,
|
||||||
|
turnId,
|
||||||
|
itemId: 'agent-msg',
|
||||||
|
delta: `partial before restart: ${text}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
method: 'item/started',
|
||||||
|
params: {
|
||||||
|
threadId: thread.id,
|
||||||
|
turnId,
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
item: {
|
||||||
|
id: 'recover-tool',
|
||||||
|
type: 'commandExecution',
|
||||||
|
command: '/bin/bash -lc echo recover',
|
||||||
|
status: 'inProgress',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
method: 'item/commandExecution/outputDelta',
|
||||||
|
params: {
|
||||||
|
threadId: thread.id,
|
||||||
|
turnId,
|
||||||
|
itemId: 'recover-tool',
|
||||||
|
delta: 'recover tool output\n',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);
|
thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);
|
||||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,31 @@ async function withServer(env, fn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startServer(env) {
|
||||||
|
const child = spawn('/usr/bin/node', [SERVER_PATH], {
|
||||||
|
cwd: REPO_DIR,
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||||
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||||
|
await waitForPort(env.PORT, 10000);
|
||||||
|
return {
|
||||||
|
child,
|
||||||
|
stdout: () => stdout,
|
||||||
|
stderr: () => stderr,
|
||||||
|
async stop(signal = 'SIGTERM') {
|
||||||
|
if (child.exitCode !== null || child.signalCode) return;
|
||||||
|
child.kill(signal);
|
||||||
|
await sleep(300);
|
||||||
|
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
|
||||||
|
await sleep(200);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function connectWs(port, password) {
|
function connectWs(port, password) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||||
@@ -1005,6 +1030,74 @@ async function main() {
|
|||||||
ws.close();
|
ws.close();
|
||||||
console.log('Regression checks passed.');
|
console.log('Regression checks passed.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recoveryPort = await getFreePort();
|
||||||
|
const recoveryEnv = {
|
||||||
|
PORT: String(recoveryPort),
|
||||||
|
CC_WEB_PASSWORD: password,
|
||||||
|
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
|
||||||
|
CC_WEB_CONFIG_DIR: configDir,
|
||||||
|
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||||||
|
CC_WEB_LOGS_DIR: logsDir,
|
||||||
|
HOME: homeDir,
|
||||||
|
CLAUDE_PATH: MOCK_CLAUDE,
|
||||||
|
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recoveryServer = await startServer(recoveryEnv);
|
||||||
|
let recoverySessionId = null;
|
||||||
|
let recoveryStatePath = null;
|
||||||
|
try {
|
||||||
|
const { ws, messages } = await connectWs(recoveryPort, password);
|
||||||
|
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||||
|
const recoverCwd = path.join(tempRoot, 'codexapp-recover-space');
|
||||||
|
mkdirp(recoverCwd);
|
||||||
|
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: recoverCwd, mode: 'yolo' }));
|
||||||
|
const recoverSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === recoverCwd);
|
||||||
|
recoverySessionId = recoverSession.sessionId;
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: 'slow recover codexapp prompt', sessionId: recoverySessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||||
|
recoveryStatePath = path.join(sessionsDir, `${recoverySessionId}-run`, 'codexapp-state.json');
|
||||||
|
await waitForFile(recoveryStatePath);
|
||||||
|
let recoverStateStarted = null;
|
||||||
|
const stateStartedAt = Date.now();
|
||||||
|
while (Date.now() - stateStartedAt < 5000) {
|
||||||
|
recoverStateStarted = JSON.parse(fs.readFileSync(recoveryStatePath, 'utf8'));
|
||||||
|
if (/partial before restart/.test(recoverStateStarted.fullText || '')
|
||||||
|
&& recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || ''))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
assert(/partial before restart/.test(recoverStateStarted?.fullText || ''), 'Codex App running state should persist partial text before completion');
|
||||||
|
assert(recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Codex App running state should persist partial tool output');
|
||||||
|
ws.close();
|
||||||
|
} finally {
|
||||||
|
await recoveryServer.stop('SIGKILL');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(recoverySessionId, 'Codex App recovery test did not create a session');
|
||||||
|
assert(recoveryStatePath && fs.existsSync(recoveryStatePath), 'Codex App recovery state should survive server crash');
|
||||||
|
|
||||||
|
const restartedRecoveryServer = await startServer(recoveryEnv);
|
||||||
|
try {
|
||||||
|
const { ws, messages } = await connectWs(recoveryPort, password);
|
||||||
|
const recoveredList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === recoverySessionId));
|
||||||
|
const recoveredMeta = recoveredList.sessions.find((session) => session.id === recoverySessionId);
|
||||||
|
assert(recoveredMeta && !recoveredMeta.isRunning, 'Recovered Codex App partial turn should not stay marked running');
|
||||||
|
assert(!fs.existsSync(recoveryStatePath), 'Recovered Codex App state file should be cleaned after startup recovery');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'load_session', sessionId: recoverySessionId }));
|
||||||
|
const recoveredSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === recoverySessionId);
|
||||||
|
const recoveredAssistantMessages = (recoveredSessionInfo.messages || []).filter((message) => message.role === 'assistant' && /partial before restart/.test(String(message.content || '')));
|
||||||
|
assert(recoveredAssistantMessages.length === 1, 'Recovered Codex App partial assistant output should be persisted exactly once');
|
||||||
|
assert(recoveredAssistantMessages[0].codexAppRecoveredPartial === true, 'Recovered Codex App assistant output should be marked partial');
|
||||||
|
assert(recoveredAssistantMessages[0].toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Recovered Codex App assistant output should keep tool calls');
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
} finally {
|
||||||
|
await restartedRecoveryServer.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
209
server.js
209
server.js
@@ -532,6 +532,8 @@ const pendingCrossConversationReplies = new Map();
|
|||||||
const pendingCodexAppUserInputs = new Map();
|
const pendingCodexAppUserInputs = new Map();
|
||||||
let codexAppClient = null;
|
let codexAppClient = null;
|
||||||
let codexAppClientSignature = '';
|
let codexAppClientSignature = '';
|
||||||
|
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
|
||||||
|
const CODEX_APP_STATE_FLUSH_DELAY_MS = 250;
|
||||||
|
|
||||||
// Track which session each ws is viewing: ws -> sessionId
|
// Track which session each ws is viewing: ws -> sessionId
|
||||||
const wsSessionMap = new Map();
|
const wsSessionMap = new Map();
|
||||||
@@ -2173,6 +2175,196 @@ function cleanRunDir(sessionId) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function codexAppStatePath(sessionId) {
|
||||||
|
return path.join(runDir(sessionId), CODEX_APP_STATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToPairs(value) {
|
||||||
|
if (value instanceof Map) {
|
||||||
|
return Array.from(value.entries()).map(([key, item]) => [String(key), String(item || '')]);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.entries(value).map(([key, item]) => [String(key), String(item || '')]);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexAppTurnKey(sessionId, state = {}) {
|
||||||
|
const threadId = state.threadId || '';
|
||||||
|
const turnId = state.turnId || '';
|
||||||
|
const startedAt = state.startedAt || '';
|
||||||
|
return `${sanitizeId(sessionId)}:${threadId}:${turnId}:${startedAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCodexAppEntry(sessionId, entry) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
agent: 'codexapp',
|
||||||
|
sessionId: sanitizeId(sessionId),
|
||||||
|
cwd: entry.cwd || null,
|
||||||
|
threadId: entry.threadId || null,
|
||||||
|
turnId: entry.turnId || null,
|
||||||
|
turnStatus: entry.turnStatus || 'running',
|
||||||
|
startedAt: entry.startedAt || new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
clientUserMessageId: entry.clientUserMessageId || null,
|
||||||
|
fullText: entry.fullText || '',
|
||||||
|
toolCalls: Array.isArray(entry.toolCalls) ? entry.toolCalls : [],
|
||||||
|
toolOutputDeltas: mapToPairs(entry.toolOutputDeltas),
|
||||||
|
agentMessageItems: mapToPairs(entry.agentMessageItems),
|
||||||
|
lastUsage: entry.lastUsage || null,
|
||||||
|
lastError: entry.lastError || null,
|
||||||
|
userAborted: !!entry.userAborted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexAppTurnState(sessionId, entry) {
|
||||||
|
if (!entry || entry.codexAppStateCleaned) return;
|
||||||
|
try {
|
||||||
|
const dir = runDir(sessionId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const statePath = codexAppStatePath(sessionId);
|
||||||
|
const tmpPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(tmpPath, JSON.stringify(serializeCodexAppEntry(sessionId, entry)));
|
||||||
|
fs.renameSync(tmpPath, statePath);
|
||||||
|
} catch (err) {
|
||||||
|
plog('WARN', 'codex_app_state_persist_failed', {
|
||||||
|
sessionId: String(sessionId || '').slice(0, 8),
|
||||||
|
error: err?.message || String(err || ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCodexAppTurnState(sessionId, entry, options = {}) {
|
||||||
|
if (!sessionId || !entry || entry.codexAppStateCleaned) return;
|
||||||
|
const immediate = !!options.immediate;
|
||||||
|
if (immediate && entry.codexAppStateTimer) {
|
||||||
|
clearTimeout(entry.codexAppStateTimer);
|
||||||
|
entry.codexAppStateTimer = null;
|
||||||
|
}
|
||||||
|
if (immediate) {
|
||||||
|
entry.codexAppStateDirty = false;
|
||||||
|
writeCodexAppTurnState(sessionId, entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.codexAppStateDirty = true;
|
||||||
|
if (entry.codexAppStateTimer) return;
|
||||||
|
entry.codexAppStateTimer = setTimeout(() => {
|
||||||
|
entry.codexAppStateTimer = null;
|
||||||
|
if (!entry.codexAppStateDirty) return;
|
||||||
|
entry.codexAppStateDirty = false;
|
||||||
|
writeCodexAppTurnState(sessionId, entry);
|
||||||
|
}, CODEX_APP_STATE_FLUSH_DELAY_MS);
|
||||||
|
if (typeof entry.codexAppStateTimer.unref === 'function') entry.codexAppStateTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupCodexAppTurnState(sessionId, entry) {
|
||||||
|
if (entry?.codexAppStateTimer) {
|
||||||
|
clearTimeout(entry.codexAppStateTimer);
|
||||||
|
entry.codexAppStateTimer = null;
|
||||||
|
}
|
||||||
|
if (entry) entry.codexAppStateCleaned = true;
|
||||||
|
cleanRunDir(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCodexAppTurnState(sessionId) {
|
||||||
|
try {
|
||||||
|
const statePath = codexAppStatePath(sessionId);
|
||||||
|
if (!fs.existsSync(statePath)) return null;
|
||||||
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
|
if (!state || state.agent !== 'codexapp') return null;
|
||||||
|
return state;
|
||||||
|
} catch (err) {
|
||||||
|
plog('WARN', 'codex_app_state_load_failed', {
|
||||||
|
sessionId: String(sessionId || '').slice(0, 8),
|
||||||
|
error: err?.message || String(err || ''),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCodexAppTurnMessage(session, turnKey) {
|
||||||
|
return Array.isArray(session?.messages)
|
||||||
|
&& session.messages.some((message) => message?.codexAppTurnKey === turnKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverCodexAppTurnState(sessionId) {
|
||||||
|
const state = loadCodexAppTurnState(sessionId);
|
||||||
|
if (!state) {
|
||||||
|
cleanRunDir(sessionId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = loadSession(sessionId);
|
||||||
|
if (!session || !isCodexAppSession(session)) {
|
||||||
|
cleanRunDir(sessionId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullText = String(state.fullText || '');
|
||||||
|
const toolCalls = Array.isArray(state.toolCalls) ? state.toolCalls : [];
|
||||||
|
const hasRecoverableContent = fullText.trim() || toolCalls.length > 0;
|
||||||
|
const turnKey = codexAppTurnKey(sessionId, state);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (state.threadId) {
|
||||||
|
setRuntimeSessionId(session, state.threadId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (state.lastUsage) {
|
||||||
|
session.totalUsage = state.lastUsage;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRecoverableContent && !hasCodexAppTurnMessage(session, turnKey)) {
|
||||||
|
const interrupted = state.turnStatus !== 'completed';
|
||||||
|
session.messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: fullText,
|
||||||
|
toolCalls,
|
||||||
|
timestamp: state.updatedAt || new Date().toISOString(),
|
||||||
|
codexAppTurnKey: turnKey,
|
||||||
|
codexAppThreadId: state.threadId || null,
|
||||||
|
codexAppTurnId: state.turnId || null,
|
||||||
|
codexAppRecoveredPartial: interrupted,
|
||||||
|
interrupted,
|
||||||
|
});
|
||||||
|
if (interrupted) {
|
||||||
|
session.messages.push({
|
||||||
|
role: 'system',
|
||||||
|
content: 'Codex App 服务重启前的未完成输出已恢复,原运行任务已中断。',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
codexAppTurnKey: `${turnKey}:notice`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
session.hasUnread = true;
|
||||||
|
changed = true;
|
||||||
|
plog('INFO', 'codex_app_state_recovered', {
|
||||||
|
sessionId: sessionId.slice(0, 8),
|
||||||
|
threadId: state.threadId || null,
|
||||||
|
turnId: state.turnId || null,
|
||||||
|
responseLen: fullText.length,
|
||||||
|
toolCallCount: toolCalls.length,
|
||||||
|
interrupted,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
plog('INFO', 'codex_app_state_recovery_skipped', {
|
||||||
|
sessionId: sessionId.slice(0, 8),
|
||||||
|
threadId: state.threadId || null,
|
||||||
|
turnId: state.turnId || null,
|
||||||
|
duplicate: hasCodexAppTurnMessage(session, turnKey),
|
||||||
|
hasRecoverableContent: !!hasRecoverableContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
session.updated = new Date().toISOString();
|
||||||
|
saveSession(session);
|
||||||
|
}
|
||||||
|
cleanRunDir(sessionId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function sendSessionList(ws) {
|
function sendSessionList(ws) {
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
|
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
|
||||||
@@ -2900,6 +3092,11 @@ function recoverProcesses() {
|
|||||||
const session = loadSession(sessionId);
|
const session = loadSession(sessionId);
|
||||||
const agent = getSessionAgent(session);
|
const agent = getSessionAgent(session);
|
||||||
|
|
||||||
|
if (fs.existsSync(codexAppStatePath(sessionId))) {
|
||||||
|
recoverCodexAppTurnState(sessionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(pidPath)) {
|
if (!fs.existsSync(pidPath)) {
|
||||||
try { fs.rmSync(dir, { recursive: true }); } catch {}
|
try { fs.rmSync(dir, { recursive: true }); } catch {}
|
||||||
continue;
|
continue;
|
||||||
@@ -4465,6 +4662,7 @@ function handleCodexAppNotification(notification) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = codexAppRuntime.processCodexAppNotification(routed.entry, notification, routed.sessionId);
|
const result = codexAppRuntime.processCodexAppNotification(routed.entry, notification, routed.sessionId);
|
||||||
|
persistCodexAppTurnState(routed.sessionId, routed.entry, { immediate: !!result?.done });
|
||||||
if (result?.done) {
|
if (result?.done) {
|
||||||
handleCodexAppTurnComplete(routed.sessionId);
|
handleCodexAppTurnComplete(routed.sessionId);
|
||||||
}
|
}
|
||||||
@@ -4936,6 +5134,7 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
activeCodexAppTurns.set(session.id, entry);
|
activeCodexAppTurns.set(session.id, entry);
|
||||||
|
persistCodexAppTurnState(session.id, entry, { immediate: true });
|
||||||
sendSessionList(ws);
|
sendSessionList(ws);
|
||||||
|
|
||||||
startCodexAppTurn(session.id, input).catch((err) => {
|
startCodexAppTurn(session.id, input).catch((err) => {
|
||||||
@@ -4970,9 +5169,11 @@ async function startCodexAppTurn(sessionId, input) {
|
|||||||
setRuntimeSessionId(session, threadId);
|
setRuntimeSessionId(session, threadId);
|
||||||
session.updated = new Date().toISOString();
|
session.updated = new Date().toISOString();
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
|
persistCodexAppTurnState(sessionId, entry, { immediate: true });
|
||||||
|
|
||||||
const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);
|
const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);
|
||||||
if (turn?.turn?.id) entry.turnId = turn.turn.id;
|
if (turn?.turn?.id) entry.turnId = turn.turn.id;
|
||||||
|
persistCodexAppTurnState(sessionId, entry, { immediate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCodexAppTurnComplete(sessionId, options = {}) {
|
function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||||
@@ -4987,12 +5188,17 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const session = loadSession(sessionId);
|
const session = loadSession(sessionId);
|
||||||
if (session && ((entry.fullText || '').trim() || (entry.toolCalls || []).length > 0)) {
|
const turnKey = codexAppTurnKey(sessionId, entry);
|
||||||
|
if (session && ((entry.fullText || '').trim() || (entry.toolCalls || []).length > 0) && !hasCodexAppTurnMessage(session, turnKey)) {
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: entry.fullText || '',
|
content: entry.fullText || '',
|
||||||
toolCalls: entry.toolCalls || [],
|
toolCalls: entry.toolCalls || [],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
codexAppTurnKey: turnKey,
|
||||||
|
codexAppThreadId: entry.threadId || null,
|
||||||
|
codexAppTurnId: entry.turnId || null,
|
||||||
|
interrupted: !!options.interrupted,
|
||||||
});
|
});
|
||||||
session.updated = new Date().toISOString();
|
session.updated = new Date().toISOString();
|
||||||
if (!entry.ws) session.hasUnread = true;
|
if (!entry.ws) session.hasUnread = true;
|
||||||
@@ -5000,6 +5206,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeCodexAppTurns.delete(sessionId);
|
activeCodexAppTurns.delete(sessionId);
|
||||||
|
cleanupCodexAppTurnState(sessionId, entry);
|
||||||
if (entry.crossConversationReplyRequestId) {
|
if (entry.crossConversationReplyRequestId) {
|
||||||
completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
|
completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user