From f2b17e5389122e02b1e9910d8ae522530bb65dc9 Mon Sep 17 00:00:00 2001 From: cheho Date: Mon, 28 Sep 2026 23:03:50 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=AE=A1=E6=9F=A5=E5=8F=AA=E8=AE=A4=E6=9C=AC=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=9A=84=20codex=20=E4=B8=BB=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=AE=A4=E9=94=99=E5=88=AB=E7=9A=84?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=EF=BC=9B=E5=8E=BB=E6=8E=89=202=20=E5=88=86?= =?UTF-8?q?=E9=92=9F=E4=B8=8A=E9=99=90=EF=BC=8C=E4=B8=80=E7=9B=B4=E7=AD=89?= =?UTF-8?q?=E5=88=B0=E5=AE=A1=E6=9F=A5=E7=BB=88=E7=AB=AF=E5=85=B3=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/cc/codexSessionWatcher.test.ts | 93 +++++++++ vscode/src/cc/codexSessionWatcher.ts | 219 +++++++++------------- vscode/src/panel/handlers/sessions.ts | 24 ++- 3 files changed, 197 insertions(+), 139 deletions(-) create mode 100644 vscode/src/cc/codexSessionWatcher.test.ts diff --git a/vscode/src/cc/codexSessionWatcher.test.ts b/vscode/src/cc/codexSessionWatcher.test.ts new file mode 100644 index 0000000..5389144 --- /dev/null +++ b/vscode/src/cc/codexSessionWatcher.test.ts @@ -0,0 +1,93 @@ +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { watchForCodexSession } from './codexSessionWatcher' + +const tempDirs: string[] = [] + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const MAIN_ID = '01a0e3bb-61e3-7c31-adf3-2d2a746a3282' +const SUB_ID = '01a0e3bc-2a65-7490-84a6-e963505d9bff' +const OTHER_ID = '01a0e3be-4665-7991-b89e-f6881609c36b' + +function metaLine(id: string, cwd: string, source: unknown): string { + return `${JSON.stringify({ timestamp: '2026-09-27T16:38:24.015Z', type: 'session_meta', payload: { id, cwd, source } })}\n` +} + +function rolloutPath(baseDir: string, id: string): string { + const day = path.join(baseDir, '2026', '09', '28') + mkdirSync(day, { recursive: true }) + return path.join(day, `rollout-2026-09-28T00-38-23-${id}.jsonl`) +} + +const tick = (ms = 50): Promise => new Promise(r => setTimeout(r, ms)) + +describe('watchForCodexSession', () => { + it('只认本目录的主会话,跳过别的目录和子助手会话', async () => { + const baseDir = tempDir('codex-sessions-') + const cwd = tempDir('worktree-') + const ac = new AbortController() + const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal }) + await tick() + + writeFileSync(rolloutPath(baseDir, OTHER_ID), metaLine(OTHER_ID, '/somewhere/else', 'cli')) + writeFileSync(rolloutPath(baseDir, SUB_ID), metaLine(SUB_ID, cwd, { subagent: { thread_spawn: { parent_thread_id: MAIN_ID } } })) + await tick() + writeFileSync(rolloutPath(baseDir, MAIN_ID), metaLine(MAIN_ID, cwd, 'cli')) + + await expect(found).resolves.toBe(MAIN_ID) + }) + + it('没有时间上限,文件晚出现也能认到', async () => { + const baseDir = tempDir('codex-sessions-') + const cwd = tempDir('worktree-') + const ac = new AbortController() + const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal }) + await tick(300) + + writeFileSync(rolloutPath(baseDir, MAIN_ID), metaLine(MAIN_ID, cwd, 'cli')) + + await expect(found).resolves.toBe(MAIN_ID) + }) + + it('第一行还没写完时等它写完再认', async () => { + const baseDir = tempDir('codex-sessions-') + const cwd = tempDir('worktree-') + const ac = new AbortController() + const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal }) + await tick() + + const file = rolloutPath(baseDir, MAIN_ID) + const line = metaLine(MAIN_ID, cwd, 'cli') + writeFileSync(file, line.slice(0, 20)) + await tick() + appendFileSync(file, line.slice(20)) + + await expect(found).resolves.toBe(MAIN_ID) + }) + + it('中止后返回 null', async () => { + const baseDir = tempDir('codex-sessions-') + const cwd = tempDir('worktree-') + const ac = new AbortController() + const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal }) + await tick() + ac.abort() + + await expect(found).resolves.toBeNull() + }) +}) diff --git a/vscode/src/cc/codexSessionWatcher.ts b/vscode/src/cc/codexSessionWatcher.ts index 635e8d1..5a2ea62 100644 --- a/vscode/src/cc/codexSessionWatcher.ts +++ b/vscode/src/cc/codexSessionWatcher.ts @@ -1,24 +1,16 @@ /** - * Watch the codex sessions root directory for the next new `rollout-*.jsonl` - * file to appear, returning its thread_id. + * 等 codex 为指定目录新开的主会话落盘,拿到它的会话 id。 * - * Used by the auto-review flow: when we `sendText('codex exec review ...')` - * into a terminal tab, codex writes its session transcript to - * `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`. We can't sniff - * the terminal's stdout, so instead we watch this filesystem root for new - * files and extract the thread_id from the filename / first line. + * 审查终端里跑的是 codex 界面,读不到它的输出,只能盯 + * `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`。 + * codex 在第一轮对话开始时才建这个文件,启动慢(外部工具服务起得慢等) + * 可能拖好几分钟,所以不设时间上限,由调用方在终端关掉时中止。 * - * File layout (probed 2026-05-20): - * - * - Directory: ~/.codex/sessions/YYYY/MM/DD/ - * - Filename: rollout-2026-04-27T02-57-26-019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a.jsonl - * └────────────── thread_id (UUID v7) ──────────────┘ - * - First line: {"timestamp":"...","type":"session_meta","payload":{"id":"019dcb27-...","cwd":"..."}} - * - * Strategy: prefer UUID extracted from the filename (no I/O); fall back to - * parsing the first JSON line if the regex doesn't match. + * 同一时间可能有别的 codex 也在建文件(别的工单、审查里派出的子助手), + * 只认第一行 session_meta 里 cwd 是本目录、且不是子助手的那个。 */ +import { Buffer } from 'node:buffer' import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as path from 'node:path' @@ -26,35 +18,38 @@ import * as path from 'node:path' /** UUID at the tail of `rollout--.jsonl`. v7 UUIDs are still 8-4-4-4-12. */ export const ROLLOUT_UUID_REGEX = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i -/** - * Try to extract a thread_id (UUID) from a rollout filename. - * Returns null when the name doesn't match the expected pattern. - */ -function extractIdFromFilename(filename: string): string | null { - const m = filename.match(ROLLOUT_UUID_REGEX) - return m ? m[1] : null +/** 第一行 session_meta 通常几十 KB;超过这个长度还没换行就当它不是会话文件。 */ +const FIRST_LINE_MAX_BYTES = 8 * 1024 * 1024 + +/** 读文件第一行;还没写到换行时返回 null,交给下一次文件变化再读。 */ +async function readCompleteFirstLine(filePath: string): Promise { + const fh = await fsp.open(filePath, 'r') + try { + const chunks: Buffer[] = [] + let total = 0 + while (total < FIRST_LINE_MAX_BYTES) { + const buf = Buffer.alloc(64 * 1024) + const { bytesRead } = await fh.read(buf, 0, buf.length, total) + if (bytesRead === 0) + return null + const chunk = buf.subarray(0, bytesRead) + const nl = chunk.indexOf(0x0A) + if (nl !== -1) { + chunks.push(chunk.subarray(0, nl)) + return Buffer.concat(chunks).toString('utf8') + } + chunks.push(chunk) + total += bytesRead + } + return '' + } + finally { + await fh.close() + } } -/** - * Read the first line of `filePath` and try to extract `payload.id` - * (codex's session_meta thread id). Also accepts top-level `thread_id` / - * `session_id` / `id` for robustness against future codex format tweaks. - */ -async function extractIdFromFirstLine(filePath: string): Promise { - let raw: string - try { - // Files are typically <100KB at session start; reading the whole thing - // and slicing to the first newline is simpler than a streaming reader - // and avoids race-with-partial-write issues. - raw = await fsp.readFile(filePath, 'utf8') - } - catch { - return null - } - const nl = raw.indexOf('\n') - const firstLine = nl === -1 ? raw : raw.slice(0, nl) - if (!firstLine.trim()) - return null +/** 第一行是本目录主会话的 session_meta 时返回会话 id,否则 null。 */ +function matchMainSession(firstLine: string, cwds: Set): string | null { let parsed: unknown try { parsed = JSON.parse(firstLine) @@ -62,125 +57,91 @@ async function extractIdFromFirstLine(filePath: string): Promise catch { return null } - if (!parsed || typeof parsed !== 'object') + const obj = parsed as { type?: unknown, payload?: { id?: unknown, cwd?: unknown, source?: unknown } } | null + if (obj?.type !== 'session_meta' || !obj.payload) return null - const obj = parsed as Record - const payload = obj.payload - if (payload && typeof payload === 'object') { - const pid = (payload as Record).id - if (typeof pid === 'string' && pid.length > 0) - return pid - } - for (const k of ['thread_id', 'session_id', 'id'] as const) { - const v = obj[k] - if (typeof v === 'string' && v.length > 0) - return v - } - return null + const { id, cwd, source } = obj.payload + if (typeof id !== 'string' || typeof cwd !== 'string') + return null + // 子助手会话的 source 是 { subagent: ... },主会话是 "cli" 这类字符串。 + if (typeof source === 'object' && source !== null) + return null + return cwds.has(path.resolve(cwd)) ? id : null } /** - * Watch `baseDir` recursively for the next new `rollout-*.jsonl` file and - * resolve with its thread_id. Resolves with null on timeout, watcher error, - * or when `fs.watch` isn't supported on this platform. - * - * Caller is responsible for ensuring `baseDir` exists; if it doesn't, this - * returns null immediately. + * 盯 `baseDir` 下新出现的会话文件,返回 `cwd` 目录下主会话的 id。 + * `signal` 中止、目录不存在或盯文件出错时返回 null。 */ -export async function watchForNewCodexSession(opts: { - /** Base directory to watch recursively, typically `~/.codex/sessions`. */ +export async function watchForCodexSession(opts: { + /** 会话根目录,通常是 `~/.codex/sessions`。 */ baseDir: string - /** Max milliseconds to wait before giving up. */ - timeoutMs: number + /** codex 启动时所在目录。 */ + cwd: string + signal: AbortSignal }): Promise { - const { baseDir, timeoutMs } = opts - + const { baseDir, signal } = opts + if (signal.aborted) + return null try { - const stat = await fsp.stat(baseDir) - if (!stat.isDirectory()) + if (!(await fsp.stat(baseDir)).isDirectory()) return null } catch { - // baseDir doesn't exist (codex never run on this machine?). The first - // codex run will create it, but `fs.watch` on a missing path throws — - // bail out so the caller doesn't hang. return null } + // codex 记的是解析过软链接的路径,两种写法都认。 + const cwds = new Set([path.resolve(opts.cwd)]) + try { + cwds.add(await fsp.realpath(opts.cwd)) + } + catch { + // 目录已被删掉时只按原样比对。 + } return new Promise((resolve) => { let settled = false let watcher: fs.FSWatcher | null = null - let timer: NodeJS.Timeout | null = null - // Dedupe: fs.watch fires 'rename' twice on some platforms; skip files we - // already inspected this session. - const seen = new Set() + // 已经看过第一行、确定不是目标的文件,不再重复读。 + const decided = new Set() - const done = (id: string | null): void => { + function done(id: string | null): void { if (settled) return settled = true - if (timer) { - clearTimeout(timer) - timer = null - } - if (watcher) { - try { - watcher.close() - } - catch { - // ignore close errors - } - watcher = null - } + signal.removeEventListener('abort', onAbort) + watcher?.close() resolve(id) } + function onAbort(): void { + done(null) + } + signal.addEventListener('abort', onAbort) + + const inspect = async (name: string): Promise => { + const firstLine = await readCompleteFirstLine(path.join(baseDir, name)).catch(() => null) + if (settled || firstLine === null || decided.has(name)) + return + decided.add(name) + const id = matchMainSession(firstLine, cwds) + if (id) + done(id) + } try { - // `recursive: true` is supported on Linux since Node 20. On older - // runtimes the call throws — fall through to done(null). - watcher = fs.watch(baseDir, { persistent: false, recursive: true }, (event, filename) => { + watcher = fs.watch(baseDir, { persistent: false, recursive: true }, (_event, filename) => { if (settled || !filename) return - if (event !== 'rename') - return const name = filename.toString() - if (!name.endsWith('.jsonl')) - return const base = path.basename(name) - if (!base.startsWith('rollout-')) + if (!base.startsWith('rollout-') || !base.endsWith('.jsonl') || decided.has(name)) return - if (seen.has(name)) - return - seen.add(name) - - const full = path.join(baseDir, name) - // `rename` fires for both create AND delete; confirm the file is - // really there before claiming a hit. - fsp.stat(full).then(async () => { - if (settled) - return - let id = extractIdFromFilename(base) - if (!id) - id = await extractIdFromFirstLine(full) - if (id) - done(id) - }).catch(() => { - // File no longer exists (delete event) — ignore. - }) - }) - watcher.on('error', (err) => { - console.warn('[superpowers] codexSessionWatcher: watch error', err) - done(null) + void inspect(name) }) + watcher.on('error', () => done(null)) } - catch (err) { - console.warn('[superpowers] codexSessionWatcher: fs.watch threw', err) + catch { done(null) - return } - - timer = setTimeout(() => { - done(null) - }, timeoutMs) }) } diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 2a298f7..016e407 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -10,7 +10,7 @@ import { loginForToken } from '../../auth/identity' import { getToken } from '../../auth/secrets' import { buildCcCommand } from '../../cc/ccCommand' import { buildCodexCommandString } from '../../cc/codexCommand' -import { watchForNewCodexSession } from '../../cc/codexSessionWatcher' +import { watchForCodexSession } from '../../cc/codexSessionWatcher' import { resolveProfilePath } from '../../cc/profiles' import { renderBrainstormPrompt, renderImplementPlanPrompt, renderPrototypePrompt, renderReviewPrompt, renderTestPrompt } from '../../cc/prompts' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' @@ -570,15 +570,19 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { }) } - // Start the codex session watcher BEFORE sendText so we don't race the - // rollout-*.jsonl creation. codex writes a new file under - // ~/.codex/sessions/YYYY/MM/DD/ on every run; we extract thread_id from - // its name and persist it as reviewSessionId so the detail panel's - // resume button works after the user closes this tab. - const watcherPromise = watchForNewCodexSession({ - baseDir: defaultCodexSessionsDir(), - timeoutMs: 120_000, + // 先盯会话目录再发命令,免得文件在盯上之前就建好了。拿到的 id 存成 + // reviewSessionId:PR 再更新时靠它往这个窗口发「再审」,窗口关了也能接着聊。 + // codex 启动可能拖好几分钟,一直盯到这个终端关掉为止。 + const watchAbort = new AbortController() + const closeListener = window.onDidCloseTerminal((closed) => { + if (closed === terminal) + watchAbort.abort() }) + const watcherPromise = watchForCodexSession({ + baseDir: defaultCodexSessionsDir(), + cwd: effectiveCwd, + signal: watchAbort.signal, + }).finally(() => closeListener.dispose()) // Interactive TUI (`codex` without `exec`/`review` subcommands): keeps // the terminal alive after the run so users can follow up with codex, @@ -606,7 +610,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { logger.add({ level: 'warn', source: 'review', - message: `审查会话监听超时 (#${opts.issueNumber})`, + message: `审查终端关闭前没等到会话文件 (#${opts.issueNumber})`, }) return }