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

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

No files matched your search

+5 -3
View File
@@ -1097,6 +1097,10 @@ export class KanbanWebviewPanel {
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
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,再补 live terminal 态。
const issues = this.withLiveTerminalTabState(
@@ -1104,9 +1108,6 @@ export class KanbanWebviewPanel {
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.issueOwners = new Map(
issues.map(i => [i.number, { poster: i.poster, assignees: i.assignees }] as const),
@@ -1350,6 +1351,7 @@ export class KanbanWebviewPanel {
}
private dispose(): void {
sessions.stopReviewSessionCaptures(this)
KanbanWebviewPanel.current = undefined
webhookCoordinator.setActivePanel(undefined)
this.gitStateDisposable?.dispose()
+2
View File
@@ -2024,6 +2024,8 @@ export async function handleClearSession(panel: KanbanWebviewPanel, issueNumber:
return
try {
if (kind === 'review')
await sessions.cancelReviewSessionCapture(panel, issueNumber)
await panel.mergeIssueState(issueNumber, { [field]: '' })
}
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)
}))
}
+40 -95
View File
@@ -10,7 +10,6 @@ import { loginForToken } from '../../auth/identity'
import { getToken } from '../../auth/secrets'
import { buildCcArgv } from '../../cc/ccCommand'
import { buildCodexArgv } from '../../cc/codexCommand'
import { watchForCodexSession } from '../../cc/codexSessionWatcher'
import { resolveProfilePath } from '../../cc/profiles'
import { renderBrainstormPrompt, renderImplementPlanPrompt, renderPrototypePrompt, renderReviewPrompt, renderTestPrompt } from '../../cc/prompts'
import { sessionLaunch } from '../../cc/sessionTerminal'
@@ -19,8 +18,9 @@ import { detectRepo } from '../../git/remote'
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'
import { updateIssueAssignees } from '../../gitea/api'
import { getLocalIssueState } from '../../issues/localState'
import { logger } from '../../logging/logger'
import { claudeSessionFileExists, codexSessionFileExists, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
import { claudeSessionFileExists, codexSessionFileExists } from '../../sessions/sessionFiles'
import { getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { makeNonce } from '../../webview/html'
@@ -28,8 +28,11 @@ import { decideAutoReviewPath } from '../autoReviewDecision'
import { pickBrainstormContinuePath } from '../brainstormContinue'
import { cleanupFeatureBranch } from './branchCleanup'
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
import { cancelReviewSessionCapture, prepareReviewSessionCapture, recoverReviewSessionCapture } from './reviewSessionCapture'
import { submitToSessionTerminal } from './terminals'
export { cancelReviewSessionCapture, recoverOpenReviewSessions, stopReviewSessionCaptures } from './reviewSessionCapture'
/**
* 会话入口统一的 state 读取:读失败只记 warn,按空状态继续——提示词模板里的
* 条件块会自然走 else 分支,没必要为一次网络抖动拦住开会话。
@@ -415,21 +418,13 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
const terminalName = `issue-${opts.issueNumber}-审查`
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 /
// network blip) MUST NOT break the whole review trigger — fall through
// with existingSessionId = undefined and we'll spin up a fresh codex.
let existingSessionId: string | undefined
// 本机关联优先;未保存过时才从工单状态读取,或确认存活终端的启动记录。
const localState = getLocalIssueState(panel.context, panel.refFor(opts.issueNumber).source, opts.issueNumber)
let existingSessionId = nonEmptyString(localState.reviewSessionId)
try {
const remote = await detectRepo(opts.workspaceRoot)
if (remote) {
const token = await getToken(panel.context, remote.host)
if (token) {
const state = await panel.readIssueState(opts.issueNumber)
const sid = state?.reviewSessionId
if (typeof sid === 'string' && sid.length > 0)
existingSessionId = sid
}
if (localState.reviewSessionId === undefined) {
const state = await panel.readIssueState(opts.issueNumber)
existingSessionId = nonEmptyString(state.reviewSessionId)
}
}
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({
tabAlive: !!existing,
reviewSessionId: existingSessionId,
@@ -520,45 +518,43 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
const { themeColor, iconUri } = await panel.resolveIssueIcon(opts.issueNumber)
const settings = getSettings(panel.context)
// 先盯会话目录再拉起 codex,免得文件在盯上之前就建好了。拿到的 id 存成
// reviewSessionId:PR 再更新时靠它往这个窗口发「再审」,窗口关了也能接着聊。
// codex 启动可能拖好几分钟,一直盯到这个终端关掉为止。
const watchAbort = new AbortController()
const watcherPromise = watchForCodexSession({
baseDir: defaultCodexSessionsDir(),
const capture = await prepareReviewSessionCapture(panel, opts.issueNumber, {
cwd: effectiveCwd,
signal: watchAbort.signal,
startedAt: Date.now(),
prompt: opts.prompt,
})
// Interactive TUI (`codex` without `exec`/`review` subcommands): keeps
// the terminal alive after the run so users can follow up with codex,
// mirroring how claude implementation/brainstorm sessions stay open.
// 提示词模板自带 /review 前缀,不在这里再拼。
const terminal = window.createTerminal({
name: terminalName,
cwd: effectiveCwd,
location: panel.resolveTerminalLocation(false),
iconPath: iconUri,
color: themeColor,
...sessionLaunch(buildCodexArgv({
mode: 'interactive',
prompt: opts.prompt,
model: settings.codexModel,
reasoningEffort: settings.codexReasoningEffort,
})),
})
const closeListener = window.onDidCloseTerminal((closed) => {
if (closed === terminal)
watchAbort.abort()
})
void watcherPromise.finally(() => closeListener.dispose())
let terminal: Terminal
try {
terminal = window.createTerminal({
name: terminalName,
cwd: effectiveCwd,
location: panel.resolveTerminalLocation(false),
iconPath: iconUri,
color: themeColor,
...sessionLaunch(buildCodexArgv({
mode: 'interactive',
prompt: opts.prompt,
model: settings.codexModel,
reasoningEffort: settings.codexReasoningEffort,
})),
})
void capture.attach(terminal)
panel.trackSessionTerminal(terminal, opts.issueNumber, 'review')
terminal.show(false)
}
catch (err) {
await cancelReviewSessionCapture(panel, opts.issueNumber)
throw err
}
logger.add({
level: 'info',
source: 'terminal',
message: `已创建审查终端 "${terminalName}" cwd=${effectiveCwd},codex review 已启动`,
})
panel.trackSessionTerminal(terminal, opts.issueNumber, 'review')
terminal.show(false)
if (cwdFallback) {
panel.postMessage({
@@ -569,57 +565,6 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
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),
})
})
}
/**