From 0d79cb7c732dd190a807d1ab38c3e3a777f133b6 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 31 Jul 2026 08:20:42 +0800 Subject: [PATCH] 11 --- vscode/package.json | 2 +- vscode/src/panel/KanbanPanel.ts | 18 +---- vscode/src/panel/autoReviewDecision.test.ts | 22 ++++++ vscode/src/panel/autoReviewDecision.ts | 27 +++++++ vscode/src/panel/handlers/sessions.ts | 80 +++++++++++++++------ vscode/src/webhook/coordinator.ts | 27 +++++++ 6 files changed, 139 insertions(+), 37 deletions(-) create mode 100644 vscode/src/panel/autoReviewDecision.test.ts create mode 100644 vscode/src/panel/autoReviewDecision.ts diff --git a/vscode/package.json b/vscode/package.json index 401b0fa..c9e3213 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.70", + "version": "0.2.71", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 3cdb9e0..0b4b77d 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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'` 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 diff --git a/vscode/src/panel/autoReviewDecision.test.ts b/vscode/src/panel/autoReviewDecision.test.ts new file mode 100644 index 0000000..1ff3cc4 --- /dev/null +++ b/vscode/src/panel/autoReviewDecision.test.ts @@ -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') + }) +}) diff --git a/vscode/src/panel/autoReviewDecision.ts b/vscode/src/panel/autoReviewDecision.ts new file mode 100644 index 0000000..dedd485 --- /dev/null +++ b/vscode/src/panel/autoReviewDecision.ts @@ -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' +} diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 12953ee..d0ba0ef 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -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 { // 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'` 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 { + // 防 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 { // 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 + 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 diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index c086a63..0a25e0b 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -53,6 +53,8 @@ class WebhookCoordinator { private activePanel: KanbanWebviewPanel | undefined private initialized = false private eventSubscription?: { dispose: () => void } + /** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */ + private reviewInFlight = new Set() /** 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 { + 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.