🐛 fix(vscode): 恢复实施会话改按显式角色命名,worktree 清理后不再误标为规划

Claude-Session: https://claude.ai/code/session_012YFEAU7RsuCdinUNMS9tzv
This commit is contained in:
cruldra committed 2026-09-09 10:41:04 +08:00
1 parent 1fee0f2630
commit e8963ac2a2
11 files changed
+116 -61

No files matched your search

+1 -1
View File
@@ -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.123", "version": "0.2.124",
"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",
+1 -1
View File
@@ -395,7 +395,7 @@ export class KanbanWebviewPanel {
return return
} }
if (msg.type === 'session/resume') { if (msg.type === 'session/resume') {
void sessions.handleResumeSession(this, msg.sessionId, msg.profilePath, msg.cwd, msg.issueNumber) void sessions.handleResumeSession(this, msg.sessionId, msg.kind, msg.profilePath, msg.cwd, msg.issueNumber)
return return
} }
if (msg.type === 'session/focus') { if (msg.type === 'session/focus') {
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
const ROOT = '/repo'
const WT = '/repo-worktrees/feature-x'
describe('resumeTerminalName', () => {
it('实施会话 → issue-N-实施', () => {
expect(resumeTerminalName('implement', 'abcdef1234', 398)).toBe('issue-398-实施')
})
it('规划会话 → issue-N-规划', () => {
expect(resumeTerminalName('brainstorm', 'abcdef1234', 398)).toBe('issue-398-规划')
})
it('没有工单号 → 按会话 id 前 8 位命名', () => {
expect(resumeTerminalName('implement', 'abcdef1234', undefined)).toBe('Claude · abcdef12')
})
})
describe('planResumeCwd', () => {
it('规划会话永远在工作区根目录', () => {
expect(planResumeCwd({ kind: 'brainstorm', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: true }))
.toEqual({ cwd: ROOT, worktreeMissing: false })
})
it('实施会话 worktree 在 → cwd 用 worktree', () => {
expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: true }))
.toEqual({ cwd: WT, worktreeMissing: false })
})
it('实施会话 worktree 记了但磁盘上没了 → 退回根目录并标记缺失', () => {
expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: false }))
.toEqual({ cwd: ROOT, worktreeMissing: true })
})
it('实施会话工单里没记 worktreePath(PR 合并后清掉)→ 同样标记缺失,角色不变', () => {
expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: undefined, worktreeExists: false }))
.toEqual({ cwd: ROOT, worktreeMissing: true })
expect(resumeTerminalName('implement', 'abcdef1234', 398)).toBe('issue-398-实施')
})
})
+39
View File
@@ -0,0 +1,39 @@
/**
* 实施 / 规划会话 resume 的落点决策。纯函数,不碰 vscode API,便于单测。
*
* 角色由调用方明确传入,不从「有没有 cwd」反推:worktree 被清理后
* 工单里的 worktreePath 会清空,靠 cwd 推断会把实施会话误判成规划。
*/
export type ResumeKind = 'implement' | 'brainstorm'
export function resumeSessionRole(kind: ResumeKind): '实施' | '规划' {
return kind === 'implement' ? '实施' : '规划'
}
export function resumeTerminalName(kind: ResumeKind, sessionId: string, issueNumber?: number): string {
return issueNumber !== undefined
? `issue-${issueNumber}-${resumeSessionRole(kind)}`
: `Claude · ${sessionId.slice(0, 8)}`
}
export interface ResumeCwdInput {
kind: ResumeKind
workspaceRoot: string
/** 已解析成绝对路径的 worktree;工单没记就 undefined */
worktreeAbs?: string
worktreeExists: boolean
}
export interface ResumeCwdPlan {
cwd: string
/** 实施会话但 worktree 已不在磁盘上(含工单里没记 worktreePath 的情况) */
worktreeMissing: boolean
}
export function planResumeCwd(input: ResumeCwdInput): ResumeCwdPlan {
if (input.kind !== 'implement')
return { cwd: input.workspaceRoot, worktreeMissing: false }
if (input.worktreeAbs && input.worktreeExists)
return { cwd: input.worktreeAbs, worktreeMissing: false }
return { cwd: input.workspaceRoot, worktreeMissing: true }
}
+28 -50
View File
@@ -1,5 +1,6 @@
import type { Terminal } from 'vscode' import type { Terminal } from 'vscode'
import type { KanbanWebviewPanel } from '../KanbanPanel' import type { KanbanWebviewPanel } from '../KanbanPanel'
import type { ResumeKind } from './resumeTarget'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs' import { promises as fsp } from 'node:fs'
@@ -25,13 +26,10 @@ import { decideAutoReviewPath } from '../autoReviewDecision'
import { pickBrainstormContinuePath } from '../brainstormContinue' import { pickBrainstormContinuePath } from '../brainstormContinue'
import { makeNonce } from '../KanbanPanel' import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup' import { cleanupFeatureBranch } from './branchCleanup'
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
import { submitToSessionTerminal } from './terminals' import { submitToSessionTerminal } from './terminals'
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> { export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, sessionKind: ResumeKind, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
// resumeInFlight 锁 key)。relCwd 在则是实施 resume,否则视为规划/讨论。
const sessionKind: 'implement' | 'brainstorm' = relCwd ? 'implement' : 'brainstorm'
// In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s) // In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s)
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时 // 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
// (legacy session)跳过锁,直接走原路径。 // (legacy session)跳过锁,直接走原路径。
@@ -91,45 +89,25 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
return return
} }
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
// Implementation sessions live in a worktree; `relCwd` may be absolute / // 实施会话住在 worktree 里;relCwd 可能是绝对路径 / `~/...` / 工作区相对
// `~/...` / workspace-relative — must go through resolveWorktreePath // 路径,统一走 resolveWorktreePath。worktree 已被清理(典型:卡片拖到
// (path.join(workspaceRoot, abs) would mangle absolute paths). // 「完成」→ 自动 `git worktree remove`,工单里 worktreePath 随之清空)
// If the worktree has since been cleaned up (typical: drop card to // 时退回工作区根目录;角色只看 sessionKind,不受 cwd 有无影响。
// "完成" → auto `git worktree remove`), fall back to the workspace const worktreeAbs = relCwd && workspaceRoot ? resolveWorktreePath(relCwd, workspaceRoot) : undefined
// root instead of pointing the terminal at a missing directory. const plan = workspaceRoot
let effectiveCwd: string | undefined = workspaceRoot ? planResumeCwd({ kind: sessionKind, workspaceRoot, worktreeAbs, worktreeExists: !!worktreeAbs && fs.existsSync(worktreeAbs) })
let cwdFallback = false : undefined
if (relCwd && workspaceRoot) { const effectiveCwd = plan?.cwd
const resolved = resolveWorktreePath(relCwd, workspaceRoot) const worktreeMissing = plan?.worktreeMissing ?? false
if (fs.existsSync(resolved)) { if (worktreeMissing) {
effectiveCwd = resolved logger.add({
} level: 'warn',
else { source: 'panel',
cwdFallback = true message: `worktree 不存在,退回工作区根目录 (#${issueNumber ?? '?'}): ${relCwd ?? '(工单未记录 worktreePath)'}`,
effectiveCwd = workspaceRoot })
logger.add({
level: 'warn',
source: 'panel',
message: `worktree 不存在,退回工作区根目录 (#${issueNumber ?? '?'}): ${relCwd}`,
})
}
} }
// Open the terminal as an editor tab beside the kanban (not in the // 终端开成编辑区 tab(不在底部面板),和看板并排。
// bottom panel), so the user can see both side-by-side. `Beside` opens const terminalName = resumeTerminalName(sessionKind, sessionId, issueNumber)
// in a new editor group when needed.
// Name reflects which of the three session roles this is:
// relCwd present → 实施 session (resumed inside the worktree)
// relCwd absent → 规划 / discussion session (workspace root)
// Fall back to the legacy id-suffixed name if we somehow lack issueNumber.
const sessionRole = relCwd ? '实施' : '规划'
// 注:sessionKind 已在方法入口处判定(用于 resumeInFlight 锁 key)。
// 原来两条 trackSessionTerminal 路径都硬写 'brainstorm',导致实施 resume
// 后 terminalOrigin map 里 kind 错,onDidCloseTerminal 里的
// `kind === 'implement'` 判断永远不命中,impl-tab-post-close 钩子(关
// pycharm)不会执行。
const terminalName = issueNumber !== undefined
? `issue-${issueNumber}-${sessionRole}`
: `Claude · ${sessionId.slice(0, 8)}`
// Scan live terminals before creating a new one — survives panel // Scan live terminals before creating a new one — survives panel
// reload / webview rebuild where `this.terminals` got wiped but the // reload / webview rebuild where `this.terminals` got wiped but the
// tab is still open. Refresh the Map too so subsequent lookups (and // tab is still open. Refresh the Map too so subsequent lookups (and
@@ -142,12 +120,12 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
existingByName.show(false) existingByName.show(false)
return return
} }
// 实施 resume 走新建 terminal 路径时(且 worktree 还在)需触发 // 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create
// impl-tab-pre-create 钩子(启动 pycharm 等)。existingByName fast path // 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已
// 不调钩子——pycharm 应该已经在跑,重复启会冲突;cwdFallback=true 时 // 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开
// worktree 已清理,pycharm 不该指向 workspace root;legacy session // pycharm,指向工作区根目录会把主仓库窗口拉起来;legacy session
// (issueNumber === undefined) 钩子签名要 issueNumber,也跳过。 // (issueNumber === undefined) 钩子签名要 issueNumber,同样跳过。
if (sessionRole === '实施' && !cwdFallback && issueNumber !== undefined && workspaceRoot) { if (sessionKind === 'implement' && !worktreeMissing && issueNumber !== undefined && workspaceRoot) {
const settingsForHook = getSettings(panel.context) const settingsForHook = getSettings(panel.context)
// 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里 // 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里
// BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。 // BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。
@@ -205,7 +183,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
}) })
// Worktree 已清理时给用户一个明确提示,避免他们误以为 cc 在原 // Worktree 已清理时给用户一个明确提示,避免他们误以为 cc 在原
// worktree 路径里跑。toast 上 dismissOnTimer 5s 自动消失。 // worktree 路径里跑。toast 上 dismissOnTimer 5s 自动消失。
if (cwdFallback) { if (worktreeMissing) {
panel.postMessage({ panel.postMessage({
type: 'toast/show', type: 'toast/show',
id: makeNonce(), id: makeNonce(),
+1 -1
View File
@@ -180,7 +180,7 @@ export type WebviewToExtension
| { type: 'youtrack/import' } | { type: 'youtrack/import' }
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string } | { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
| { type: 'toast/open-url', url: string } | { type: 'toast/open-url', url: string }
| { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number } | { type: 'session/resume', sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number }
| { type: 'session/focus', issueNumber: number } | { type: 'session/focus', issueNumber: number }
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
+1 -1
View File
@@ -225,7 +225,7 @@ export function App() {
const profileForResume = isImpl const profileForResume = isImpl
? selectedIssue?.profilePath ? selectedIssue?.profilePath
: selectedIssue?.brainstormProfilePath : selectedIssue?.brainstormProfilePath
resumeSession(sid, profileForResume, cwd, selectedIssue?.number) resumeSession(sid, isImpl ? 'implement' : 'brainstorm', profileForResume, cwd, selectedIssue?.number)
} }
return return
} }
@@ -28,7 +28,7 @@ interface BottomTabsProps {
allIssues: Issue[] allIssues: Issue[]
globalAutoReview: boolean globalAutoReview: boolean
onOpenInBrowser: (url: string) => void onOpenInBrowser: (url: string) => void
onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
onStartTestSession: (issueNumber: number) => void onStartTestSession: (issueNumber: number) => void
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
@@ -35,7 +35,7 @@ interface IssueDetailPanelProps {
/** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */ /** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */
globalAutoReview: boolean globalAutoReview: boolean
onOpenInBrowser: (url: string) => void onOpenInBrowser: (url: string) => void
onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
/** /**
* Open a new terminal that runs `codex resume <id>` for the auto-review * Open a new terminal that runs `codex resume <id>` for the auto-review
* conversation associated with this issue. * conversation associated with this issue.
@@ -243,7 +243,7 @@ export function IssueDetailPanel({
return return
if (!throttleBySessionId(v)) if (!throttleBySessionId(v))
return return
onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number) onResumeSession(v, 'brainstorm', issue?.brainstormProfilePath, undefined, issue?.number)
}, },
secondaryActions, secondaryActions,
} }
@@ -265,7 +265,7 @@ export function IssueDetailPanel({
return return
if (!throttleBySessionId(v)) if (!throttleBySessionId(v))
return return
onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number) onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number)
}, },
secondaryActions: issue?.implementTabOpen secondaryActions: issue?.implementTabOpen
? [{ ? [{
+3 -2
View File
@@ -170,7 +170,7 @@ export interface UseIssuesResult {
createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string) => void createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string) => void
dismissToast: (id: string) => void dismissToast: (id: string) => void
openUrl: (url: string) => void openUrl: (url: string) => void
resumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void resumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
startTestSession: (issueNumber: number) => void startTestSession: (issueNumber: number) => void
resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
@@ -454,11 +454,12 @@ export function useIssues(): UseIssuesResult {
const resumeSession = useCallback(( const resumeSession = useCallback((
sessionId: string, sessionId: string,
kind: 'implement' | 'brainstorm',
profilePath?: string, profilePath?: string,
cwd?: string, cwd?: string,
issueNumber?: number, issueNumber?: number,
): void => { ): void => {
postMessage({ type: 'session/resume', sessionId, profilePath, cwd, issueNumber }) postMessage({ type: 'session/resume', sessionId, kind, profilePath, cwd, issueNumber })
}, []) }, [])
const focusSession = useCallback((issueNumber: number): void => { const focusSession = useCallback((issueNumber: number): void => {
+1 -1
View File
@@ -211,7 +211,7 @@ export type WebviewToExtension
| { type: 'youtrack/import' } | { type: 'youtrack/import' }
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string } | { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
| { type: 'toast/open-url', url: string } | { type: 'toast/open-url', url: string }
| { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number } | { type: 'session/resume', sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number }
| { type: 'session/focus', issueNumber: number } | { type: 'session/focus', issueNumber: number }
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }