From 7eb8daa8803a47d0e713b9b1194513bd69d19c50 Mon Sep 17 00:00:00 2001 From: cat0825 <1759138827@qq.com> Date: Mon, 24 Aug 2026 18:02:28 +0800 Subject: [PATCH] fix(core,storage): unify the Codex thread source gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreign-session scanner and the Codex Session adapter each owned a private set of eligible `source` tokens, so the same Codex thread could be visible through one surface and invisible through the other: - bare `exec` was accepted by the adapter but dropped by the scanner; - bare `atlas`/`chatgpt` and wrapped `{"custom":"cli"}` / `{"custom":"vscode"}` were accepted by the scanner but dropped by the adapter; - a NULL `source` column was admitted by the adapter but dropped by the scanner, hiding threads written by older Codex schemas. Make `CODEX_SUPPORTED_THREAD_SOURCES` in `@maka/core/foreign-session` the single authority (`cli`, `exec`, `vscode`, `atlas`, `chatgpt`) and delete the adapter's duplicate gate. `codexSourceToken` now also accepts an already-parsed object, which is the shape rollout `session_meta` payloads arrive in, and the new `isSupportedCodexThreadSource` states the absent-is-eligible rule once instead of at each call site. Internal subagent threads (`{"subagent":{…}}`) still resolve to no token and stay out of both surfaces. Closes #3693 --- .../src/__tests__/foreign-session.test.ts | Bin 20373 -> 22931 bytes packages/core/src/foreign-session.ts | 72 +++++++++++++----- .../__tests__/codex-session-adapter.test.ts | 58 ++++++++++++++ packages/storage/src/codex-session-adapter.ts | 22 +----- 4 files changed, 112 insertions(+), 40 deletions(-) diff --git a/packages/core/src/__tests__/foreign-session.test.ts b/packages/core/src/__tests__/foreign-session.test.ts index a7e58779e159d741543481a023bde83c1f8c9ce4..58399b93e3879eb6a1850dc908e1ff36fe819300 100644 GIT binary patch delta 1876 zcmaJ>&u<$=6qco?DkyCgNTeo2;mLKnUdf4Lno{Aav=T^MDv(GxRZ%_OefAC+&unLA z<64$62mXT*XZ{0(lyeXK0URoE;0PD~4&LmKq)CZ=@XqeM`R04?eee7Emzq1R{r1z+ zCN~FJnksEE{zAq0>M+sB;scfG2v2`py49GoD8kelV9;k80bgMRCf{$th}kINatJm- z$j<|dS!z*3^X~mmKf50WEog@D_;Z-P5A zn4``M*aI|%D_Ox;V1gTB4&1hg6H!cvyr59%MLwIHZI5t#QZQx)PPpOy99pYvnD{Fy ztcY1A)DV~?Kv@OW>2MX9^WM$lBPES3it*U3gd6Ug#&QkaQDfcN=96na3)k7+EEfMZ zx1akjPj-3Sf&hc?5rNF;pB~&cgYz1H{oS)?WqBb|A{$Z4RQtX`X+blhtBEs{kV#WD z&ed+LEon6d&jf1Hb?$w9U1mbS%>A>qx=dT;%`!OsA>3R3w!BuOQQ`#6dD-&rYQk>$ z7DRb{E;ZJdoERC)BUmk!I4J@LZ4;6xRmJGjg$cszAKZRlxZ)6LQ|mn z-(zH9pw)=_sg)2avyifhP%JKQS^01ZQD&?fbs=C@FcVO!$$oq7t?!vUmTD~h&eN}e zxUFnW!DM|lq*8WaTB_oON!eJ^`^>%GfQK6W^-lXIw!Q8t5Vy#lFsr(Q^uZ*iZ0r@V>Di}Ev%>)m=xEIvRTYo8PlZZd=%*|(O^U!FizcM!1V>(9lulV<1IDsRH76G^ z0o>o=&3lVDG&33FfJ=kXvj1XQD)er9*xva*r{ z^$y&h;rjMMtp(+XyPF+oU!5k6;ThZVM1AJZe6-+<-)~&KzyAs4I$*+}+k159^!0): string[] { * content. * ------------------------------------------------------------------ */ -/** Codex thread sources eligible for import (per issue #1057). */ -export const CODEX_SUPPORTED_THREAD_SOURCES = ['cli', 'vscode', 'atlas', 'chatgpt'] as const; +/** + * Codex thread sources eligible for import — the single authority for both the + * foreign-session scanner below and the Codex Session adapter in `@maka/storage`. + * #1057 defined the original list; `exec` covers headless `codex exec` runs, + * which #2502 listed as root Sessions from the adapter's first commit. The two + * gates drifted apart while each owned its own token set, so the same thread was + * visible through one surface and invisible through the other. + */ +export const CODEX_SUPPORTED_THREAD_SOURCES = [ + 'cli', + 'exec', + 'vscode', + 'atlas', + 'chatgpt', +] as const; /** * Timestamps below this (2020-01-01 UTC in ms) are treated as seconds and @@ -413,34 +426,51 @@ function normalizeEpochMs(value: unknown): number | undefined { } /** - * Codex persists `source` either as a bare token (`cli`, `vscode`) or as a - * JSON object string (`{"custom":"atlas"}`, `{"custom":"chatgpt"}`). Return - * the canonical token, or undefined when it isn't a supported source — a - * bare-string equality check would silently drop every atlas/chatgpt thread. + * Codex persists `source` as a bare token (`cli`, `exec`, `vscode`), as a JSON + * object string (`{"custom":"atlas"}`), or — in rollout `session_meta` payloads, + * which arrive already parsed — as the object itself. Return the canonical + * token, or undefined when it isn't a supported source: a bare-string equality + * check would silently drop every atlas/chatgpt thread, and a token set that + * only knows the wrapped form would drop the bare one. + * + * Unsupported shapes (notably `{"subagent":{…}}`) resolve to undefined, which is + * what keeps internal subagent threads out of both surfaces. */ export function codexSourceToken(value: unknown): string | undefined { - if (typeof value !== 'string' || value.length === 0) return undefined; - if ((CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(value)) return value; - if (value.startsWith('{')) { + if (typeof value === 'string') { + if (value.length === 0) return undefined; + if (isSupportedCodexSourceToken(value)) return value; + // Only an object string can carry a `custom` token; anything else is a + // plain unsupported source and must not reach JSON.parse. + if (!value.startsWith('{')) return undefined; try { - const parsed = JSON.parse(value) as unknown; - const custom = - typeof parsed === 'object' && parsed !== null - ? (parsed as Record).custom - : undefined; - if ( - typeof custom === 'string' && - (CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(custom) - ) { - return custom; - } + return codexSourceToken(JSON.parse(value) as unknown); } catch { return undefined; } } + if (typeof value === 'object' && value !== null) { + const custom = (value as Record).custom; + return typeof custom === 'string' && isSupportedCodexSourceToken(custom) ? custom : undefined; + } return undefined; } +/** + * Whether a recorded `source` makes a Codex thread eligible. An absent source is + * eligible: older Codex schemas have no `source` column and older rollout + * `session_meta` payloads omit the field, so dropping those would hide every + * legacy thread. A present-but-unsupported source is a hard drop. + */ +export function isSupportedCodexThreadSource(value: unknown): boolean { + if (value === undefined || value === null) return true; + return codexSourceToken(value) !== undefined; +} + +function isSupportedCodexSourceToken(value: string): boolean { + return (CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(value); +} + export interface CodexThreadRow { id?: unknown; rollout_path?: unknown; @@ -468,7 +498,7 @@ export function normalizeCodexThreadRow( if (row.archived === 1 || row.archived === true) return undefined; // A present-but-unsupported source is a hard drop; an absent source column // (older schema) is allowed through — the SELECT simply didn't project it. - if (row.source !== undefined && codexSourceToken(row.source) === undefined) return undefined; + if (!isSupportedCodexThreadSource(row.source)) return undefined; const updatedAtMs = normalizeEpochMs(row.updated_at_ms) ?? normalizeEpochMs(row.updated_at) ?? 0; const title = sanitizeForeignTitle(row.title) || sanitizeForeignTitle(row.first_user_message) || row.id; diff --git a/packages/storage/src/__tests__/codex-session-adapter.test.ts b/packages/storage/src/__tests__/codex-session-adapter.test.ts index e9d92a7951..7add9b40ca 100644 --- a/packages/storage/src/__tests__/codex-session-adapter.test.ts +++ b/packages/storage/src/__tests__/codex-session-adapter.test.ts @@ -129,6 +129,64 @@ describe('CodexSessionAdapter', () => { }); }); + test('lists every thread source the foreign-session scanner accepts (#3693)', async () => { + // The adapter owned its own token set, so bare `atlas`/`chatgpt` and a + // wrapped `{"custom":"cli"}` were dropped here while the scanner in + // `@maka/core/foreign-session` listed them. Both gates now share one + // authority, so the catalog and the scan agree on every shape. + await withCodexHome(async (codexHome) => { + const sources = ['cli', 'exec', 'vscode', 'atlas', 'chatgpt'] as const; + const rows: StateRow[] = []; + for (const [index, source] of sources.entries()) { + const bareId = `codex-bare-${source}`; + const wrappedId = `codex-wrapped-${source}`; + rows.push({ + id: bareId, + rolloutPath: await seedMinimalRollout(codexHome, bareId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `bare ${source}`, + createdAtMs: 1000 + index, + updatedAtMs: 3000 + index, + archived: false, + source, + }); + rows.push({ + id: wrappedId, + rolloutPath: await seedMinimalRollout(codexHome, wrappedId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `wrapped ${source}`, + createdAtMs: 1100 + index, + updatedAtMs: 3100 + index, + archived: false, + source: JSON.stringify({ custom: source }), + }); + } + const subagentId = 'codex-subagent-drop'; + rows.push({ + id: subagentId, + rolloutPath: await seedMinimalRollout(codexHome, subagentId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: 'internal child', + createdAtMs: 2000, + updatedAtMs: 4000, + archived: false, + source: '{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}', + }); + await seedStateDatabase(codexHome, rows); + + const listed = new Set( + (await new CodexSessionAdapter({ codexHome }).listSessions()).map((session) => session.id), + ); + for (const source of sources) { + assert.ok(listed.has(`codex-bare-${source}`), `bare ${source} was dropped`); + assert.ok(listed.has(`codex-wrapped-${source}`), `wrapped ${source} was dropped`); + } + // Internal subagent threads stay out of the catalog. + assert.equal(listed.has(subagentId), false); + assert.equal(listed.size, sources.length * 2); + }); + }); + test('a Windows path spelling reaches the matcher instead of being lost in SQL', async () => { // The SQL used to prefilter with `cwd IN ()`, and // SQLite compares those exactly — a row stored `C:\\Repo\\App` was diff --git a/packages/storage/src/codex-session-adapter.ts b/packages/storage/src/codex-session-adapter.ts index be3c48c4bc..4c3164db69 100644 --- a/packages/storage/src/codex-session-adapter.ts +++ b/packages/storage/src/codex-session-adapter.ts @@ -22,7 +22,7 @@ import { open, readdir, realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join, resolve, sep } from 'node:path'; import type { StoredMessage } from '@maka/core/session'; -import { sanitizeForeignTitle } from '@maka/core/foreign-session'; +import { isSupportedCodexThreadSource, sanitizeForeignTitle } from '@maka/core/foreign-session'; import { externalSessionMatchesQuery } from '@maka/core/external-session'; import type { ExternalMakaSession, @@ -38,7 +38,6 @@ const CODEX_ROLLOUT_HEAD_BYTES = 512 * 1024; const CODEX_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const CODEX_UNSAFE_PATH_CHARS = /[\u0000-\u001F\u007F\u0080-\u009F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/; -const CODEX_ROOT_SOURCE_TOKENS = new Set(['cli', 'exec', 'vscode']); export interface CodexSessionAdapterOptions { /** Codex's state root. Defaults to `$CODEX_HOME`, then `~/.codex`. */ @@ -156,7 +155,7 @@ export class CodexSessionAdapter implements ExternalSessionAdapter { private async entryFromRow(row: CodexThreadRow): Promise { if (!isSafeCodexSessionId(row.id)) return undefined; if (typeof row.rollout_path !== 'string' || row.rollout_path.length === 0) return undefined; - if (!isRootCodexSource(row.source)) return undefined; + if (!isSupportedCodexThreadSource(row.source)) return undefined; const rolloutPath = await this.resolveRolloutPath(row.rollout_path, row.id); if (!rolloutPath) return undefined; @@ -575,7 +574,7 @@ function catalogEntryFromRolloutHead( const payload = asRecord(record.payload); if (!payload) continue; if (record.type === 'session_meta') { - if (!isRootCodexSource(payload.source)) return undefined; + if (!isSupportedCodexThreadSource(payload.source)) return undefined; id = stringField(payload, 'session_id') ?? stringField(payload, 'id') ?? id; cwd = safeCodexCwd(payload.cwd) || cwd; createdAt = @@ -794,21 +793,6 @@ function firstNonEmptyTitle(...values: unknown[]): string | undefined { return undefined; } -function isRootCodexSource(value: unknown): boolean { - if (value === undefined || value === null) return true; - if (typeof value === 'string') { - if (CODEX_ROOT_SOURCE_TOKENS.has(value)) return true; - if (!value.startsWith('{')) return false; - try { - return isRootCodexSource(JSON.parse(value) as unknown); - } catch { - return false; - } - } - if (!isRecord(value)) return false; - return value.custom === 'atlas' || value.custom === 'chatgpt'; -} - function codexErrorAffectsTurnStatus(payload: JsonRecord): boolean { const info = payload.codex_error_info; if (info === 'thread_rollback_failed' || info === 'active_turn_not_steerable') return false;