11
This commit is contained in:
+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.70",
|
"version": "0.2.71",
|
||||||
"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",
|
||||||
|
|||||||
@@ -704,21 +704,9 @@ export class KanbanWebviewPanel {
|
|||||||
* review command. Used by the webhook auto-review flow so the user can watch
|
* 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.
|
* 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
|
* 只有已持久化 reviewSessionId 时才注入 "PR 更新了,再审一下";tab 活着但
|
||||||
* exited), codex TUI is already running inside; we just `show` the tab and
|
* session 尚未落盘时 noop,避免首次审查被竞态污染。详见
|
||||||
* send a short follow-up message ("PR 更新了,再审一下") as the next user
|
* `decideAutoReviewPath` / `sessions.triggerAutoReviewTab`。
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
public async triggerAutoReviewTab(opts: {
|
public async triggerAutoReviewTab(opts: {
|
||||||
issueNumber: number
|
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 { webhookCoordinator } from '../../webhook/coordinator'
|
||||||
import { resolveProfilePath } from '../../cc/profiles'
|
import { resolveProfilePath } from '../../cc/profiles'
|
||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
|
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||||
|
|
||||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||||
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
// 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
|
* 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.
|
* 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
|
* 路径由 {@link decideAutoReviewPath} 决定(与 TUI 对齐):
|
||||||
* exited), codex TUI is already running inside; we just `show` the tab and
|
* - reuse: tab 活着且已有 reviewSessionId → 注入 "PR 更新了,再审一下"
|
||||||
* send a short follow-up message ("PR 更新了,再审一下") as the next user
|
* - resume: tab 已关且已有 reviewSessionId → codex resume + 再审注入
|
||||||
* input so codex handles it as a new round, reusing the existing session
|
* - new: 无 session → 完整启动 codex review
|
||||||
* context. We deliberately do NOT re-run the full
|
* - noop: tab 活着但 session 尚未落盘 → 跳过(避免 opened 后紧跟
|
||||||
* `codex --dangerously-bypass-... '/review\n<prompt>'` startup command on
|
* synchronize 把首轮审查污染成再审文案)
|
||||||
* 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
|
* Returns true if the command was successfully dispatched (or intentionally
|
||||||
* pre-condition failed (single-quote in prompt — only checked on the
|
* skipped as noop / already in-flight), false if a pre-condition failed
|
||||||
* new-terminal path since reuse sends a fixed string).
|
* (single-quote in prompt — only checked on the new-terminal path).
|
||||||
*/
|
*/
|
||||||
export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
||||||
issueNumber: number
|
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. */
|
/** workspace-relative or absolute path; if missing/invalid we fall back to workspaceRoot. */
|
||||||
worktreePath: string
|
worktreePath: string
|
||||||
workspaceRoot: 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> {
|
}): Promise<boolean> {
|
||||||
// Resolve cwd. If worktreePath is provided but doesn't exist on disk
|
// Resolve cwd. If worktreePath is provided but doesn't exist on disk
|
||||||
// (worktree was cleaned up after merge), fall back to workspaceRoot and
|
// (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 terminalName = `issue-${opts.issueNumber}-审查`
|
||||||
const existing = panel.findExistingTerminal(terminalName)
|
const existing = panel.findExistingTerminal(terminalName)
|
||||||
const isReuse = !!existing
|
|
||||||
|
|
||||||
// Pre-fetch the persisted reviewSessionId from issue state JSON so we
|
// Pre-fetch the persisted reviewSessionId from issue state JSON so we
|
||||||
// can pick the right branch below. Any failure (no remote / no token /
|
// 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:
|
const reviewPath = decideAutoReviewPath({
|
||||||
// tab alive / sid present or not -> reuse: inject "再审" into the live TUI
|
tabAlive: !!existing,
|
||||||
// tab closed / sid present -> resume: codex resume <sid> + inject "再审"
|
reviewSessionId: existingSessionId,
|
||||||
// tab closed / sid absent -> new: spin up fresh codex review session
|
})
|
||||||
if (isReuse) {
|
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!
|
const terminal = existing!
|
||||||
panel.trackSessionTerminal(terminal, opts.issueNumber, 'review')
|
panel.trackSessionTerminal(terminal, opts.issueNumber, 'review')
|
||||||
terminal.show(false)
|
terminal.show(false)
|
||||||
@@ -407,7 +445,7 @@ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingSessionId) {
|
if (reviewPath === 'resume' && existingSessionId) {
|
||||||
// --- resume path: tab was closed but we have a persisted thread_id,
|
// --- resume path: tab was closed but we have a persisted thread_id,
|
||||||
// so reuse the codex conversation instead of dropping its context.
|
// so reuse the codex conversation instead of dropping its context.
|
||||||
// Mirrors handleResumeReviewSession's terminal-creation pipeline
|
// Mirrors handleResumeReviewSession's terminal-creation pipeline
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ class WebhookCoordinator {
|
|||||||
private activePanel: KanbanWebviewPanel | undefined
|
private activePanel: KanbanWebviewPanel | undefined
|
||||||
private initialized = false
|
private initialized = false
|
||||||
private eventSubscription?: { dispose: () => void }
|
private eventSubscription?: { dispose: () => void }
|
||||||
|
/** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */
|
||||||
|
private reviewInFlight = new Set<number>()
|
||||||
|
|
||||||
/** Called once from extension.ts activate(). Idempotent. */
|
/** Called once from extension.ts activate(). Idempotent. */
|
||||||
init(ctx: ExtensionContext): void {
|
init(ctx: ExtensionContext): void {
|
||||||
@@ -1269,6 +1271,31 @@ class WebhookCoordinator {
|
|||||||
if (!this.ctx)
|
if (!this.ctx)
|
||||||
return
|
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',
|
// Read current column from state JSON; if it's still 'in-progress',
|
||||||
// auto-advance to 'review' so the kanban reflects "审查中" status. Skip if
|
// auto-advance to 'review' so the kanban reflects "审查中" status. Skip if
|
||||||
// already in review/done to avoid bouncing the card around.
|
// already in review/done to avoid bouncing the card around.
|
||||||
|
|||||||
Reference in New Issue
Block a user