🐛 fix(vscode): 自动审查只认本目录的 codex 主会话,不再认错别的会话;去掉 2 分钟上限,一直等到审查终端关掉
This commit is contained in:
1 parent
bb85bbd862
commit
f2b17e5389
3 files changed
+197
-139
No files matched your search
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user