🐛 fix(vscode): 自动审查只认本目录的 codex 主会话,不再认错别的会话;去掉 2 分钟上限,一直等到审查终端关掉

This commit is contained in:
chehongweo committed 2026-09-28 23:03:50 +08:00
1 parent bb85bbd862
commit f2b17e5389
3 files changed
+197 -139

No files matched your search

+93
View File
@@ -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<void> => 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()
})
})
+90 -129
View File
@@ -1,24 +1,16 @@
/** /**
* Watch the codex sessions root directory for the next new `rollout-*.jsonl` * 等 codex 为指定目录新开的主会话落盘,拿到它的会话 id。
* file to appear, returning its thread_id.
* *
* Used by the auto-review flow: when we `sendText('codex exec review ...')` * 审查终端里跑的是 codex 界面,读不到它的输出,只能盯
* into a terminal tab, codex writes its session transcript to * `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`。
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`. We can't sniff * codex 在第一轮对话开始时才建这个文件,启动慢(外部工具服务起得慢等)
* the terminal's stdout, so instead we watch this filesystem root for new * 可能拖好几分钟,所以不设时间上限,由调用方在终端关掉时中止。
* files and extract the thread_id from the filename / first line.
* *
* File layout (probed 2026-05-20): * 同一时间可能有别的 codex 也在建文件(别的工单、审查里派出的子助手),
* * 只认第一行 session_meta 里 cwd 是本目录、且不是子助手的那个。
* - 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.
*/ */
import { Buffer } from 'node:buffer'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs' import { promises as fsp } from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
@@ -26,35 +18,38 @@ import * as path from 'node:path'
/** UUID at the tail of `rollout-<ISO>-<uuid>.jsonl`. v7 UUIDs are still 8-4-4-4-12. */ /** UUID at the tail of `rollout-<ISO>-<uuid>.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 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
/** /** 第一行 session_meta 通常几十 KB;超过这个长度还没换行就当它不是会话文件。 */
* Try to extract a thread_id (UUID) from a rollout filename. const FIRST_LINE_MAX_BYTES = 8 * 1024 * 1024
* Returns null when the name doesn't match the expected pattern.
*/ /** 读文件第一行;还没写到换行时返回 null,交给下一次文件变化再读。 */
function extractIdFromFilename(filename: string): string | null { async function readCompleteFirstLine(filePath: string): Promise<string | null> {
const m = filename.match(ROLLOUT_UUID_REGEX) const fh = await fsp.open(filePath, 'r')
return m ? m[1] : null 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()
}
} }
/** /** 第一行是本目录主会话的 session_meta 时返回会话 id,否则 null。 */
* Read the first line of `filePath` and try to extract `payload.id` function matchMainSession(firstLine: string, cwds: Set<string>): string | null {
* (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<string | null> {
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
let parsed: unknown let parsed: unknown
try { try {
parsed = JSON.parse(firstLine) parsed = JSON.parse(firstLine)
@@ -62,125 +57,91 @@ async function extractIdFromFirstLine(filePath: string): Promise<string | null>
catch { catch {
return null 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 return null
const obj = parsed as Record<string, unknown> const { id, cwd, source } = obj.payload
const payload = obj.payload if (typeof id !== 'string' || typeof cwd !== 'string')
if (payload && typeof payload === 'object') { return null
const pid = (payload as Record<string, unknown>).id // 子助手会话的 source 是 { subagent: ... },主会话是 "cli" 这类字符串。
if (typeof pid === 'string' && pid.length > 0) if (typeof source === 'object' && source !== null)
return pid return null
} return cwds.has(path.resolve(cwd)) ? id : null
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
} }
/** /**
* Watch `baseDir` recursively for the next new `rollout-*.jsonl` file and * 盯 `baseDir` 下新出现的会话文件,返回 `cwd` 目录下主会话的 id。
* resolve with its thread_id. Resolves with null on timeout, watcher error, * `signal` 中止、目录不存在或盯文件出错时返回 null。
* 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.
*/ */
export async function watchForNewCodexSession(opts: { export async function watchForCodexSession(opts: {
/** Base directory to watch recursively, typically `~/.codex/sessions`. */ /** 会话根目录,通常是 `~/.codex/sessions`。 */
baseDir: string baseDir: string
/** Max milliseconds to wait before giving up. */ /** codex 启动时所在目录。 */
timeoutMs: number cwd: string
signal: AbortSignal
}): Promise<string | null> { }): Promise<string | null> {
const { baseDir, timeoutMs } = opts const { baseDir, signal } = opts
if (signal.aborted)
return null
try { try {
const stat = await fsp.stat(baseDir) if (!(await fsp.stat(baseDir)).isDirectory())
if (!stat.isDirectory())
return null return null
} }
catch { 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 return null
} }
// codex 记的是解析过软链接的路径,两种写法都认。
const cwds = new Set([path.resolve(opts.cwd)])
try {
cwds.add(await fsp.realpath(opts.cwd))
}
catch {
// 目录已被删掉时只按原样比对。
}
return new Promise<string | null>((resolve) => { return new Promise<string | null>((resolve) => {
let settled = false let settled = false
let watcher: fs.FSWatcher | null = null let watcher: fs.FSWatcher | null = null
let timer: NodeJS.Timeout | null = null // 已经看过第一行、确定不是目标的文件,不再重复读。
// Dedupe: fs.watch fires 'rename' twice on some platforms; skip files we const decided = new Set<string>()
// already inspected this session.
const seen = new Set<string>()
const done = (id: string | null): void => { function done(id: string | null): void {
if (settled) if (settled)
return return
settled = true settled = true
if (timer) { signal.removeEventListener('abort', onAbort)
clearTimeout(timer) watcher?.close()
timer = null
}
if (watcher) {
try {
watcher.close()
}
catch {
// ignore close errors
}
watcher = null
}
resolve(id) resolve(id)
} }
function onAbort(): void {
done(null)
}
signal.addEventListener('abort', onAbort)
const inspect = async (name: string): Promise<void> => {
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 { try {
// `recursive: true` is supported on Linux since Node 20. On older watcher = fs.watch(baseDir, { persistent: false, recursive: true }, (_event, filename) => {
// runtimes the call throws — fall through to done(null).
watcher = fs.watch(baseDir, { persistent: false, recursive: true }, (event, filename) => {
if (settled || !filename) if (settled || !filename)
return return
if (event !== 'rename')
return
const name = filename.toString() const name = filename.toString()
if (!name.endsWith('.jsonl'))
return
const base = path.basename(name) const base = path.basename(name)
if (!base.startsWith('rollout-')) if (!base.startsWith('rollout-') || !base.endsWith('.jsonl') || decided.has(name))
return return
if (seen.has(name)) void inspect(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)
}) })
watcher.on('error', () => done(null))
} }
catch (err) { catch {
console.warn('[superpowers] codexSessionWatcher: fs.watch threw', err)
done(null) done(null)
return
} }
timer = setTimeout(() => {
done(null)
}, timeoutMs)
}) })
} }
+14 -10
View File
@@ -10,7 +10,7 @@ import { loginForToken } from '../../auth/identity'
import { getToken } from '../../auth/secrets' import { getToken } from '../../auth/secrets'
import { buildCcCommand } from '../../cc/ccCommand' import { buildCcCommand } from '../../cc/ccCommand'
import { buildCodexCommandString } from '../../cc/codexCommand' import { buildCodexCommandString } from '../../cc/codexCommand'
import { watchForNewCodexSession } from '../../cc/codexSessionWatcher' import { watchForCodexSession } from '../../cc/codexSessionWatcher'
import { resolveProfilePath } from '../../cc/profiles' import { resolveProfilePath } from '../../cc/profiles'
import { renderBrainstormPrompt, renderImplementPlanPrompt, renderPrototypePrompt, renderReviewPrompt, renderTestPrompt } from '../../cc/prompts' import { renderBrainstormPrompt, renderImplementPlanPrompt, renderPrototypePrompt, renderReviewPrompt, renderTestPrompt } from '../../cc/prompts'
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' 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 // 先盯会话目录再发命令,免得文件在盯上之前就建好了。拿到的 id 存成
// rollout-*.jsonl creation. codex writes a new file under // reviewSessionId:PR 再更新时靠它往这个窗口发「再审」,窗口关了也能接着聊。
// ~/.codex/sessions/YYYY/MM/DD/ on every run; we extract thread_id from // codex 启动可能拖好几分钟,一直盯到这个终端关掉为止。
// its name and persist it as reviewSessionId so the detail panel's const watchAbort = new AbortController()
// resume button works after the user closes this tab. const closeListener = window.onDidCloseTerminal((closed) => {
const watcherPromise = watchForNewCodexSession({ if (closed === terminal)
baseDir: defaultCodexSessionsDir(), watchAbort.abort()
timeoutMs: 120_000,
}) })
const watcherPromise = watchForCodexSession({
baseDir: defaultCodexSessionsDir(),
cwd: effectiveCwd,
signal: watchAbort.signal,
}).finally(() => closeListener.dispose())
// Interactive TUI (`codex` without `exec`/`review` subcommands): keeps // Interactive TUI (`codex` without `exec`/`review` subcommands): keeps
// the terminal alive after the run so users can follow up with codex, // 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({ logger.add({
level: 'warn', level: 'warn',
source: 'review', source: 'review',
message: `审查会话监听超时 (#${opts.issueNumber})`, message: `审查终端关闭前没等到会话文件 (#${opts.issueNumber})`,
}) })
return return
} }