🐛 fix(vscode): 可靠捕获并补回审查会话关联
This commit is contained in:
1 parent
1bb0850fa8
commit
a34d7d3f4d
7 files changed
+1054
-273
No files matched your search
@@ -1,21 +1,41 @@
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { appendFileSync, promises as fsp, mkdirSync, mkdtempSync, rmSync, symlinkSync, watch, 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'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { findCodexSession, watchForCodexSession } from './codexSessionWatcher'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return { ...actual, watch: vi.fn() }
|
||||
})
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
const tempDirs: string[] = []
|
||||
const controllers: AbortController[] = []
|
||||
let notices: (() => void) | undefined
|
||||
let fakeWatcher: EventEmitter & { close: ReturnType<typeof vi.fn> }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
fakeWatcher = Object.assign(new EventEmitter(), { close: vi.fn() })
|
||||
vi.mocked(watch).mockReset().mockImplementation(((_dir: string, _opts: unknown, notify: () => void) => {
|
||||
notices = notify
|
||||
return fakeWatcher
|
||||
}) as unknown as typeof watch)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const ac of controllers.splice(0))
|
||||
ac.abort()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
for (const dir of tempDirs.splice(0))
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
notices = undefined
|
||||
})
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'codex-capture-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
@@ -23,71 +43,204 @@ function tempDir(prefix: string): string {
|
||||
const MAIN_ID = '01a0e3bb-61e3-7c31-adf3-2d2a746a3282'
|
||||
const SUB_ID = '01a0e3bc-2a65-7490-84a6-e963505d9bff'
|
||||
const OTHER_ID = '01a0e3be-4665-7991-b89e-f6881609c36b'
|
||||
const OLD_ID = '01a0e3be-4665-7991-b89e-f6881609c36b'
|
||||
const STARTED_AT = Date.parse('2026-10-01T16:42:25.000Z')
|
||||
const PROMPT = '审查这个仓库的 PR #470。\n只提交审查意见。'
|
||||
|
||||
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 metaLine(id: string, cwd: string, source: unknown = 'cli', timestamp = '2026-10-01T16:42:27.505Z', extra = ''): string {
|
||||
return `${JSON.stringify({ timestamp: '2026-10-01T16:42:28.040Z', type: 'session_meta', payload: { id, cwd, source, timestamp, extra } })}\n`
|
||||
}
|
||||
|
||||
function promptLine(prompt = PROMPT): string {
|
||||
return `${JSON.stringify({ type: 'response_item', payload: { role: 'user', content: [{ type: 'input_text', text: prompt }] } })}\n`
|
||||
}
|
||||
|
||||
function rolloutPath(baseDir: string, id: string): string {
|
||||
const day = path.join(baseDir, '2026', '09', '28')
|
||||
const day = path.join(baseDir, '2026', '10', '02')
|
||||
mkdirSync(day, { recursive: true })
|
||||
return path.join(day, `rollout-2026-09-28T00-38-23-${id}.jsonl`)
|
||||
return path.join(day, `rollout-2026-10-02T00-42-27-${id}.jsonl`)
|
||||
}
|
||||
|
||||
const tick = (ms = 50): Promise<void> => new Promise(r => setTimeout(r, ms))
|
||||
function fixture(): { baseDir: string, cwd: string, ac: AbortController } {
|
||||
const ac = new AbortController()
|
||||
controllers.push(ac)
|
||||
return { baseDir: tempDir(), cwd: tempDir(), ac }
|
||||
}
|
||||
|
||||
async function prepare(f: ReturnType<typeof fixture>) {
|
||||
return watchForCodexSession({ baseDir: f.baseDir, cwd: f.cwd, startedAt: STARTED_AT, prompt: PROMPT, signal: f.ac.signal })
|
||||
}
|
||||
|
||||
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 f = fixture()
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
const found = await prepare(f)
|
||||
expect(found.initialSessionId).toBe(MAIN_ID)
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
expect(fakeWatcher.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
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 f = fixture()
|
||||
const original = fsp.realpath
|
||||
let release!: () => void
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const check = vi.spyOn(fsp, 'realpath').mockImplementationOnce(async (p) => {
|
||||
await paused
|
||||
return original(p)
|
||||
})
|
||||
const preparing = prepare(f)
|
||||
await vi.waitFor(() => expect(check).toHaveBeenCalled())
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
release()
|
||||
await expect((await preparing).sessionId).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()
|
||||
it('漏掉全部文件通知时定时扫描仍捕获新会话', async () => {
|
||||
const f = fixture()
|
||||
const found = await prepare(f)
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
const file = rolloutPath(baseDir, MAIN_ID)
|
||||
const line = metaLine(MAIN_ID, cwd, 'cli')
|
||||
it('目录变化通知没有文件名时也会扫描', async () => {
|
||||
const f = fixture()
|
||||
const found = await prepare(f)
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
notices?.()
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
it('跳过其他目录、子助手与同目录不同启动提示词', async () => {
|
||||
const f = fixture()
|
||||
writeFileSync(rolloutPath(f.baseDir, OTHER_ID), metaLine(OTHER_ID, '/somewhere/else') + promptLine())
|
||||
writeFileSync(rolloutPath(f.baseDir, SUB_ID), metaLine(SUB_ID, f.cwd, { subagent: {} }) + promptLine())
|
||||
const differentId = '01a0e3be-4665-7991-b89e-f6881609c36c'
|
||||
writeFileSync(rolloutPath(f.baseDir, differentId), metaLine(differentId, f.cwd) + promptLine('其他工作'))
|
||||
const found = await prepare(f)
|
||||
expect(found.initialSessionId).toBeNull()
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
notices?.()
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
it('按会话实际创建时间排除启动前旧会话,即使刚被写入或外层时间较新', async () => {
|
||||
const f = fixture()
|
||||
writeFileSync(rolloutPath(f.baseDir, OLD_ID), metaLine(OLD_ID, f.cwd, 'cli', '2026-10-01T16:42:24.999Z') + promptLine())
|
||||
const found = await prepare(f)
|
||||
expect(found.initialSessionId).toBeNull()
|
||||
f.ac.abort()
|
||||
await expect(found.sessionId).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('其他主会话后续收到相同提示词时不能当作本次启动', async () => {
|
||||
const f = fixture()
|
||||
const assistant = `${JSON.stringify({ type: 'response_item', payload: { role: 'assistant', type: 'message', content: [] } })}\n`
|
||||
writeFileSync(rolloutPath(f.baseDir, OTHER_ID), metaLine(OTHER_ID, f.cwd) + promptLine('其他工作的启动') + assistant + promptLine())
|
||||
await expect(findCodexSession({ baseDir: f.baseDir, cwd: f.cwd, startedAt: STARTED_AT, prompt: PROMPT })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('半写的第一行在后续通知丢失时仍会重读,支持大行和中文', async () => {
|
||||
const f = fixture()
|
||||
const file = rolloutPath(f.baseDir, MAIN_ID)
|
||||
const line = metaLine(MAIN_ID, f.cwd, 'cli', undefined, '中文上下文'.repeat(20000))
|
||||
writeFileSync(file, line.slice(0, 20))
|
||||
await tick()
|
||||
appendFileSync(file, line.slice(20))
|
||||
|
||||
await expect(found).resolves.toBe(MAIN_ID)
|
||||
const found = await prepare(f)
|
||||
expect(found.initialSessionId).toBeNull()
|
||||
appendFileSync(file, line.slice(20) + promptLine())
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(found.sessionId).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()
|
||||
it('元数据完整但提示词尚未完整时继续等待', async () => {
|
||||
const f = fixture()
|
||||
const file = rolloutPath(f.baseDir, MAIN_ID)
|
||||
const line = promptLine()
|
||||
writeFileSync(file, metaLine(MAIN_ID, f.cwd) + promptLine('# AGENTS.md instructions') + line.slice(0, 40))
|
||||
const found = await prepare(f)
|
||||
expect(found.initialSessionId).toBeNull()
|
||||
appendFileSync(file, line.slice(40))
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
await expect(found).resolves.toBeNull()
|
||||
it('软链接启动目录与真实目录匹配', async () => {
|
||||
const f = fixture()
|
||||
const link = path.join(tempDir(), 'cwd')
|
||||
symlinkSync(f.cwd, link)
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, await fsp.realpath(f.cwd)) + promptLine())
|
||||
await expect(findCodexSession({ baseDir: f.baseDir, cwd: link, startedAt: STARTED_AT, prompt: PROMPT })).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
it('多个主会话满足同一启动信息时明确失败,不选最新', async () => {
|
||||
const f = fixture()
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
writeFileSync(rolloutPath(f.baseDir, OTHER_ID), metaLine(OTHER_ID, f.cwd) + promptLine())
|
||||
await expect(prepare(f)).rejects.toThrow('多个符合本次启动信息')
|
||||
expect(fakeWatcher.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('监听建立失败时记录原因并继续通过扫描确认', async () => {
|
||||
const f = fixture()
|
||||
const err = new Error('watch unavailable')
|
||||
vi.mocked(watch).mockImplementationOnce(() => {
|
||||
throw err
|
||||
})
|
||||
const onWarning = vi.fn()
|
||||
const found = await watchForCodexSession({ baseDir: f.baseDir, cwd: f.cwd, startedAt: STARTED_AT, prompt: PROMPT, signal: f.ac.signal, onWarning })
|
||||
expect(onWarning).toHaveBeenCalledWith(err)
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
it('监听运行中失败也保持扫描,并关闭失效监听', async () => {
|
||||
const f = fixture()
|
||||
const found = await prepare(f)
|
||||
fakeWatcher.emit('error', new Error('watch stopped'))
|
||||
expect(fakeWatcher.close).toHaveBeenCalledOnce()
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
it('会话目录尚不存在时建立目录,之后能够捕获', async () => {
|
||||
const f = fixture()
|
||||
f.baseDir = path.join(f.baseDir, 'new', 'sessions')
|
||||
const found = await prepare(f)
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
notices?.()
|
||||
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||
})
|
||||
|
||||
it('会话根路径不是目录时准备明确失败', async () => {
|
||||
const f = fixture()
|
||||
f.baseDir = path.join(f.baseDir, 'not-dir')
|
||||
writeFileSync(f.baseDir, '')
|
||||
await expect(prepare(f)).rejects.toThrow()
|
||||
expect(watch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('中止会关闭监听和补偿定时器,且不再捕获', async () => {
|
||||
const f = fixture()
|
||||
const found = await prepare(f)
|
||||
f.ac.abort()
|
||||
await expect(found.sessionId).resolves.toBeNull()
|
||||
expect(fakeWatcher.close).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||
notices?.()
|
||||
await expect(found.sessionId).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('准备前已中止时不创建监听', async () => {
|
||||
const f = fixture()
|
||||
f.ac.abort()
|
||||
await expect((await prepare(f)).sessionId).resolves.toBeNull()
|
||||
expect(watch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user