From e066c2658329b82298604fec31113e754bb93450 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 16:28:44 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E7=A7=BB?= =?UTF-8?q?=E4=BA=A4=E5=9B=9E=E6=BB=9A=E9=A1=BA=E5=BA=8F=E3=80=81done=20?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E4=B8=8E=E6=97=A0=20branch=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph --- vscode/src/panel/handlers/handoff.test.ts | 34 ++++++++++- vscode/src/panel/handlers/handoff.ts | 26 +++++++- vscode/src/panel/handlers/handoffFlow.ts | 72 +++++++++++++++++------ 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts index 71bbc23..26f01b9 100644 --- a/vscode/src/panel/handlers/handoff.test.ts +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -5,6 +5,7 @@ import { checkWorktreePushed, handoffAcceptedStateExtra, handoffAcceptedUiPatch, + handoffLocalTombstones, handoffStartedStateExtra, handoffStartedUiPatch, } from './handoff' @@ -52,10 +53,14 @@ describe('checkWorktreePushed', () => { }) describe('payloads', () => { - it('移交落盘:两个共享字段 + 本机字段全部墓碑', () => { + it('移交落盘:只写两个共享字段,本机字段留到本机清理跑完后再墓碑', () => { expect(handoffStartedStateExtra(9, 'chw')).toEqual({ handoffAttachmentId: '9', handoffFrom: 'chw', + }) + }) + it('本机墓碑:keepWorktreePath=false 时九个本机字段全清', () => { + expect(handoffLocalTombstones({ keepWorktreePath: false })).toEqual({ sessionId: '', implementSessionId: '', reviewSessionId: '', @@ -67,6 +72,20 @@ describe('payloads', () => { prDiffFile: '', }) }) + it('本机墓碑:keepWorktreePath=true 时保留 worktreePath', () => { + const extra = handoffLocalTombstones({ keepWorktreePath: true }) + expect(extra.worktreePath).toBeUndefined() + expect(extra).toEqual({ + sessionId: '', + implementSessionId: '', + reviewSessionId: '', + testSessionId: '', + profilePath: '', + brainstormProfilePath: '', + testProfilePath: '', + prDiffFile: '', + }) + }) it('移交 UI patch 用 null 清字段并更新 assignees', () => { expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({ handoffAttachmentId: '9', @@ -108,4 +127,17 @@ describe('payloads', () => { worktreeExists: true, }) }) + it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => { + expect(handoffAcceptedUiPatch({ + sessions: { sessionId: 's' }, + profiles: {}, + worktreeExists: false, + })).toEqual({ + handoffAttachmentId: null, + handoffFrom: null, + sessionId: 's', + worktreePath: null, + worktreeExists: false, + }) + }) }) diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index 7b17713..043ee58 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -34,10 +34,28 @@ export function checkWorktreePushed(input: { return { ok: true } } +/** + * 共享墓碑:只写 handoffAttachmentId / handoffFrom 两个共享字段。本机 + * sid/profile/worktreePath 墓碑要等本机清理跑完(成功指派后)才落盘, + * 见 handoffLocalTombstones —— 否则④/⑤失败回滚时本机字段已经被清空, + * 用户会话恢复不了。 + */ export function handoffStartedStateExtra(attachmentId: number, from: string): Record { - const extra: Record = { handoffAttachmentId: String(attachmentId), handoffFrom: from } - for (const f of LOCAL_STATE_FIELDS) + return { handoffAttachmentId: String(attachmentId), handoffFrom: from } +} + +/** + * 本机墓碑:移交流程最后一步(本机清理跑完之后)才调用。worktree 删除失败时 + * 保留 worktreePath(keepWorktreePath),别把用户还留在磁盘上的 worktree + * 从 state 里抹掉。 + */ +export function handoffLocalTombstones(opts: { keepWorktreePath: boolean }): Record { + const extra: Record = {} + for (const f of LOCAL_STATE_FIELDS) { + if (f === 'worktreePath' && opts.keepWorktreePath) + continue extra[f] = '' + } return extra } @@ -89,7 +107,9 @@ export function handoffAcceptedUiPatch(local: { handoffFrom: null, ...definedEntries(local.sessions), ...definedEntries(local.profiles), - ...(local.worktreePath ? { worktreePath: local.worktreePath } : {}), + // 显式 null 而非省略:省略会让 webview 侧留着接管前的旧 worktreePath, + // 与 worktreeExists: false 对不上。 + worktreePath: local.worktreePath ?? null, worktreeExists: local.worktreeExists, } } diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 5a2f6da..4f579de 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -34,6 +34,7 @@ import { checkWorktreePushed, handoffAcceptedStateExtra, handoffAcceptedUiPatch, + handoffLocalTombstones, handoffStartedStateExtra, handoffStartedUiPatch, } from './handoff' @@ -99,8 +100,11 @@ function scratchDir(prefix: string): Promise { export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise { const ctx = await resolveRepoCtx(panel) - if (!ctx) + if (!ctx) { + // ctx 解析失败已经 toast 过原因;这里必须补一条空结果,否则弹窗的 spinner 转不停。 + panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] }) return + } try { const users = await listRepoAssignees(ctx) panel.postMessage({ @@ -117,16 +121,20 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: } /** - * 发送方:校验 worktree 已 push → 关终端 → 打包上传 → 写共享字段 → 重新指派 → - * 本机清理。远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动; - * 本机清理失败只告警,移交在远端已经生效。 + * 发送方:校验 worktree 已 push(worktree 存在但 state 缺 branch 直接拒绝)→ + * 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。 + * 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理 + * 与墓碑写入失败只告警,移交在远端已经生效。 */ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise { - const ctx = await resolveRepoCtx(panel) - if (!ctx) - return - const { workspaceRoot, host, owner, repo, token, me } = ctx + // finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。 const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) + const ctx = await resolveRepoCtx(panel) + if (!ctx) { + finish() + return + } + const { workspaceRoot, host, owner, repo, token, me } = ctx let state: Record try { @@ -151,9 +159,15 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: ...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}), } - // ① worktree 守卫:干净 + 与远端一致 + // ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时 + // 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。 const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined - if (worktreeAbs && fs.existsSync(worktreeAbs) && branch) { + if (worktreeAbs && fs.existsSync(worktreeAbs)) { + if (!branch) { + toast(panel, 'error', `无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`) + finish() + return + } const status = await git(worktreeAbs, ['status', '--porcelain']) const fetched = await git(worktreeAbs, ['fetch', 'origin', branch]) if (!status.ok || !fetched.ok) { @@ -230,16 +244,19 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: return } finally { - await fsp.rm(staging, { recursive: true, force: true }) - await fsp.rm(outDir, { recursive: true, force: true }) + // rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。 + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) + await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) } - // ④ 写共享字段 + 本机墓碑;⑤ 重新指派。失败回滚远端改动。 + // ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦); + // ⑤ 重新指派。失败回滚远端改动。 try { await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me)) } catch (err) { const message = err instanceof Error ? err.message : String(err) + // 回滚失败不能遮蔽原始错误:吞掉,走下面统一的失败 toast。 await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) dismiss(panel, spinner) toast(panel, 'error', `移交失败(写状态):${message}`) @@ -251,6 +268,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: } catch (err) { const message = err instanceof Error ? err.message : String(err) + // 回滚失败不能遮蔽原始错误:两次 catch 都吞掉,走下面统一的失败 toast。 await panel.mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' }).catch(() => {}) await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) dismiss(panel, spinner) @@ -259,7 +277,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: return } - // ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方) + // ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警—— + // 远端移交已经生效,这里不回滚。 + let worktreeRemovalFailed = false if (worktreeAbs && fs.existsSync(worktreeAbs)) { const settings = getSettings(panel.context) try { @@ -275,6 +295,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: await removeWorktreeDir(workspaceRoot, worktreeAbs) } catch (err) { + worktreeRemovalFailed = true const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:清理 worktree 失败`, details: message }) toast(panel, 'error', `已移交,但本机 worktree 清理失败:${message}`) @@ -285,6 +306,14 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settings.devBranch, autoBuildBranch: settings.autoBuildBranch }) } + // ⑦ 本机墓碑:sid/profile/worktreePath 归零。worktree 没删掉时保留 + // worktreePath,别把用户还留在磁盘上的 worktree 从 state 里抹掉。写墓碑 + // 失败只告警——远端移交已经生效,不回滚。 + await panel.mergeIssueState(issueNumber, handoffLocalTombstones({ keepWorktreePath: worktreeRemovalFailed })).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:写本机墓碑失败`, details: message }) + }) + dismiss(panel, spinner) panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to) }) toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`) @@ -296,11 +325,14 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: * 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。 */ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise { - const ctx = await resolveRepoCtx(panel) - if (!ctx) - return - const { workspaceRoot, host, owner, repo, token } = ctx + // finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。 const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) + const ctx = await resolveRepoCtx(panel) + if (!ctx) { + finish() + return + } + const { workspaceRoot, host, owner, repo, token } = ctx let state: Record try { @@ -381,7 +413,9 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber toast(panel, 'error', `接管失败:${message}`) } finally { - await fsp.rm(extracted, { recursive: true, force: true }) + // finish 先发:下面的 rm 失败不该让弹窗的 spinner 卡住不收。 finish() + // rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。 + await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {}) } }