11
This commit is contained in:
@@ -704,21 +704,9 @@ export class KanbanWebviewPanel {
|
||||
* review command. Used by the webhook auto-review flow so the user can watch
|
||||
* codex work in real time instead of it running headless in the background.
|
||||
*
|
||||
* Re-use semantics: if a tab named `issue-${N}-审查` already exists (live, not
|
||||
* exited), codex TUI is already running inside; we just `show` the tab and
|
||||
* send a short follow-up message ("PR 更新了,再审一下") as the next user
|
||||
* input so codex handles it as a new round, reusing the existing session
|
||||
* context. We deliberately do NOT re-run the full
|
||||
* `codex --dangerously-bypass-... '/review\n<prompt>'` startup command on
|
||||
* reuse (it would be received as a long redundant user message, wasting
|
||||
* tokens) and we do NOT restart the codex session watcher (reuse doesn't
|
||||
* write a new rollout-*.jsonl, so the watcher would just idle until
|
||||
* timeout). We do NOT track the terminal in `reviewTerminals` (that map is
|
||||
* keyed by codex thread_id, which we don't have on this path).
|
||||
*
|
||||
* Returns true if the command was successfully dispatched, false if a
|
||||
* pre-condition failed (single-quote in prompt — only checked on the
|
||||
* new-terminal path since reuse sends a fixed string).
|
||||
* 只有已持久化 reviewSessionId 时才注入 "PR 更新了,再审一下";tab 活着但
|
||||
* session 尚未落盘时 noop,避免首次审查被竞态污染。详见
|
||||
* `decideAutoReviewPath` / `sessions.triggerAutoReviewTab`。
|
||||
*/
|
||||
public async triggerAutoReviewTab(opts: {
|
||||
issueNumber: number
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decideAutoReviewPath } from './autoReviewDecision'
|
||||
|
||||
describe('decideAutoReviewPath', () => {
|
||||
it('tab 活着 + 有 session → reuse(真再审)', () => {
|
||||
expect(decideAutoReviewPath({ tabAlive: true, reviewSessionId: 'thr-1' })).toBe('reuse')
|
||||
})
|
||||
|
||||
it('tab 已关 + 有 session → resume', () => {
|
||||
expect(decideAutoReviewPath({ tabAlive: false, reviewSessionId: 'thr-1' })).toBe('resume')
|
||||
})
|
||||
|
||||
it('tab 活着 + 无 session → noop(首次审查进行中,禁止注入再审文案)', () => {
|
||||
expect(decideAutoReviewPath({ tabAlive: true, reviewSessionId: undefined })).toBe('noop')
|
||||
expect(decideAutoReviewPath({ tabAlive: true, reviewSessionId: '' })).toBe('noop')
|
||||
})
|
||||
|
||||
it('无 tab + 无 session → new(首次审查)', () => {
|
||||
expect(decideAutoReviewPath({ tabAlive: false, reviewSessionId: undefined })).toBe('new')
|
||||
expect(decideAutoReviewPath({ tabAlive: false, reviewSessionId: '' })).toBe('new')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 自动审查启动路径决策(纯逻辑,不碰 VS Code API)。
|
||||
*
|
||||
* 与 TUI 对齐:只有已持久化 `reviewSessionId` 才走「再审」;
|
||||
* 仅 tab 活着但还没有 session id,说明首次审查刚开、session 尚未落盘,
|
||||
* 再注入 "PR 更新了,再审一下" 会污染首轮 prompt(opened 后紧跟 synchronize 的常见竞态)。
|
||||
*/
|
||||
|
||||
export type AutoReviewPath = 'reuse' | 'resume' | 'new' | 'noop'
|
||||
|
||||
export function decideAutoReviewPath(opts: {
|
||||
tabAlive: boolean
|
||||
reviewSessionId: string | undefined
|
||||
}): AutoReviewPath {
|
||||
const hasSid = typeof opts.reviewSessionId === 'string' && opts.reviewSessionId.length > 0
|
||||
// ① 有 session + tab 活着 → 往现有 TUI 注入再审
|
||||
if (opts.tabAlive && hasSid)
|
||||
return 'reuse'
|
||||
// ② 有 session + tab 已关 → codex resume 后再注入
|
||||
if (!opts.tabAlive && hasSid)
|
||||
return 'resume'
|
||||
// ③ tab 活着但无 session → 首次审查进行中,跳过
|
||||
if (opts.tabAlive)
|
||||
return 'noop'
|
||||
// ④ 无 session、无 tab → 新建完整审查会话
|
||||
return 'new'
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { getSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||
|
||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
||||
@@ -312,21 +313,16 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
|
||||
* review command. Used by the webhook auto-review flow so the user can watch
|
||||
* codex work in real time instead of it running headless in the background.
|
||||
*
|
||||
* Re-use semantics: if a tab named `issue-${N}-审查` already exists (live, not
|
||||
* exited), codex TUI is already running inside; we just `show` the tab and
|
||||
* send a short follow-up message ("PR 更新了,再审一下") as the next user
|
||||
* input so codex handles it as a new round, reusing the existing session
|
||||
* context. We deliberately do NOT re-run the full
|
||||
* `codex --dangerously-bypass-... '/review\n<prompt>'` startup command on
|
||||
* reuse (it would be received as a long redundant user message, wasting
|
||||
* tokens) and we do NOT restart the codex session watcher (reuse doesn't
|
||||
* write a new rollout-*.jsonl, so the watcher would just idle until
|
||||
* timeout). We do NOT track the terminal in `reviewTerminals` (that map is
|
||||
* keyed by codex thread_id, which we don't have on this path).
|
||||
* 路径由 {@link decideAutoReviewPath} 决定(与 TUI 对齐):
|
||||
* - reuse: tab 活着且已有 reviewSessionId → 注入 "PR 更新了,再审一下"
|
||||
* - resume: tab 已关且已有 reviewSessionId → codex resume + 再审注入
|
||||
* - new: 无 session → 完整启动 codex review
|
||||
* - noop: tab 活着但 session 尚未落盘 → 跳过(避免 opened 后紧跟
|
||||
* synchronize 把首轮审查污染成再审文案)
|
||||
*
|
||||
* Returns true if the command was successfully dispatched, false if a
|
||||
* pre-condition failed (single-quote in prompt — only checked on the
|
||||
* new-terminal path since reuse sends a fixed string).
|
||||
* Returns true if the command was successfully dispatched (or intentionally
|
||||
* skipped as noop / already in-flight), false if a pre-condition failed
|
||||
* (single-quote in prompt — only checked on the new-terminal path).
|
||||
*/
|
||||
export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
||||
issueNumber: number
|
||||
@@ -335,6 +331,33 @@ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
||||
/** workspace-relative or absolute path; if missing/invalid we fall back to workspaceRoot. */
|
||||
worktreePath: string
|
||||
workspaceRoot: string
|
||||
}): Promise<boolean> {
|
||||
// 防 opened + synchronize 竞态:同 issue 的自动审查启动中不重入。
|
||||
const lockKey = `${opts.issueNumber}:auto-review`
|
||||
if (panel.resumeInFlight.has(lockKey)) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'review',
|
||||
message: `自动审查 #${opts.issueNumber} 已在启动中,忽略重入`,
|
||||
})
|
||||
return true
|
||||
}
|
||||
panel.resumeInFlight.add(lockKey)
|
||||
|
||||
try {
|
||||
return await triggerAutoReviewTabUnlocked(panel, opts)
|
||||
}
|
||||
finally {
|
||||
panel.resumeInFlight.delete(lockKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
||||
issueNumber: number
|
||||
prNumber: string
|
||||
prompt: string
|
||||
worktreePath: string
|
||||
workspaceRoot: string
|
||||
}): Promise<boolean> {
|
||||
// Resolve cwd. If worktreePath is provided but doesn't exist on disk
|
||||
// (worktree was cleaned up after merge), fall back to workspaceRoot and
|
||||
@@ -352,7 +375,6 @@ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
||||
|
||||
const terminalName = `issue-${opts.issueNumber}-审查`
|
||||
const existing = panel.findExistingTerminal(terminalName)
|
||||
const isReuse = !!existing
|
||||
|
||||
// Pre-fetch the persisted reviewSessionId from issue state JSON so we
|
||||
// can pick the right branch below. Any failure (no remote / no token /
|
||||
@@ -380,11 +402,27 @@ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
||||
})
|
||||
}
|
||||
|
||||
// Three-branch decision matrix:
|
||||
// tab alive / sid present or not -> reuse: inject "再审" into the live TUI
|
||||
// tab closed / sid present -> resume: codex resume <sid> + inject "再审"
|
||||
// tab closed / sid absent -> new: spin up fresh codex review session
|
||||
if (isReuse) {
|
||||
const reviewPath = decideAutoReviewPath({
|
||||
tabAlive: !!existing,
|
||||
reviewSessionId: existingSessionId,
|
||||
})
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'review',
|
||||
message: `自动审查路径 #${opts.issueNumber} path=${reviewPath} tabAlive=${!!existing} hasSid=${!!existingSessionId}`,
|
||||
})
|
||||
|
||||
if (reviewPath === 'noop') {
|
||||
// 首次审查 tab 已开、session 尚未写入 state JSON(典型:opened 后
|
||||
// 立刻 synchronize)。禁止注入再审文案,交给已在跑的首轮审查完成。
|
||||
if (existing) {
|
||||
panel.trackSessionTerminal(existing, opts.issueNumber, 'review')
|
||||
existing.show(false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (reviewPath === 'reuse') {
|
||||
const terminal = existing!
|
||||
panel.trackSessionTerminal(terminal, opts.issueNumber, 'review')
|
||||
terminal.show(false)
|
||||
@@ -407,7 +445,7 @@ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
||||
return true
|
||||
}
|
||||
|
||||
if (existingSessionId) {
|
||||
if (reviewPath === 'resume' && existingSessionId) {
|
||||
// --- resume path: tab was closed but we have a persisted thread_id,
|
||||
// so reuse the codex conversation instead of dropping its context.
|
||||
// Mirrors handleResumeReviewSession's terminal-creation pipeline
|
||||
|
||||
Reference in New Issue
Block a user