Files
open-design-crew[bot] 5a3f0c8923 fix(daemon): archive owned Codex threads after terminal completion (#8051)
* fix(daemon): archive owned Codex threads after terminal completion

* fix(daemon): archive owned Codex threads after interrupted close

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-14 03:52:01 +00:00

237 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chmodSync, mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import {
codexSessionIdFromRunEvents,
extractCodexLastTurnFirstCallUsage,
readCodexRolloutFirstCall,
} from '../src/codex-rollout-usage.js';
// Shapes mirror real codex 0.133.0 rollout JSONL (`~/.codex/sessions/**/rollout-*.jsonl`):
// each turn opens with a `task_started` event_msg, and every model call emits a
// `token_count` event_msg whose `info.last_token_usage` is that single call's
// usage (OpenAI-style: input_tokens INCLUDES the cached_input_tokens subset).
function taskStarted(): string {
return JSON.stringify({ type: 'event_msg', payload: { type: 'task_started' } });
}
function tokenCount(input: number | null, cached: number | null): string {
return JSON.stringify({
type: 'event_msg',
payload: {
type: 'token_count',
info:
input === null
? { last_token_usage: null }
: { last_token_usage: { input_tokens: input, cached_input_tokens: cached } },
},
});
}
describe('extractCodexLastTurnFirstCallUsage', () => {
it('reports the first call of the LAST turn, not the whole-session aggregate', () => {
// Real numbers from a 3-turn session: turn-1 is a cold open (18.5%), turn-2
// a warm resume (96.2%). The daemon just finished turn-3 (95%).
const rollout = [
taskStarted(),
tokenCount(13137, 2432), // turn-1 first call
tokenCount(13160, 2560), // within-turn-1 follow-up — must be ignored
taskStarted(),
tokenCount(13171, 12672), // turn-2 first call (the 96% spec number)
taskStarted(),
tokenCount(13342, 12672), // turn-3 first call — this is what we want
tokenCount(20000, 18000), // within-turn-3 follow-up — must be ignored
].join('\n');
const result = extractCodexLastTurnFirstCallUsage(rollout);
expect(result).toEqual({
first_call_input_tokens: 13342,
first_call_cache_read_input_tokens: 12672,
first_call_cache_hit_ratio: 12672 / 13342,
});
});
it('skips zero-input placeholder calls and uses the turn’s real first call', () => {
const rollout = [
taskStarted(),
tokenCount(0, 0), // interrupted/placeholder call — no usable ratio
tokenCount(40318, 15744), // the turn’s real opening call
].join('\n');
const result = extractCodexLastTurnFirstCallUsage(rollout);
expect(result?.first_call_input_tokens).toBe(40318);
expect(result?.first_call_cache_hit_ratio).toBeCloseTo(15744 / 40318);
});
it('treats a missing cached subset as a zero cache hit on a cold open', () => {
const rollout = [taskStarted(), tokenCount(15761, null)].join('\n');
const result = extractCodexLastTurnFirstCallUsage(rollout);
expect(result).toEqual({
first_call_input_tokens: 15761,
first_call_cache_read_input_tokens: 0,
first_call_cache_hit_ratio: 0,
});
});
it('returns null when the last turn produced no usable token_count yet', () => {
const rollout = [
taskStarted(),
tokenCount(13171, 12672),
taskStarted(), // a fresh turn opened but no call has landed
tokenCount(null, null),
].join('\n');
expect(extractCodexLastTurnFirstCallUsage(rollout)).toBeNull();
});
it('tolerates blank lines and non-JSON noise without throwing', () => {
const rollout = ['', 'not json', taskStarted(), ' ', tokenCount(13342, 12672)].join('\n');
const result = extractCodexLastTurnFirstCallUsage(rollout);
expect(result?.first_call_cache_hit_ratio).toBeCloseTo(12672 / 13342);
});
});
describe('codexSessionIdFromRunEvents', () => {
const statusEvent = (extra: Record<string, unknown>) => ({
event: 'agent',
data: { type: 'status', label: 'initializing', ...extra },
});
it('returns the thread id from the initializing status event', () => {
const events = [
statusEvent({ sessionId: '019eef4f-7409-7c82-bebe-30504eed3959' }),
{ event: 'agent', data: { type: 'status', label: 'thinking' } },
];
expect(codexSessionIdFromRunEvents(events)).toBe(
'019eef4f-7409-7c82-bebe-30504eed3959',
);
});
it('ignores status events without a session id and non-status events', () => {
const events = [
{ event: 'agent', data: { type: 'usage', usage: { input_tokens: 1 } } },
statusEvent({ label: 'thinking' }),
];
expect(codexSessionIdFromRunEvents(events)).toBeNull();
});
it('returns null for malformed input', () => {
expect(codexSessionIdFromRunEvents(null)).toBeNull();
expect(codexSessionIdFromRunEvents('nope')).toBeNull();
expect(codexSessionIdFromRunEvents([{ event: 'agent' }, 42])).toBeNull();
});
});
describe('readCodexRolloutFirstCall', () => {
// Plants a rollout under <home>/sessions/<y>/<m>/<d>/ exactly as codex names
// them, so the locate-and-read layer is covered (not just the pure extractor).
function plantRollout(sessionId: string, lines: string[]): string {
const home = mkdtempSync(path.join(tmpdir(), 'codex-home-'));
const dayDir = path.join(home, 'sessions', '2026', '06', '24');
mkdirSync(dayDir, { recursive: true });
writeFileSync(
path.join(dayDir, `rollout-2026-06-24T12-00-00-${sessionId}.jsonl`),
lines.join('\n'),
);
return home;
}
const rollout = (sid: string) => [
JSON.stringify({ type: 'event_msg', payload: { type: 'task_started' } }),
JSON.stringify({
type: 'event_msg',
payload: {
type: 'token_count',
info: { last_token_usage: { input_tokens: 13342, cached_input_tokens: 12672 } },
},
}),
];
it('locates the rollout by session id under CODEX_HOME and extracts first-call', async () => {
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
const home = plantRollout(sid, rollout(sid));
const result = await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid });
expect(result?.first_call_input_tokens).toBe(13342);
expect(result?.first_call_cache_hit_ratio).toBeCloseTo(12672 / 13342);
});
it('preserves first-call usage after an owned thread has been archived', async () => {
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
const home = plantRollout(sid, rollout(sid));
try {
const before = await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid });
const name = `rollout-2026-06-24T12-00-00-${sid}.jsonl`;
mkdirSync(path.join(home, 'archived_sessions'));
renameSync(path.join(home, 'sessions/2026/06/24', name), path.join(home, 'archived_sessions', name));
expect(await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid })).toEqual(before);
} finally { rmSync(home, { recursive: true, force: true }); }
});
it('retains indexed archived usage after the flat archive exceeds the scan limit', async () => {
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
const home = plantRollout(sid, rollout(sid));
try {
const archive = path.join(home, 'archived_sessions');
mkdirSync(archive);
const name = `rollout-2026-06-24T12-00-00-${sid}.jsonl`;
const archivedPath = path.join(archive, name);
renameSync(path.join(home, 'sessions/2026/06/24', name), archivedPath);
// Codex archives into one flat directory, while its native primary-key
// index resolves a known thread without scanning other conversations.
for (let i = 0; i < 2_049; i += 1) writeFileSync(path.join(archive, `unrelated-${i}`), '');
const db = new Database(path.join(home, 'state_5.sqlite'));
try {
db.exec('CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER NOT NULL)');
db.prepare('INSERT INTO threads VALUES (?, ?, 1)').run(sid, archivedPath);
} finally { db.close(); }
expect((await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid }))?.first_call_input_tokens).toBe(13342);
} finally { rmSync(home, { recursive: true, force: true }); }
});
it.each(['outside archive', 'other thread', 'symlink', 'writable file'])(
'does not trust an indexed rollout pointing to %s',
async (scenario) => {
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
const home = plantRollout(sid, rollout(sid));
try {
const archive = path.join(home, 'archived_sessions');
mkdirSync(archive);
const name = `rollout-2026-06-24T12-00-00-${sid}.jsonl`;
const activePath = path.join(home, 'sessions/2026/06/24', name);
const target = path.join(home, name);
renameSync(activePath, target);
let indexedPath = path.join(archive, name);
if (scenario === 'outside archive') indexedPath = target;
else if (scenario === 'other thread') {
indexedPath = path.join(archive, 'rollout-2026-06-24T12-00-00-20000000-0000-4000-8000-000000000002.jsonl');
renameSync(target, indexedPath);
} else if (scenario === 'symlink') symlinkSync(target, indexedPath);
else {
renameSync(target, indexedPath);
chmodSync(indexedPath, 0o666);
}
for (let i = 0; i < 2_049; i += 1) writeFileSync(path.join(archive, `unrelated-${i}`), '');
const db = new Database(path.join(home, 'state_5.sqlite'));
try {
db.exec('CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER NOT NULL)');
db.prepare('INSERT INTO threads VALUES (?, ?, 1)').run(sid, indexedPath);
} finally { db.close(); }
expect(await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid })).toBeNull();
} finally { rmSync(home, { recursive: true, force: true }); }
},
);
it('returns null (never throws) when the session id has no matching rollout', async () => {
const home = plantRollout('some-other-session', rollout('some-other-session'));
await expect(
readCodexRolloutFirstCall({ codexHome: home, sessionId: 'missing-session' }),
).resolves.toBeNull();
});
it('returns null when no session id is given', async () => {
await expect(
readCodexRolloutFirstCall({ codexHome: '/nonexistent', sessionId: null }),
).resolves.toBeNull();
});
});