🐛 fix(vscode): 移交回滚顺序、done 信号与无 branch 守卫
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
checkWorktreePushed,
|
checkWorktreePushed,
|
||||||
handoffAcceptedStateExtra,
|
handoffAcceptedStateExtra,
|
||||||
handoffAcceptedUiPatch,
|
handoffAcceptedUiPatch,
|
||||||
|
handoffLocalTombstones,
|
||||||
handoffStartedStateExtra,
|
handoffStartedStateExtra,
|
||||||
handoffStartedUiPatch,
|
handoffStartedUiPatch,
|
||||||
} from './handoff'
|
} from './handoff'
|
||||||
@@ -52,10 +53,14 @@ describe('checkWorktreePushed', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('payloads', () => {
|
describe('payloads', () => {
|
||||||
it('移交落盘:两个共享字段 + 本机字段全部墓碑', () => {
|
it('移交落盘:只写两个共享字段,本机字段留到本机清理跑完后再墓碑', () => {
|
||||||
expect(handoffStartedStateExtra(9, 'chw')).toEqual({
|
expect(handoffStartedStateExtra(9, 'chw')).toEqual({
|
||||||
handoffAttachmentId: '9',
|
handoffAttachmentId: '9',
|
||||||
handoffFrom: 'chw',
|
handoffFrom: 'chw',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('本机墓碑:keepWorktreePath=false 时九个本机字段全清', () => {
|
||||||
|
expect(handoffLocalTombstones({ keepWorktreePath: false })).toEqual({
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
implementSessionId: '',
|
implementSessionId: '',
|
||||||
reviewSessionId: '',
|
reviewSessionId: '',
|
||||||
@@ -67,6 +72,20 @@ describe('payloads', () => {
|
|||||||
prDiffFile: '',
|
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', () => {
|
it('移交 UI patch 用 null 清字段并更新 assignees', () => {
|
||||||
expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({
|
expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({
|
||||||
handoffAttachmentId: '9',
|
handoffAttachmentId: '9',
|
||||||
@@ -108,4 +127,17 @@ describe('payloads', () => {
|
|||||||
worktreeExists: true,
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,10 +34,28 @@ export function checkWorktreePushed(input: {
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 共享墓碑:只写 handoffAttachmentId / handoffFrom 两个共享字段。本机
|
||||||
|
* sid/profile/worktreePath 墓碑要等本机清理跑完(成功指派后)才落盘,
|
||||||
|
* 见 handoffLocalTombstones —— 否则④/⑤失败回滚时本机字段已经被清空,
|
||||||
|
* 用户会话恢复不了。
|
||||||
|
*/
|
||||||
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown> {
|
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown> {
|
||||||
const extra: Record<string, unknown> = { handoffAttachmentId: String(attachmentId), handoffFrom: from }
|
return { handoffAttachmentId: String(attachmentId), handoffFrom: from }
|
||||||
for (const f of LOCAL_STATE_FIELDS)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本机墓碑:移交流程最后一步(本机清理跑完之后)才调用。worktree 删除失败时
|
||||||
|
* 保留 worktreePath(keepWorktreePath),别把用户还留在磁盘上的 worktree
|
||||||
|
* 从 state 里抹掉。
|
||||||
|
*/
|
||||||
|
export function handoffLocalTombstones(opts: { keepWorktreePath: boolean }): Record<string, string> {
|
||||||
|
const extra: Record<string, string> = {}
|
||||||
|
for (const f of LOCAL_STATE_FIELDS) {
|
||||||
|
if (f === 'worktreePath' && opts.keepWorktreePath)
|
||||||
|
continue
|
||||||
extra[f] = ''
|
extra[f] = ''
|
||||||
|
}
|
||||||
return extra
|
return extra
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +107,9 @@ export function handoffAcceptedUiPatch(local: {
|
|||||||
handoffFrom: null,
|
handoffFrom: null,
|
||||||
...definedEntries(local.sessions),
|
...definedEntries(local.sessions),
|
||||||
...definedEntries(local.profiles),
|
...definedEntries(local.profiles),
|
||||||
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
|
// 显式 null 而非省略:省略会让 webview 侧留着接管前的旧 worktreePath,
|
||||||
|
// 与 worktreeExists: false 对不上。
|
||||||
|
worktreePath: local.worktreePath ?? null,
|
||||||
worktreeExists: local.worktreeExists,
|
worktreeExists: local.worktreeExists,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
checkWorktreePushed,
|
checkWorktreePushed,
|
||||||
handoffAcceptedStateExtra,
|
handoffAcceptedStateExtra,
|
||||||
handoffAcceptedUiPatch,
|
handoffAcceptedUiPatch,
|
||||||
|
handoffLocalTombstones,
|
||||||
handoffStartedStateExtra,
|
handoffStartedStateExtra,
|
||||||
handoffStartedUiPatch,
|
handoffStartedUiPatch,
|
||||||
} from './handoff'
|
} from './handoff'
|
||||||
@@ -99,8 +100,11 @@ function scratchDir(prefix: string): Promise<string> {
|
|||||||
|
|
||||||
export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
const ctx = await resolveRepoCtx(panel)
|
const ctx = await resolveRepoCtx(panel)
|
||||||
if (!ctx)
|
if (!ctx) {
|
||||||
|
// ctx 解析失败已经 toast 过原因;这里必须补一条空结果,否则弹窗的 spinner 转不停。
|
||||||
|
panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] })
|
||||||
return
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const users = await listRepoAssignees(ctx)
|
const users = await listRepoAssignees(ctx)
|
||||||
panel.postMessage({
|
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<void> {
|
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
|
||||||
const ctx = await resolveRepoCtx(panel)
|
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
|
||||||
if (!ctx)
|
|
||||||
return
|
|
||||||
const { workspaceRoot, host, owner, repo, token, me } = ctx
|
|
||||||
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
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<string, unknown>
|
let state: Record<string, unknown>
|
||||||
try {
|
try {
|
||||||
@@ -151,9 +159,15 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
|
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ① worktree 守卫:干净 + 与远端一致
|
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
|
||||||
|
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
|
||||||
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
|
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 status = await git(worktreeAbs, ['status', '--porcelain'])
|
||||||
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
|
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
|
||||||
if (!status.ok || !fetched.ok) {
|
if (!status.ok || !fetched.ok) {
|
||||||
@@ -230,16 +244,19 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
await fsp.rm(staging, { recursive: true, force: true })
|
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
|
||||||
await fsp.rm(outDir, { recursive: true, force: true })
|
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
|
||||||
|
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ④ 写共享字段 + 本机墓碑;⑤ 重新指派。失败回滚远端改动。
|
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦);
|
||||||
|
// ⑤ 重新指派。失败回滚远端改动。
|
||||||
try {
|
try {
|
||||||
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
|
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
// 回滚失败不能遮蔽原始错误:吞掉,走下面统一的失败 toast。
|
||||||
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
|
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
|
||||||
dismiss(panel, spinner)
|
dismiss(panel, spinner)
|
||||||
toast(panel, 'error', `移交失败(写状态):${message}`)
|
toast(panel, 'error', `移交失败(写状态):${message}`)
|
||||||
@@ -251,6 +268,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
// 回滚失败不能遮蔽原始错误:两次 catch 都吞掉,走下面统一的失败 toast。
|
||||||
await panel.mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' }).catch(() => {})
|
await panel.mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' }).catch(() => {})
|
||||||
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
|
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
|
||||||
dismiss(panel, spinner)
|
dismiss(panel, spinner)
|
||||||
@@ -259,7 +277,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)
|
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
|
||||||
|
// 远端移交已经生效,这里不回滚。
|
||||||
|
let worktreeRemovalFailed = false
|
||||||
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
||||||
const settings = getSettings(panel.context)
|
const settings = getSettings(panel.context)
|
||||||
try {
|
try {
|
||||||
@@ -275,6 +295,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
await removeWorktreeDir(workspaceRoot, worktreeAbs)
|
await removeWorktreeDir(workspaceRoot, worktreeAbs)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
worktreeRemovalFailed = true
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:清理 worktree 失败`, details: message })
|
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:清理 worktree 失败`, details: message })
|
||||||
toast(panel, 'error', `已移交,但本机 worktree 清理失败:${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 })
|
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)
|
dismiss(panel, spinner)
|
||||||
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to) })
|
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to) })
|
||||||
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
|
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<void> {
|
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
const ctx = await resolveRepoCtx(panel)
|
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
|
||||||
if (!ctx)
|
|
||||||
return
|
|
||||||
const { workspaceRoot, host, owner, repo, token } = ctx
|
|
||||||
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
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<string, unknown>
|
let state: Record<string, unknown>
|
||||||
try {
|
try {
|
||||||
@@ -381,7 +413,9 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
|||||||
toast(panel, 'error', `接管失败:${message}`)
|
toast(panel, 'error', `接管失败:${message}`)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
await fsp.rm(extracted, { recursive: true, force: true })
|
// finish 先发:下面的 rm 失败不该让弹窗的 spinner 卡住不收。
|
||||||
finish()
|
finish()
|
||||||
|
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
|
||||||
|
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user