This commit is contained in:
2026-07-31 08:20:42 +08:00
parent 0d078e848c
commit 0d79cb7c73
6 changed files with 139 additions and 37 deletions
+3 -15
View File
@@ -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')
})
})
+27
View File
@@ -0,0 +1,27 @@
/**
* 自动审查启动路径决策(纯逻辑,不碰 VS Code API)。
*
* 与 TUI 对齐:只有已持久化 `reviewSessionId` 才走「再审」;
* 仅 tab 活着但还没有 session id,说明首次审查刚开、session 尚未落盘,
* 再注入 "PR 更新了,再审一下" 会污染首轮 promptopened 后紧跟 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'
}
+59 -21
View File
@@ -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
+27
View File
@@ -53,6 +53,8 @@ class WebhookCoordinator {
private activePanel: KanbanWebviewPanel | undefined
private initialized = false
private eventSubscription?: { dispose: () => void }
/** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */
private reviewInFlight = new Set<number>()
/** Called once from extension.ts activate(). Idempotent. */
init(ctx: ExtensionContext): void {
@@ -1269,6 +1271,31 @@ class WebhookCoordinator {
if (!this.ctx)
return
if (this.reviewInFlight.has(issueNumber)) {
logger.add({
level: 'info',
source: 'webhook',
message: `自动审查 #${issueNumber} 已在进行中,忽略并发触发`,
})
return
}
this.reviewInFlight.add(issueNumber)
try {
await this.triggerReviewBody(issueNumber, prNumber, ctx)
}
finally {
this.reviewInFlight.delete(issueNumber)
}
}
private async triggerReviewBody(
issueNumber: number,
prNumber: string,
ctx: { host: string, owner: string, repo: string, token: string },
): Promise<void> {
if (!this.ctx)
return
// Read current column from state JSON; if it's still 'in-progress',
// auto-advance to 'review' so the kanban reflects "审查中" status. Skip if
// already in review/done to avoid bouncing the card around.