Compare commits
2
Commits
1bb0850fa8
...
b0f67cbfcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0f67cbfcd | ||
|
|
a34d7d3f4d |
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"publisher": "clurdra",
|
"publisher": "clurdra",
|
||||||
"name": "superpowers-vscode-clurdra",
|
"name": "superpowers-vscode-clurdra",
|
||||||
"displayName": "Superpowers-clurdra",
|
"displayName": "Superpowers-clurdra",
|
||||||
"version": "0.2.213",
|
"version": "0.2.214",
|
||||||
"packageManager": "pnpm@10.27.0",
|
"packageManager": "pnpm@10.27.0",
|
||||||
"description": "Superpowers specs and plans Kanban explorer",
|
"description": "Superpowers specs and plans Kanban explorer",
|
||||||
"author": "clurdra",
|
"author": "clurdra",
|
||||||
|
|||||||
@@ -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 os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { watchForCodexSession } from './codexSessionWatcher'
|
import { findCodexSession, watchForCodexSession } from './codexSessionWatcher'
|
||||||
|
|
||||||
const tempDirs: string[] = []
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>()
|
||||||
afterEach(() => {
|
return { ...actual, watch: vi.fn() }
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
const dir = tempDirs.pop()
|
|
||||||
if (dir)
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function tempDir(prefix: string): string {
|
const tempDirs: string[] = []
|
||||||
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
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)
|
tempDirs.push(dir)
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
@@ -23,71 +43,204 @@ function tempDir(prefix: string): string {
|
|||||||
const MAIN_ID = '01a0e3bb-61e3-7c31-adf3-2d2a746a3282'
|
const MAIN_ID = '01a0e3bb-61e3-7c31-adf3-2d2a746a3282'
|
||||||
const SUB_ID = '01a0e3bc-2a65-7490-84a6-e963505d9bff'
|
const SUB_ID = '01a0e3bc-2a65-7490-84a6-e963505d9bff'
|
||||||
const OTHER_ID = '01a0e3be-4665-7991-b89e-f6881609c36b'
|
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 {
|
function metaLine(id: string, cwd: string, source: unknown = 'cli', timestamp = '2026-10-01T16:42:27.505Z', extra = ''): string {
|
||||||
return `${JSON.stringify({ timestamp: '2026-09-27T16:38:24.015Z', type: 'session_meta', payload: { id, cwd, source } })}\n`
|
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 {
|
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 })
|
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', () => {
|
describe('watchForCodexSession', () => {
|
||||||
it('只认本目录的主会话,跳过别的目录和子助手会话', async () => {
|
it('准备阶段扫描已落盘会话,不依赖文件通知', async () => {
|
||||||
const baseDir = tempDir('codex-sessions-')
|
const f = fixture()
|
||||||
const cwd = tempDir('worktree-')
|
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||||
const ac = new AbortController()
|
const found = await prepare(f)
|
||||||
const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal })
|
expect(found.initialSessionId).toBe(MAIN_ID)
|
||||||
await tick()
|
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||||
|
expect(fakeWatcher.close).toHaveBeenCalledOnce()
|
||||||
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 () => {
|
it('准备尚未结束时落盘的文件也会被首次扫描发现', async () => {
|
||||||
const baseDir = tempDir('codex-sessions-')
|
const f = fixture()
|
||||||
const cwd = tempDir('worktree-')
|
const original = fsp.realpath
|
||||||
const ac = new AbortController()
|
let release!: () => void
|
||||||
const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal })
|
const paused = new Promise<void>((resolve) => {
|
||||||
await tick(300)
|
release = resolve
|
||||||
|
})
|
||||||
writeFileSync(rolloutPath(baseDir, MAIN_ID), metaLine(MAIN_ID, cwd, 'cli'))
|
const check = vi.spyOn(fsp, 'realpath').mockImplementationOnce(async (p) => {
|
||||||
|
await paused
|
||||||
await expect(found).resolves.toBe(MAIN_ID)
|
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 () => {
|
it('漏掉全部文件通知时定时扫描仍捕获新会话', async () => {
|
||||||
const baseDir = tempDir('codex-sessions-')
|
const f = fixture()
|
||||||
const cwd = tempDir('worktree-')
|
const found = await prepare(f)
|
||||||
const ac = new AbortController()
|
writeFileSync(rolloutPath(f.baseDir, MAIN_ID), metaLine(MAIN_ID, f.cwd) + promptLine())
|
||||||
const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal })
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
await tick()
|
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||||
|
})
|
||||||
|
|
||||||
const file = rolloutPath(baseDir, MAIN_ID)
|
it('目录变化通知没有文件名时也会扫描', async () => {
|
||||||
const line = metaLine(MAIN_ID, cwd, 'cli')
|
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))
|
writeFileSync(file, line.slice(0, 20))
|
||||||
await tick()
|
const found = await prepare(f)
|
||||||
appendFileSync(file, line.slice(20))
|
expect(found.initialSessionId).toBeNull()
|
||||||
|
appendFileSync(file, line.slice(20) + promptLine())
|
||||||
await expect(found).resolves.toBe(MAIN_ID)
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
await expect(found.sessionId).resolves.toBe(MAIN_ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('中止后返回 null', async () => {
|
it('元数据完整但提示词尚未完整时继续等待', async () => {
|
||||||
const baseDir = tempDir('codex-sessions-')
|
const f = fixture()
|
||||||
const cwd = tempDir('worktree-')
|
const file = rolloutPath(f.baseDir, MAIN_ID)
|
||||||
const ac = new AbortController()
|
const line = promptLine()
|
||||||
const found = watchForCodexSession({ baseDir, cwd, signal: ac.signal })
|
writeFileSync(file, metaLine(MAIN_ID, f.cwd) + promptLine('# AGENTS.md instructions') + line.slice(0, 40))
|
||||||
await tick()
|
const found = await prepare(f)
|
||||||
ac.abort()
|
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 主会话,文件通知只负责提醒扫描。 */
|
||||||
* 等 codex 为指定目录新开的主会话落盘,拿到它的会话 id。
|
|
||||||
*
|
|
||||||
* 审查终端里跑的是 codex 界面,读不到它的输出,只能盯
|
|
||||||
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`。
|
|
||||||
* codex 在第一轮对话开始时才建这个文件,启动慢(外部工具服务起得慢等)
|
|
||||||
* 可能拖好几分钟,所以不设时间上限,由调用方在终端关掉时中止。
|
|
||||||
*
|
|
||||||
* 同一时间可能有别的 codex 也在建文件(别的工单、审查里派出的子助手),
|
|
||||||
* 只认第一行 session_meta 里 cwd 是本目录、且不是子助手的那个。
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
import type { FileHandle } from 'node:fs/promises'
|
||||||
import { Buffer } from 'node:buffer'
|
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'
|
||||||
|
|
||||||
/** 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
|
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 LINE_MAX_BYTES = 8 * 1024 * 1024
|
||||||
const FIRST_LINE_MAX_BYTES = 8 * 1024 * 1024
|
const SCAN_INTERVAL_MS = 1000
|
||||||
|
|
||||||
/** 读文件第一行;还没写到换行时返回 null,交给下一次文件变化再读。 */
|
export interface CodexSessionLaunch {
|
||||||
async function readCompleteFirstLine(filePath: string): Promise<string | null> {
|
cwd: string
|
||||||
const fh = await fsp.open(filePath, 'r')
|
startedAt: number
|
||||||
try {
|
prompt: string
|
||||||
const chunks: Buffer[] = []
|
}
|
||||||
let total = 0
|
|
||||||
while (total < FIRST_LINE_MAX_BYTES) {
|
interface FileProgress {
|
||||||
const buf = Buffer.alloc(64 * 1024)
|
offset: number
|
||||||
const { bytesRead } = await fh.read(buf, 0, buf.length, total)
|
sessionId?: string
|
||||||
if (bytesRead === 0)
|
decided?: boolean
|
||||||
return null
|
matched?: boolean
|
||||||
const chunk = buf.subarray(0, bytesRead)
|
}
|
||||||
const nl = chunk.indexOf(0x0A)
|
|
||||||
if (nl !== -1) {
|
/** 未写出完整的一行时保留读取位置,下次扫描继续读。 */
|
||||||
chunks.push(chunk.subarray(0, nl))
|
async function readCompleteLine(fh: FileHandle, offset: number, signal?: AbortSignal): Promise<{ line: string, nextOffset: number } | null> {
|
||||||
return Buffer.concat(chunks).toString('utf8')
|
const chunks: Buffer[] = []
|
||||||
}
|
let total = 0
|
||||||
chunks.push(chunk)
|
while (total < LINE_MAX_BYTES) {
|
||||||
total += bytesRead
|
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 {
|
throw new Error(`codex 会话记录单行超过 ${LINE_MAX_BYTES} 字节`)
|
||||||
await fh.close()
|
}
|
||||||
|
|
||||||
|
interface SessionRecord {
|
||||||
|
type?: unknown
|
||||||
|
payload?: {
|
||||||
|
type?: unknown
|
||||||
|
id?: unknown
|
||||||
|
cwd?: unknown
|
||||||
|
source?: unknown
|
||||||
|
timestamp?: unknown
|
||||||
|
role?: unknown
|
||||||
|
content?: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第一行是本目录主会话的 session_meta 时返回会话 id,否则 null。 */
|
function parseLine(line: string): SessionRecord | null {
|
||||||
function matchMainSession(firstLine: string, cwds: Set<string>): string | null {
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(firstLine)
|
const obj: unknown = JSON.parse(line)
|
||||||
|
return obj !== null && typeof obj === 'object' ? obj as SessionRecord : null
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return null
|
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)
|
if (obj?.type !== 'session_meta' || !obj.payload)
|
||||||
return null
|
return null
|
||||||
const { id, cwd, source } = obj.payload
|
const { id, cwd, source, timestamp } = obj.payload
|
||||||
if (typeof id !== 'string' || typeof cwd !== 'string')
|
if (typeof id !== 'string' || typeof cwd !== 'string' || typeof source !== 'string' || typeof timestamp !== 'string')
|
||||||
return null
|
return null
|
||||||
// 子助手会话的 source 是 { subagent: ... },主会话是 "cli" 这类字符串。
|
const createdAt = Date.parse(timestamp)
|
||||||
if (typeof source === 'object' && source !== null)
|
if (!Number.isFinite(createdAt) || createdAt < startedAt)
|
||||||
return null
|
return null
|
||||||
return cwds.has(path.resolve(cwd)) ? id : null
|
return cwds.has(path.resolve(cwd)) ? id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function matchesPrompt(obj: SessionRecord | null, prompt: string): boolean {
|
||||||
* 盯 `baseDir` 下新出现的会话文件,返回 `cwd` 目录下主会话的 id。
|
if (obj?.type !== 'response_item' || obj.payload?.role !== 'user' || !Array.isArray(obj.payload.content))
|
||||||
* `signal` 中止、目录不存在或盯文件出错时返回 null。
|
return false
|
||||||
*/
|
const text = obj.payload.content
|
||||||
export async function watchForCodexSession(opts: {
|
.filter((part: { type?: unknown, text?: unknown } | null) => part?.type === 'input_text' && typeof part.text === 'string')
|
||||||
/** 会话根目录,通常是 `~/.codex/sessions`。 */
|
.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
|
baseDir: string
|
||||||
/** codex 启动时所在目录。 */
|
|
||||||
cwd: string
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}): Promise<string | null> {
|
onWarning?: (err: unknown) => void
|
||||||
|
}): Promise<{ sessionId: Promise<string | null>, initialSessionId: string | null }> {
|
||||||
const { baseDir, signal } = opts
|
const { baseDir, signal } = opts
|
||||||
if (signal.aborted)
|
if (signal.aborted)
|
||||||
return null
|
return { sessionId: Promise.resolve(null), initialSessionId: null }
|
||||||
try {
|
await fsp.mkdir(baseDir, { recursive: true })
|
||||||
if (!(await fsp.stat(baseDir)).isDirectory())
|
const scan = await createScanner(baseDir, opts, signal)
|
||||||
return null
|
// 建立监听前验证可读,避免把准备失败当成已经开始等待。
|
||||||
}
|
await fsp.readdir(baseDir)
|
||||||
catch {
|
if (signal.aborted)
|
||||||
return null
|
return { sessionId: Promise.resolve(null), initialSessionId: 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) => {
|
let resolveSession!: (id: string | null) => void
|
||||||
let settled = false
|
let rejectSession!: (err: unknown) => void
|
||||||
let watcher: fs.FSWatcher | null = null
|
const sessionId = new Promise<string | null>((resolve, reject) => {
|
||||||
// 已经看过第一行、确定不是目标的文件,不再重复读。
|
resolveSession = resolve
|
||||||
const decided = new Set<string>()
|
rejectSession = reject
|
||||||
|
|
||||||
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 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 }
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1097,10 @@ export class KanbanWebviewPanel {
|
|||||||
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
|
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
|
||||||
this.loadYouTrackSafe(),
|
this.loadYouTrackSafe(),
|
||||||
])
|
])
|
||||||
|
this.issueRefs = new Map(
|
||||||
|
[...giteaIssues, ...youtrackList].map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
|
||||||
|
)
|
||||||
|
await sessions.recoverOpenReviewSessions(this, [...giteaIssues, ...youtrackList].map(i => i.number))
|
||||||
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有
|
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有
|
||||||
// 会话文件的会话 id,再补 live terminal 态。
|
// 会话文件的会话 id,再补 live terminal 态。
|
||||||
const issues = this.withLiveTerminalTabState(
|
const issues = this.withLiveTerminalTabState(
|
||||||
@@ -1104,9 +1108,6 @@ export class KanbanWebviewPanel {
|
|||||||
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
|
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
this.issueRefs = new Map(
|
|
||||||
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
|
|
||||||
)
|
|
||||||
this.me = me
|
this.me = me
|
||||||
this.issueOwners = new Map(
|
this.issueOwners = new Map(
|
||||||
issues.map(i => [i.number, { poster: i.poster, assignees: i.assignees }] as const),
|
issues.map(i => [i.number, { poster: i.poster, assignees: i.assignees }] as const),
|
||||||
@@ -1350,6 +1351,7 @@ export class KanbanWebviewPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private dispose(): void {
|
private dispose(): void {
|
||||||
|
sessions.stopReviewSessionCaptures(this)
|
||||||
KanbanWebviewPanel.current = undefined
|
KanbanWebviewPanel.current = undefined
|
||||||
webhookCoordinator.setActivePanel(undefined)
|
webhookCoordinator.setActivePanel(undefined)
|
||||||
this.gitStateDisposable?.dispose()
|
this.gitStateDisposable?.dispose()
|
||||||
|
|||||||
@@ -2024,6 +2024,8 @@ export async function handleClearSession(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (kind === 'review')
|
||||||
|
await sessions.cancelReviewSessionCapture(panel, issueNumber)
|
||||||
await panel.mergeIssueState(issueNumber, { [field]: '' })
|
await panel.mergeIssueState(issueNumber, { [field]: '' })
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import type { ExtensionContext, Terminal } from 'vscode'
|
||||||
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getLocalIssueState } from '../../issues/localState'
|
||||||
|
import {
|
||||||
|
cancelReviewSessionCapture,
|
||||||
|
codexProcessStartedAt,
|
||||||
|
interactiveCodexLaunch,
|
||||||
|
prepareReviewSessionCapture,
|
||||||
|
recoverOpenReviewSessions,
|
||||||
|
recoverReviewSessionCapture,
|
||||||
|
stopReviewSessionCaptures,
|
||||||
|
} from './reviewSessionCapture'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
watch: vi.fn(),
|
||||||
|
exec: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
closeListeners: new Set<(terminal: Terminal) => void>(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vscode', () => ({
|
||||||
|
window: {
|
||||||
|
onDidCloseTerminal: (listener: (terminal: Terminal) => void) => {
|
||||||
|
mocks.closeListeners.add(listener)
|
||||||
|
return {
|
||||||
|
dispose: () => {
|
||||||
|
mocks.dispose()
|
||||||
|
mocks.closeListeners.delete(listener)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('node:child_process', () => ({ execFile: mocks.exec }))
|
||||||
|
vi.mock('../../cc/codexSessionWatcher', () => ({ watchForCodexSession: mocks.watch }))
|
||||||
|
vi.mock('../../logging/logger', () => ({ logger: { add: mocks.log } }))
|
||||||
|
vi.mock('../../sessions/sessionFiles', () => ({ defaultCodexSessionsDir: () => '/codex/sessions' }))
|
||||||
|
|
||||||
|
const SID = '01a0f858-8af3-7d21-9ca5-3b63413f9efa'
|
||||||
|
const STARTED_AT = Date.parse('Fri Oct 2 00:42:25 2026')
|
||||||
|
const PROMPT = '开启`ADHD`模式,审查 PR #470。\n不要合并。\n'
|
||||||
|
const LAUNCH = { cwd: '/worktree', startedAt: STARTED_AT, prompt: PROMPT }
|
||||||
|
const panels: KanbanWebviewPanel[] = []
|
||||||
|
let resolveSession!: (id: string | null) => void
|
||||||
|
|
||||||
|
function fixture(source: 'gitea' | 'youtrack' = 'gitea') {
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
const context = {
|
||||||
|
workspaceState: {
|
||||||
|
get: vi.fn((key: string) => store.get(key)),
|
||||||
|
update: vi.fn(async (key: string, value: unknown) => {
|
||||||
|
if (value === undefined)
|
||||||
|
store.delete(key)
|
||||||
|
else
|
||||||
|
store.set(key, value)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionContext
|
||||||
|
const terminal = {
|
||||||
|
creationOptions: {
|
||||||
|
name: 'issue-466-审查',
|
||||||
|
cwd: LAUNCH.cwd,
|
||||||
|
shellPath: '/bin/zsh',
|
||||||
|
shellArgs: ['-ilc', 'exec "$@"', 'zsh', 'codex', '-c', 'model=gpt-6-astra', '-c', 'model_reasoning_effort=xhigh', '--dangerously-bypass-approvals-and-sandbox', PROMPT],
|
||||||
|
},
|
||||||
|
processId: Promise.resolve(43567),
|
||||||
|
exitStatus: undefined,
|
||||||
|
sendText: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
|
} as unknown as Terminal
|
||||||
|
const panel = {
|
||||||
|
context,
|
||||||
|
refFor: vi.fn((number: number) => ({ source, number })),
|
||||||
|
reviewTerminals: new Map(),
|
||||||
|
trackSessionTerminal: vi.fn(),
|
||||||
|
findExistingTerminal: vi.fn(() => terminal),
|
||||||
|
postMessage: vi.fn(),
|
||||||
|
mergeIssueState: vi.fn(() => { throw new Error('remote unavailable') }),
|
||||||
|
} as unknown as KanbanWebviewPanel
|
||||||
|
panels.push(panel)
|
||||||
|
return { panel, context, store, terminal, launchKey: `superpowers.reviewSessionLaunch.${source}:466` }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.exec.mockImplementation((_cmd, args, _options, callback) => {
|
||||||
|
callback(null, args.at(-1) === 'lstart=' ? 'Fri Oct 2 00:42:25 2026\n' : 'codex\n')
|
||||||
|
})
|
||||||
|
mocks.watch.mockImplementation(async ({ signal }) => {
|
||||||
|
const sessionId = new Promise<string | null>((resolve) => {
|
||||||
|
resolveSession = resolve
|
||||||
|
})
|
||||||
|
signal.addEventListener('abort', () => resolveSession(null))
|
||||||
|
return { sessionId, initialSessionId: null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const panel of panels.splice(0))
|
||||||
|
stopReviewSessionCaptures(panel)
|
||||||
|
mocks.closeListeners.clear()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reviewSessionCapture', () => {
|
||||||
|
it('准备完成后绑定终端,直接保存本机ID,不依赖远端', async () => {
|
||||||
|
const f = fixture('youtrack')
|
||||||
|
const prepared = await prepareReviewSessionCapture(f.panel, 466, LAUNCH)
|
||||||
|
expect(f.store.get(f.launchKey)).toEqual(LAUNCH)
|
||||||
|
expect(getLocalIssueState(f.context, 'youtrack', 466).reviewSessionId).toBeUndefined()
|
||||||
|
const captured = prepared.attach(f.terminal)
|
||||||
|
resolveSession(SID)
|
||||||
|
await expect(captured).resolves.toBe(SID)
|
||||||
|
expect(getLocalIssueState(f.context, 'youtrack', 466).reviewSessionId).toBe(SID)
|
||||||
|
expect(f.panel.mergeIssueState).not.toHaveBeenCalled()
|
||||||
|
expect(f.panel.reviewTerminals.get(SID)).toBe(f.terminal)
|
||||||
|
expect(f.panel.postMessage).toHaveBeenCalledWith({ type: 'issue/patch', issueNumber: 466, patch: { reviewSessionId: SID } })
|
||||||
|
expect(f.store.has(f.launchKey)).toBe(false)
|
||||||
|
expect(mocks.dispose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('刷新已有终端时按进程启动信息补回ID,不发送再审消息', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
mocks.watch.mockResolvedValue({ sessionId: Promise.resolve(SID), initialSessionId: SID })
|
||||||
|
await recoverOpenReviewSessions(f.panel, [466])
|
||||||
|
expect(mocks.watch).toHaveBeenCalledWith(expect.objectContaining({ ...LAUNCH, startedAt: STARTED_AT }))
|
||||||
|
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe(SID)
|
||||||
|
expect(f.terminal.sendText).not.toHaveBeenCalled()
|
||||||
|
expect(f.terminal.show).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('尚未落盘的恢复立即返回,之后后台补回ID', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
await expect(recoverReviewSessionCapture(f.panel, 466, f.terminal)).resolves.toBeNull()
|
||||||
|
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBeUndefined()
|
||||||
|
resolveSession(SID)
|
||||||
|
await vi.waitFor(() => expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe(SID))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('用户清除留下的墓碑不得自动补回', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
f.store.set('superpowers.localIssueState', { 'gitea:466': { reviewSessionId: '' } })
|
||||||
|
await expect(recoverReviewSessionCapture(f.panel, 466, f.terminal)).resolves.toBeNull()
|
||||||
|
expect(mocks.exec).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.watch).not.toHaveBeenCalled()
|
||||||
|
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('用户明确新建的审查可以替换之前清除留下的墓碑', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
f.store.set('superpowers.localIssueState', { 'gitea:466': { reviewSessionId: '' } })
|
||||||
|
const prepared = await prepareReviewSessionCapture(f.panel, 466, LAUNCH)
|
||||||
|
const captured = prepared.attach(f.terminal)
|
||||||
|
resolveSession(SID)
|
||||||
|
await expect(captured).resolves.toBe(SID)
|
||||||
|
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBe(SID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清除时中止待捕获并删除启动记录,之后不写回ID', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const prepared = await prepareReviewSessionCapture(f.panel, 466, LAUNCH)
|
||||||
|
const captured = prepared.attach(f.terminal)
|
||||||
|
await cancelReviewSessionCapture(f.panel, 466)
|
||||||
|
resolveSession(SID)
|
||||||
|
await expect(captured).resolves.toBeNull()
|
||||||
|
expect(f.store.has(f.launchKey)).toBe(false)
|
||||||
|
expect(getLocalIssueState(f.context, 'gitea', 466).reviewSessionId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('准备期间被取消,不允许稍后继续创建终端', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
let ready!: () => void
|
||||||
|
mocks.watch.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
ready = () => resolve({ sessionId: Promise.resolve(null), initialSessionId: null })
|
||||||
|
}))
|
||||||
|
const preparing = prepareReviewSessionCapture(f.panel, 466, LAUNCH)
|
||||||
|
const rejected = expect(preparing).rejects.toThrow('已中止')
|
||||||
|
await vi.waitFor(() => expect(mocks.watch).toHaveBeenCalled())
|
||||||
|
await cancelReviewSessionCapture(f.panel, 466)
|
||||||
|
ready()
|
||||||
|
await rejected
|
||||||
|
expect(f.store.has(f.launchKey)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('恢复查询进程期间被取消,不启动新的后台捕获', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const pending: Array<{ field: string, callback: (err: null, output: string) => void }> = []
|
||||||
|
mocks.exec.mockImplementation((_cmd, args, _options, callback) => {
|
||||||
|
pending.push({ field: args.at(-1), callback })
|
||||||
|
})
|
||||||
|
const recovering = recoverReviewSessionCapture(f.panel, 466, f.terminal)
|
||||||
|
await vi.waitFor(() => expect(pending).toHaveLength(2))
|
||||||
|
await cancelReviewSessionCapture(f.panel, 466)
|
||||||
|
for (const item of pending)
|
||||||
|
item.callback(null, item.field === 'lstart=' ? 'Fri Oct 2 00:42:25 2026' : 'codex')
|
||||||
|
await expect(recovering).resolves.toBeNull()
|
||||||
|
expect(mocks.watch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同时触发恢复只检查一次进程和会话', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const a = recoverReviewSessionCapture(f.panel, 466, f.terminal)
|
||||||
|
const b = recoverReviewSessionCapture(f.panel, 466, f.terminal)
|
||||||
|
expect(a).toBe(b)
|
||||||
|
await a
|
||||||
|
expect(mocks.exec).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mocks.watch).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('准备失败时清理启动记录并传播原因', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
mocks.watch.mockRejectedValueOnce(new Error('sessions unreadable'))
|
||||||
|
await expect(prepareReviewSessionCapture(f.panel, 466, LAUNCH)).rejects.toThrow('sessions unreadable')
|
||||||
|
expect(f.store.has(f.launchKey)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('终端关闭时停止捕获并清理记录', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const prepared = await prepareReviewSessionCapture(f.panel, 466, LAUNCH)
|
||||||
|
const captured = prepared.attach(f.terminal)
|
||||||
|
for (const listener of mocks.closeListeners)
|
||||||
|
listener(f.terminal)
|
||||||
|
await expect(captured).resolves.toBeNull()
|
||||||
|
expect(f.store.has(f.launchKey)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无法确认进程时记录原因,不误认现存会话', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
mocks.exec.mockImplementation((_cmd, args, _options, callback) => {
|
||||||
|
callback(null, args.at(-1) === 'lstart=' ? 'Fri Oct 2 00:42:25 2026' : 'zsh')
|
||||||
|
})
|
||||||
|
await expect(recoverReviewSessionCapture(f.panel, 466, f.terminal)).resolves.toBeNull()
|
||||||
|
expect(mocks.watch).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.log).toHaveBeenCalledWith(expect.objectContaining({ level: 'warn', details: expect.stringContaining('不是可确认启动时间的 codex') }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resume终端不按新进程时间扫描历史会话', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const terminal = { ...f.terminal, creationOptions: { cwd: '/worktree', shellArgs: ['-ilc', 'exec "$@"', 'zsh', 'codex', 'resume', '--dangerously-bypass-approvals-and-sandbox', SID] } } as unknown as Terminal
|
||||||
|
expect(interactiveCodexLaunch(terminal)).toBeNull()
|
||||||
|
await expect(recoverReviewSessionCapture(f.panel, 466, terminal)).resolves.toBeNull()
|
||||||
|
expect(mocks.exec).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('进程检查使用短超时与固定英文日期,非法PID直接拒绝', async () => {
|
||||||
|
await expect(codexProcessStartedAt(43567)).resolves.toBe(STARTED_AT)
|
||||||
|
expect(mocks.exec).toHaveBeenCalledWith('ps', expect.any(Array), expect.objectContaining({ timeout: 2000, env: expect.objectContaining({ LC_ALL: 'C' }) }), expect.any(Function))
|
||||||
|
await expect(codexProcessStartedAt(-1)).rejects.toThrow('没有有效的进程编号')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('终端始终没有提供进程编号时刷新也会及时结束', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const f = fixture()
|
||||||
|
const terminal = { ...f.terminal, processId: new Promise<number | undefined>(() => {}) } as unknown as Terminal
|
||||||
|
const recovering = recoverReviewSessionCapture(f.panel, 466, terminal)
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
await expect(recovering).resolves.toBeNull()
|
||||||
|
expect(mocks.exec).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.log).toHaveBeenCalledWith(expect.objectContaining({ details: expect.stringContaining('在 2 秒内未提供进程编号') }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import type { Terminal } from 'vscode'
|
||||||
|
import type { CodexSessionLaunch } from '../../cc/codexSessionWatcher'
|
||||||
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { window } from 'vscode'
|
||||||
|
import { watchForCodexSession } from '../../cc/codexSessionWatcher'
|
||||||
|
import { getLocalIssueState, mergeLocalIssueState } from '../../issues/localState'
|
||||||
|
import { logger } from '../../logging/logger'
|
||||||
|
import { defaultCodexSessionsDir } from '../../sessions/sessionFiles'
|
||||||
|
|
||||||
|
interface ReviewCapture {
|
||||||
|
abort: AbortController
|
||||||
|
launch: CodexSessionLaunch
|
||||||
|
terminal?: Terminal
|
||||||
|
captured?: Promise<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const captures = new WeakMap<KanbanWebviewPanel, Map<number, ReviewCapture>>()
|
||||||
|
const recoveries = new WeakMap<KanbanWebviewPanel, Map<number, { abort: AbortController, result: Promise<string | null> }>>()
|
||||||
|
|
||||||
|
function captureMap(panel: KanbanWebviewPanel): Map<number, ReviewCapture> {
|
||||||
|
let map = captures.get(panel)
|
||||||
|
if (!map) {
|
||||||
|
map = new Map()
|
||||||
|
captures.set(panel, map)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchKey(panel: KanbanWebviewPanel, issueNumber: number): string {
|
||||||
|
return `superpowers.reviewSessionLaunch.${panel.refFor(issueNumber).source ?? 'gitea'}:${issueNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户清除关联时先中止捕获,防止稍后落盘的文件把关联补回来。 */
|
||||||
|
export async function cancelReviewSessionCapture(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
|
const map = captureMap(panel)
|
||||||
|
recoveries.get(panel)?.get(issueNumber)?.abort.abort()
|
||||||
|
map.get(issueNumber)?.abort.abort()
|
||||||
|
map.delete(issueNumber)
|
||||||
|
await panel.context.workspaceState.update(launchKey(panel, issueNumber), undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopReviewSessionCaptures(panel: KanbanWebviewPanel): void {
|
||||||
|
for (const recovery of recoveries.get(panel)?.values() ?? [])
|
||||||
|
recovery.abort.abort()
|
||||||
|
for (const capture of captureMap(panel).values())
|
||||||
|
capture.abort.abort()
|
||||||
|
captures.delete(panel)
|
||||||
|
recoveries.delete(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportCaptureFailure(issueNumber: number, err: unknown): void {
|
||||||
|
logger.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'review',
|
||||||
|
message: `确认审查会话失败 (#${issueNumber})`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存本机关联不依赖远端认证或网络。 */
|
||||||
|
async function persistCapture(panel: KanbanWebviewPanel, issueNumber: number, capture: ReviewCapture, id: string): Promise<string | null> {
|
||||||
|
if (capture.abort.signal.aborted || captureMap(panel).get(issueNumber) !== capture || !capture.terminal)
|
||||||
|
return null
|
||||||
|
await mergeLocalIssueState(panel.context, panel.refFor(issueNumber).source, issueNumber, { reviewSessionId: id })
|
||||||
|
if (capture.abort.signal.aborted)
|
||||||
|
return null
|
||||||
|
panel.reviewTerminals.set(id, capture.terminal)
|
||||||
|
panel.trackSessionTerminal(capture.terminal, issueNumber, 'review')
|
||||||
|
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { reviewSessionId: id } })
|
||||||
|
await panel.context.workspaceState.update(launchKey(panel, issueNumber), undefined)
|
||||||
|
logger.add({ level: 'info', source: 'review', message: `已捕获审查会话 ${id} (#${issueNumber})` })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回前完成目录确认和首次扫描,调用方随后才创建终端。 */
|
||||||
|
export async function prepareReviewSessionCapture(panel: KanbanWebviewPanel, issueNumber: number, launch: CodexSessionLaunch): Promise<{
|
||||||
|
attach: (terminal: Terminal) => Promise<string | null>
|
||||||
|
initialSessionId: string | null
|
||||||
|
}> {
|
||||||
|
captureMap(panel).get(issueNumber)?.abort.abort()
|
||||||
|
const capture: ReviewCapture = { abort: new AbortController(), launch }
|
||||||
|
captureMap(panel).set(issueNumber, capture)
|
||||||
|
try {
|
||||||
|
await panel.context.workspaceState.update(launchKey(panel, issueNumber), launch)
|
||||||
|
const watch = await watchForCodexSession({
|
||||||
|
...launch,
|
||||||
|
baseDir: defaultCodexSessionsDir(),
|
||||||
|
signal: capture.abort.signal,
|
||||||
|
onWarning: (err) => {
|
||||||
|
logger.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'review',
|
||||||
|
message: `审查会话文件通知不可用,继续定时扫描 (#${issueNumber})`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (capture.abort.signal.aborted)
|
||||||
|
throw new Error('审查会话捕获已中止')
|
||||||
|
// 捕获准备好后,attach 同步绑定终端和关闭事件,再异步保存结果。
|
||||||
|
let closeListener: { dispose: () => void } | undefined
|
||||||
|
const attach = (terminal: Terminal): Promise<string | null> => {
|
||||||
|
capture.terminal = terminal
|
||||||
|
closeListener = window.onDidCloseTerminal((closed) => {
|
||||||
|
if (closed === terminal) {
|
||||||
|
void cancelReviewSessionCapture(panel, issueNumber).catch(err => reportCaptureFailure(issueNumber, err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
capture.captured = watch.sessionId
|
||||||
|
.then(id => id ? persistCapture(panel, issueNumber, capture, id) : null)
|
||||||
|
.catch((err) => {
|
||||||
|
reportCaptureFailure(issueNumber, err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
closeListener?.dispose()
|
||||||
|
if (captureMap(panel).get(issueNumber) === capture)
|
||||||
|
captureMap(panel).delete(issueNumber)
|
||||||
|
})
|
||||||
|
return capture.captured
|
||||||
|
}
|
||||||
|
// 准备与终端创建之间失败时也有拒绝处理,避免无人接收的失败 Promise。
|
||||||
|
void watch.sessionId.catch(() => {})
|
||||||
|
logger.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'review',
|
||||||
|
message: `已准备审查会话捕获 (#${issueNumber}) cwd=${launch.cwd} startedAt=${new Date(launch.startedAt).toISOString()}`,
|
||||||
|
})
|
||||||
|
return { attach, initialSessionId: watch.initialSessionId }
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
capture.abort.abort()
|
||||||
|
if (captureMap(panel).get(issueNumber) === capture)
|
||||||
|
await cancelReviewSessionCapture(panel, issueNumber)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只认由登录 shell 直接启动的交互式 codex,resume 不走新会话发现。 */
|
||||||
|
export function interactiveCodexLaunch(terminal: Pick<Terminal, 'creationOptions'>): Pick<CodexSessionLaunch, 'cwd' | 'prompt'> | null {
|
||||||
|
const options = terminal.creationOptions
|
||||||
|
if (!('shellArgs' in options) || !Array.isArray(options.shellArgs) || !('cwd' in options) || !options.cwd)
|
||||||
|
return null
|
||||||
|
const args = options.shellArgs
|
||||||
|
if (args[0] !== '-ilc' || args[1] !== 'exec "$@"' || args[3] !== 'codex')
|
||||||
|
return null
|
||||||
|
let i = 4
|
||||||
|
while (i < args.length) {
|
||||||
|
if (args[i] === '-c')
|
||||||
|
i += 2
|
||||||
|
else if (args[i] === '--dangerously-bypass-approvals-and-sandbox')
|
||||||
|
i++
|
||||||
|
else
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (i !== args.length - 1 || !args[i] || ['resume', 'exec', 'review'].includes(args[i]))
|
||||||
|
return null
|
||||||
|
return { cwd: typeof options.cwd === 'string' ? options.cwd : options.cwd.fsPath, prompt: args[i] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function processField(pid: number, field: string, signal?: AbortSignal): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile('ps', ['-p', String(pid), '-o', `${field}=`], {
|
||||||
|
timeout: 2000,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, LC_ALL: 'C' },
|
||||||
|
signal,
|
||||||
|
}, (err, stdout) => {
|
||||||
|
if (err)
|
||||||
|
reject(err)
|
||||||
|
else
|
||||||
|
resolve(stdout.trim())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 存活终端的真实进程边界用于恢复,不能把刷新时间当作会话启动时间。 */
|
||||||
|
export async function codexProcessStartedAt(pid: number, signal?: AbortSignal): Promise<number> {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0)
|
||||||
|
throw new Error('审查终端没有有效的进程编号')
|
||||||
|
const [started, command] = await Promise.all([processField(pid, 'lstart', signal), processField(pid, 'comm', signal)])
|
||||||
|
const startedAt = Date.parse(started)
|
||||||
|
if (path.basename(command) !== 'codex' || !Number.isFinite(startedAt))
|
||||||
|
throw new Error(`审查终端进程不是可确认启动时间的 codex:${command || '空进程'} ${started}`)
|
||||||
|
return startedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalProcessId(terminal: Terminal, signal: AbortSignal): Promise<number | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
finish(new Error('审查终端在 2 秒内未提供进程编号'))
|
||||||
|
}, 2000)
|
||||||
|
timer.unref()
|
||||||
|
function finish(err?: unknown, pid?: number): void {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
|
if (err)
|
||||||
|
reject(err)
|
||||||
|
else
|
||||||
|
resolve(pid)
|
||||||
|
}
|
||||||
|
function onAbort(): void {
|
||||||
|
finish(new Error('审查会话恢复已中止'))
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort)
|
||||||
|
if (signal.aborted)
|
||||||
|
onAbort()
|
||||||
|
else
|
||||||
|
void Promise.resolve(terminal.processId).then(pid => finish(undefined, pid), err => finish(err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已有文件立即补关联;尚未落盘则后台等待,刷新不会等完整审查运行。 */
|
||||||
|
async function recoverReviewSessionCaptureInner(panel: KanbanWebviewPanel, issueNumber: number, terminal: Terminal, signal: AbortSignal): Promise<string | null> {
|
||||||
|
const local = getLocalIssueState(panel.context, panel.refFor(issueNumber).source, issueNumber)
|
||||||
|
if (local.reviewSessionId === '' || captureMap(panel).has(issueNumber))
|
||||||
|
return null
|
||||||
|
if (local.reviewSessionId)
|
||||||
|
return local.reviewSessionId
|
||||||
|
const launch = interactiveCodexLaunch(terminal)
|
||||||
|
if (!launch)
|
||||||
|
return null
|
||||||
|
try {
|
||||||
|
const pid = await terminalProcessId(terminal, signal)
|
||||||
|
if (pid === undefined)
|
||||||
|
throw new Error('审查终端未提供进程编号,无法确认本次启动边界')
|
||||||
|
const processStartedAt = await codexProcessStartedAt(pid, signal)
|
||||||
|
if (signal.aborted || terminal.exitStatus !== undefined)
|
||||||
|
return null
|
||||||
|
const stored = panel.context.workspaceState.get<CodexSessionLaunch>(launchKey(panel, issueNumber))
|
||||||
|
const startedAt = stored?.prompt === launch.prompt && stored.cwd === launch.cwd && Number.isFinite(stored.startedAt)
|
||||||
|
? Math.max(stored.startedAt, processStartedAt)
|
||||||
|
: processStartedAt
|
||||||
|
const prepared = await prepareReviewSessionCapture(panel, issueNumber, { ...launch, startedAt })
|
||||||
|
if (signal.aborted) {
|
||||||
|
await cancelReviewSessionCapture(panel, issueNumber)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const captured = prepared.attach(terminal)
|
||||||
|
return prepared.initialSessionId ? captured : null
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (!signal.aborted)
|
||||||
|
reportCaptureFailure(issueNumber, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverReviewSessionCapture(panel: KanbanWebviewPanel, issueNumber: number, terminal: Terminal): Promise<string | null> {
|
||||||
|
let map = recoveries.get(panel)
|
||||||
|
if (!map) {
|
||||||
|
map = new Map()
|
||||||
|
recoveries.set(panel, map)
|
||||||
|
}
|
||||||
|
const existing = map.get(issueNumber)
|
||||||
|
if (existing)
|
||||||
|
return existing.result
|
||||||
|
const abort = new AbortController()
|
||||||
|
const result = recoverReviewSessionCaptureInner(panel, issueNumber, terminal, abort.signal)
|
||||||
|
.finally(() => {
|
||||||
|
if (map.get(issueNumber)?.result === result)
|
||||||
|
map.delete(issueNumber)
|
||||||
|
})
|
||||||
|
map.set(issueNumber, { abort, result })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverOpenReviewSessions(panel: KanbanWebviewPanel, issueNumbers: readonly number[]): Promise<void> {
|
||||||
|
await Promise.all(issueNumbers.map(async (issueNumber) => {
|
||||||
|
const terminal = panel.findExistingTerminal(`issue-${issueNumber}-审查`)
|
||||||
|
if (terminal)
|
||||||
|
await recoverReviewSessionCapture(panel, issueNumber, terminal)
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import { loginForToken } from '../../auth/identity'
|
|||||||
import { getToken } from '../../auth/secrets'
|
import { getToken } from '../../auth/secrets'
|
||||||
import { buildCcArgv } from '../../cc/ccCommand'
|
import { buildCcArgv } from '../../cc/ccCommand'
|
||||||
import { buildCodexArgv } from '../../cc/codexCommand'
|
import { buildCodexArgv } from '../../cc/codexCommand'
|
||||||
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 { sessionLaunch } from '../../cc/sessionTerminal'
|
import { sessionLaunch } from '../../cc/sessionTerminal'
|
||||||
@@ -19,8 +18,9 @@ import { detectRepo } from '../../git/remote'
|
|||||||
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
|
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
|
||||||
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'
|
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'
|
||||||
import { updateIssueAssignees } from '../../gitea/api'
|
import { updateIssueAssignees } from '../../gitea/api'
|
||||||
|
import { getLocalIssueState } from '../../issues/localState'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
import { claudeSessionFileExists, codexSessionFileExists, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
|
import { claudeSessionFileExists, codexSessionFileExists } from '../../sessions/sessionFiles'
|
||||||
import { getSettings } from '../../settings/store'
|
import { getSettings } from '../../settings/store'
|
||||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||||
import { makeNonce } from '../../webview/html'
|
import { makeNonce } from '../../webview/html'
|
||||||
@@ -28,8 +28,11 @@ import { decideAutoReviewPath } from '../autoReviewDecision'
|
|||||||
import { pickBrainstormContinuePath } from '../brainstormContinue'
|
import { pickBrainstormContinuePath } from '../brainstormContinue'
|
||||||
import { cleanupFeatureBranch } from './branchCleanup'
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
|
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
|
||||||
|
import { cancelReviewSessionCapture, prepareReviewSessionCapture, recoverReviewSessionCapture } from './reviewSessionCapture'
|
||||||
import { submitToSessionTerminal } from './terminals'
|
import { submitToSessionTerminal } from './terminals'
|
||||||
|
|
||||||
|
export { cancelReviewSessionCapture, recoverOpenReviewSessions, stopReviewSessionCaptures } from './reviewSessionCapture'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话入口统一的 state 读取:读失败只记 warn,按空状态继续——提示词模板里的
|
* 会话入口统一的 state 读取:读失败只记 warn,按空状态继续——提示词模板里的
|
||||||
* 条件块会自然走 else 分支,没必要为一次网络抖动拦住开会话。
|
* 条件块会自然走 else 分支,没必要为一次网络抖动拦住开会话。
|
||||||
@@ -415,21 +418,13 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
|||||||
const terminalName = `issue-${opts.issueNumber}-审查`
|
const terminalName = `issue-${opts.issueNumber}-审查`
|
||||||
const existing = panel.findExistingTerminal(terminalName)
|
const existing = panel.findExistingTerminal(terminalName)
|
||||||
|
|
||||||
// Pre-fetch the persisted reviewSessionId from issue state JSON so we
|
// 本机关联优先;未保存过时才从工单状态读取,或确认存活终端的启动记录。
|
||||||
// can pick the right branch below. Any failure (no remote / no token /
|
const localState = getLocalIssueState(panel.context, panel.refFor(opts.issueNumber).source, opts.issueNumber)
|
||||||
// network blip) MUST NOT break the whole review trigger — fall through
|
let existingSessionId = nonEmptyString(localState.reviewSessionId)
|
||||||
// with existingSessionId = undefined and we'll spin up a fresh codex.
|
|
||||||
let existingSessionId: string | undefined
|
|
||||||
try {
|
try {
|
||||||
const remote = await detectRepo(opts.workspaceRoot)
|
if (localState.reviewSessionId === undefined) {
|
||||||
if (remote) {
|
const state = await panel.readIssueState(opts.issueNumber)
|
||||||
const token = await getToken(panel.context, remote.host)
|
existingSessionId = nonEmptyString(state.reviewSessionId)
|
||||||
if (token) {
|
|
||||||
const state = await panel.readIssueState(opts.issueNumber)
|
|
||||||
const sid = state?.reviewSessionId
|
|
||||||
if (typeof sid === 'string' && sid.length > 0)
|
|
||||||
existingSessionId = sid
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -441,6 +436,9 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing && !existingSessionId && localState.reviewSessionId !== '')
|
||||||
|
existingSessionId = (await recoverReviewSessionCapture(panel, opts.issueNumber, existing)) ?? undefined
|
||||||
|
|
||||||
const reviewPath = decideAutoReviewPath({
|
const reviewPath = decideAutoReviewPath({
|
||||||
tabAlive: !!existing,
|
tabAlive: !!existing,
|
||||||
reviewSessionId: existingSessionId,
|
reviewSessionId: existingSessionId,
|
||||||
@@ -520,45 +518,43 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
|||||||
const { themeColor, iconUri } = await panel.resolveIssueIcon(opts.issueNumber)
|
const { themeColor, iconUri } = await panel.resolveIssueIcon(opts.issueNumber)
|
||||||
const settings = getSettings(panel.context)
|
const settings = getSettings(panel.context)
|
||||||
|
|
||||||
// 先盯会话目录再拉起 codex,免得文件在盯上之前就建好了。拿到的 id 存成
|
const capture = await prepareReviewSessionCapture(panel, opts.issueNumber, {
|
||||||
// reviewSessionId:PR 再更新时靠它往这个窗口发「再审」,窗口关了也能接着聊。
|
|
||||||
// codex 启动可能拖好几分钟,一直盯到这个终端关掉为止。
|
|
||||||
const watchAbort = new AbortController()
|
|
||||||
const watcherPromise = watchForCodexSession({
|
|
||||||
baseDir: defaultCodexSessionsDir(),
|
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
signal: watchAbort.signal,
|
startedAt: Date.now(),
|
||||||
|
prompt: opts.prompt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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,
|
||||||
// mirroring how claude implementation/brainstorm sessions stay open.
|
// mirroring how claude implementation/brainstorm sessions stay open.
|
||||||
// 提示词模板自带 /review 前缀,不在这里再拼。
|
let terminal: Terminal
|
||||||
const terminal = window.createTerminal({
|
try {
|
||||||
name: terminalName,
|
terminal = window.createTerminal({
|
||||||
cwd: effectiveCwd,
|
name: terminalName,
|
||||||
location: panel.resolveTerminalLocation(false),
|
cwd: effectiveCwd,
|
||||||
iconPath: iconUri,
|
location: panel.resolveTerminalLocation(false),
|
||||||
color: themeColor,
|
iconPath: iconUri,
|
||||||
...sessionLaunch(buildCodexArgv({
|
color: themeColor,
|
||||||
mode: 'interactive',
|
...sessionLaunch(buildCodexArgv({
|
||||||
prompt: opts.prompt,
|
mode: 'interactive',
|
||||||
model: settings.codexModel,
|
prompt: opts.prompt,
|
||||||
reasoningEffort: settings.codexReasoningEffort,
|
model: settings.codexModel,
|
||||||
})),
|
reasoningEffort: settings.codexReasoningEffort,
|
||||||
})
|
})),
|
||||||
const closeListener = window.onDidCloseTerminal((closed) => {
|
})
|
||||||
if (closed === terminal)
|
void capture.attach(terminal)
|
||||||
watchAbort.abort()
|
panel.trackSessionTerminal(terminal, opts.issueNumber, 'review')
|
||||||
})
|
terminal.show(false)
|
||||||
void watcherPromise.finally(() => closeListener.dispose())
|
}
|
||||||
|
catch (err) {
|
||||||
|
await cancelReviewSessionCapture(panel, opts.issueNumber)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'terminal',
|
source: 'terminal',
|
||||||
message: `已创建审查终端 "${terminalName}" cwd=${effectiveCwd},codex review 已启动`,
|
message: `已创建审查终端 "${terminalName}" cwd=${effectiveCwd},codex review 已启动`,
|
||||||
})
|
})
|
||||||
panel.trackSessionTerminal(terminal, opts.issueNumber, 'review')
|
|
||||||
terminal.show(false)
|
|
||||||
|
|
||||||
if (cwdFallback) {
|
if (cwdFallback) {
|
||||||
panel.postMessage({
|
panel.postMessage({
|
||||||
@@ -569,57 +565,6 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
|||||||
dismissOnTimer: 5000,
|
dismissOnTimer: 5000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget: when the watcher resolves with a thread_id, persist
|
|
||||||
// it to state JSON + push an issue/patch so the webview shows the resume
|
|
||||||
// button. Failures here don't break the review run itself.
|
|
||||||
void watcherPromise.then(async (threadId) => {
|
|
||||||
if (!threadId) {
|
|
||||||
logger.add({
|
|
||||||
level: 'warn',
|
|
||||||
source: 'review',
|
|
||||||
message: `审查终端关闭前没等到会话文件 (#${opts.issueNumber})`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.add({
|
|
||||||
level: 'info',
|
|
||||||
source: 'review',
|
|
||||||
message: `已捕获审查会话 ${threadId} (#${opts.issueNumber})`,
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
|
||||||
if (!workspaceRoot)
|
|
||||||
return
|
|
||||||
const remote = await detectRepo(workspaceRoot)
|
|
||||||
if (!remote)
|
|
||||||
return
|
|
||||||
const token = await getToken(panel.context, remote.host)
|
|
||||||
if (!token)
|
|
||||||
return
|
|
||||||
await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId })
|
|
||||||
panel.postMessage({
|
|
||||||
type: 'issue/patch',
|
|
||||||
issueNumber: opts.issueNumber,
|
|
||||||
patch: { reviewSessionId: threadId },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
logger.add({
|
|
||||||
level: 'warn',
|
|
||||||
source: 'review',
|
|
||||||
message: `持久化 reviewSessionId 失败 (#${opts.issueNumber})`,
|
|
||||||
details: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
logger.add({
|
|
||||||
level: 'warn',
|
|
||||||
source: 'review',
|
|
||||||
message: `审查 session watcher 异常 (#${opts.issueNumber})`,
|
|
||||||
details: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user