🐛 fix(vscode): 可靠捕获并补回审查会话关联

This commit is contained in:
chehongweo committed 2026-10-02 01:19:11 +08:00
1 parent 1bb0850fa8
commit a34d7d3f4d
7 files changed
+1054 -273

No files matched your search

+253 -114
View File
@@ -1,147 +1,286 @@
/**
* 等 codex 为指定目录新开的主会话落盘,拿到它的会话 id。
*
* 审查终端里跑的是 codex 界面,读不到它的输出,只能盯
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`。
* codex 在第一轮对话开始时才建这个文件,启动慢(外部工具服务起得慢等)
* 可能拖好几分钟,所以不设时间上限,由调用方在终端关掉时中止。
*
* 同一时间可能有别的 codex 也在建文件(别的工单、审查里派出的子助手),
* 只认第一行 session_meta 里 cwd 是本目录、且不是子助手的那个。
*/
/** 按启动时间、目录和启动提示词确认 codex 主会话,文件通知只负责提醒扫描。 */
import type { FileHandle } from 'node:fs/promises'
import { Buffer } from 'node:buffer'
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
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`. */
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;超过这个长度还没换行就当它不是会话文件。 */
const FIRST_LINE_MAX_BYTES = 8 * 1024 * 1024
const LINE_MAX_BYTES = 8 * 1024 * 1024
const SCAN_INTERVAL_MS = 1000
/** 读文件第一行;还没写到换行时返回 null,交给下一次文件变化再读。 */
async function readCompleteFirstLine(filePath: string): Promise<string | null> {
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
export interface CodexSessionLaunch {
cwd: string
startedAt: number
prompt: string
}
interface FileProgress {
offset: number
sessionId?: string
decided?: boolean
matched?: boolean
}
/** 未写出完整的一行时保留读取位置,下次扫描继续读。 */
async function readCompleteLine(fh: FileHandle, offset: number, signal?: AbortSignal): Promise<{ line: string, nextOffset: number } | null> {
const chunks: Buffer[] = []
let total = 0
while (total < LINE_MAX_BYTES) {
if (signal?.aborted)
return null
const buf = Buffer.alloc(Math.min(64 * 1024, LINE_MAX_BYTES - total))
const { bytesRead } = await fh.read(buf, 0, buf.length, offset + 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 { line: Buffer.concat(chunks).toString('utf8'), nextOffset: offset + total + nl + 1 }
}
return ''
chunks.push(chunk)
total += bytesRead
}
finally {
await fh.close()
throw new Error(`codex 会话记录单行超过 ${LINE_MAX_BYTES} 字节`)
}
interface SessionRecord {
type?: unknown
payload?: {
type?: unknown
id?: unknown
cwd?: unknown
source?: unknown
timestamp?: unknown
role?: unknown
content?: unknown
}
}
/** 第一行是本目录主会话的 session_meta 时返回会话 id,否则 null。 */
function matchMainSession(firstLine: string, cwds: Set<string>): string | null {
let parsed: unknown
function parseLine(line: string): SessionRecord | null {
try {
parsed = JSON.parse(firstLine)
const obj: unknown = JSON.parse(line)
return obj !== null && typeof obj === 'object' ? obj as SessionRecord : null
}
catch {
return null
}
const obj = parsed as { type?: unknown, payload?: { id?: unknown, cwd?: unknown, source?: unknown } } | null
}
function matchMainSession(line: string, cwds: Set<string>, startedAt: number): string | null {
const obj = parseLine(line)
if (obj?.type !== 'session_meta' || !obj.payload)
return null
const { id, cwd, source } = obj.payload
if (typeof id !== 'string' || typeof cwd !== 'string')
const { id, cwd, source, timestamp } = obj.payload
if (typeof id !== 'string' || typeof cwd !== 'string' || typeof source !== 'string' || typeof timestamp !== 'string')
return null
// 子助手会话的 source 是 { subagent: ... },主会话是 "cli" 这类字符串。
if (typeof source === 'object' && source !== null)
const createdAt = Date.parse(timestamp)
if (!Number.isFinite(createdAt) || createdAt < startedAt)
return null
return cwds.has(path.resolve(cwd)) ? id : null
}
/**
* 盯 `baseDir` 下新出现的会话文件,返回 `cwd` 目录下主会话的 id。
* `signal` 中止、目录不存在或盯文件出错时返回 null。
*/
export async function watchForCodexSession(opts: {
/** 会话根目录,通常是 `~/.codex/sessions`。 */
function matchesPrompt(obj: SessionRecord | null, prompt: string): boolean {
if (obj?.type !== 'response_item' || obj.payload?.role !== 'user' || !Array.isArray(obj.payload.content))
return false
const text = obj.payload.content
.filter((part: { type?: unknown, text?: unknown } | null) => part?.type === 'input_text' && typeof part.text === 'string')
.map((part: { text: string }) => part.text)
.join('\n')
return text === prompt
}
async function createScanner(baseDir: string, launch: CodexSessionLaunch, signal?: AbortSignal): Promise<() => Promise<string | null>> {
if (!Number.isFinite(launch.startedAt) || !launch.prompt)
throw new Error('捕获 codex 会话必须提供有效的启动时间和提示词')
const cwds = new Set([path.resolve(launch.cwd), await fsp.realpath(launch.cwd)])
const progress = new Map<string, FileProgress>()
async function inspect(filePath: string): Promise<string | null> {
const state = progress.get(filePath) ?? { offset: 0 }
progress.set(filePath, state)
if (state.decided)
return null
if (state.matched)
return state.sessionId ?? null
let fh: FileHandle
try {
fh = await fsp.open(filePath, 'r')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return null
throw err
}
try {
for (;;) {
const next = await readCompleteLine(fh, state.offset, signal)
if (!next)
return null
state.offset = next.nextOffset
if (!state.sessionId) {
const id = matchMainSession(next.line, cwds, launch.startedAt)
if (!id) {
state.decided = true
return null
}
state.sessionId = id
}
else {
const obj = parseLine(next.line)
if (matchesPrompt(obj, launch.prompt)) {
state.matched = true
return state.sessionId
}
// 首轮助手已经开始工作,之后的用户消息不再是启动提示词。
if (obj?.type === 'response_item' && (obj.payload?.role === 'assistant'
|| ['reasoning', 'function_call', 'custom_tool_call'].includes(String(obj.payload?.type)))) {
state.decided = true
return null
}
}
}
}
finally {
await fh.close()
}
}
return async () => {
const names = await fsp.readdir(baseDir, { recursive: true })
const matches = new Set<string>()
for (const name of names) {
if (signal?.aborted)
return null
if (!path.basename(name).startsWith('rollout-') || !ROLLOUT_UUID_REGEX.test(name))
continue
const id = await inspect(path.join(baseDir, name))
if (id)
matches.add(id)
}
if (matches.size > 1)
throw new Error(`存在多个符合本次启动信息的 codex 主会话:${[...matches].join(', ')}`)
return matches.values().next().value ?? null
}
}
/** 一次磁盘扫描;与持续捕获共用相同的确认规则。 */
export async function findCodexSession(opts: CodexSessionLaunch & { baseDir: string }): Promise<string | null> {
const scan = await createScanner(opts.baseDir, opts)
return scan()
}
/** 准备完成后才允许启动终端;已落盘的会话也会在准备阶段确认。 */
export async function watchForCodexSession(opts: CodexSessionLaunch & {
baseDir: string
/** codex 启动时所在目录。 */
cwd: string
signal: AbortSignal
}): Promise<string | null> {
onWarning?: (err: unknown) => void
}): Promise<{ sessionId: Promise<string | null>, initialSessionId: string | null }> {
const { baseDir, signal } = opts
if (signal.aborted)
return null
try {
if (!(await fsp.stat(baseDir)).isDirectory())
return null
}
catch {
return null
}
// codex 记的是解析过软链接的路径,两种写法都认。
const cwds = new Set([path.resolve(opts.cwd)])
try {
cwds.add(await fsp.realpath(opts.cwd))
}
catch {
// 目录已被删掉时只按原样比对。
}
return { sessionId: Promise.resolve(null), initialSessionId: null }
await fsp.mkdir(baseDir, { recursive: true })
const scan = await createScanner(baseDir, opts, signal)
// 建立监听前验证可读,避免把准备失败当成已经开始等待。
await fsp.readdir(baseDir)
if (signal.aborted)
return { sessionId: Promise.resolve(null), initialSessionId: null }
return new Promise<string | null>((resolve) => {
let settled = false
let watcher: fs.FSWatcher | null = null
// 已经看过第一行、确定不是目标的文件,不再重复读。
const decided = new Set<string>()
function done(id: string | null): void {
if (settled)
return
settled = true
signal.removeEventListener('abort', onAbort)
watcher?.close()
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 {
watcher = fs.watch(baseDir, { persistent: false, recursive: true }, (_event, filename) => {
if (settled || !filename)
return
const name = filename.toString()
const base = path.basename(name)
if (!base.startsWith('rollout-') || !base.endsWith('.jsonl') || decided.has(name))
return
void inspect(name)
})
watcher.on('error', () => done(null))
}
catch {
done(null)
}
let resolveSession!: (id: string | null) => void
let rejectSession!: (err: unknown) => void
const sessionId = new Promise<string | null>((resolve, reject) => {
resolveSession = resolve
rejectSession = reject
})
let settled = false
let watcher: fs.FSWatcher | undefined
let timer: ReturnType<typeof setInterval> | undefined
let scanning = false
let scanQueued = false
function cleanup(): void {
signal.removeEventListener('abort', onAbort)
watcher?.close()
clearInterval(timer)
}
function done(id: string | null, err?: unknown): void {
if (settled)
return
settled = true
cleanup()
if (err)
rejectSession(err)
else
resolveSession(id)
}
function onAbort(): void {
done(null)
}
async function rescan(): Promise<void> {
if (settled)
return
scanQueued = true
if (scanning)
return
scanning = true
try {
while (scanQueued && !settled) {
scanQueued = false
const id = await scan()
if (id)
done(id)
}
}
catch (err) {
done(null, err)
}
finally {
scanning = false
}
}
signal.addEventListener('abort', onAbort)
try {
watcher = fs.watch(baseDir, { persistent: false, recursive: true }, () => {
void rescan()
})
watcher.on('error', (err) => {
watcher?.close()
watcher = undefined
opts.onWarning?.(err)
})
}
catch (err) {
opts.onWarning?.(err)
}
// 准备时的扫描与通知/定时扫描不并发,读取位置只由一个扫描推进。
scanning = true
let initialSessionId: string | null
try {
initialSessionId = await scan()
if (initialSessionId)
done(initialSessionId)
}
catch (err) {
settled = true
cleanup()
resolveSession(null)
throw err
}
finally {
scanning = false
}
if (!settled) {
timer = setInterval(() => {
void rescan()
}, SCAN_INTERVAL_MS)
timer.unref()
if (scanQueued)
void rescan()
}
return { sessionId, initialSessionId: settled && signal.aborted ? null : initialSessionId }
}