🐛 fix(vscode): 移交终审修正:schema 墓碑、顶层兜底、前置复查、分支保护、清单校验
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
|
||||
import type { IssueColumn } from '../../gitea/types'
|
||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||
import { execFile } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
@@ -19,6 +20,7 @@ import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '.
|
||||
import {
|
||||
deleteIssueAttachment,
|
||||
downloadAttachment,
|
||||
getIssue,
|
||||
getIssueAttachment,
|
||||
listIssueAttachments,
|
||||
listRepoAssignees,
|
||||
@@ -31,6 +33,8 @@ import { getSettings } from '../../settings/store'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
import {
|
||||
canAcceptHandoff,
|
||||
canStartHandoff,
|
||||
checkWorktreePushed,
|
||||
handoffAcceptedStateExtra,
|
||||
handoffAcceptedUiPatch,
|
||||
@@ -121,12 +125,242 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送方:校验 worktree 已 push(worktree 存在但 state 缺 branch 直接拒绝)→
|
||||
* 发送方:服务端复查 canStartHandoff(webview 按钮的显示条件可能过期)→
|
||||
* 校验 worktree 已 push(worktree 存在但 state 缺 branch 直接拒绝;没有
|
||||
* worktree 时改查本地分支是否已 push,避免 ⑥ 误删未推送的分支)→
|
||||
* 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。
|
||||
* 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理
|
||||
* 与墓碑写入失败只告警,移交在远端已经生效。
|
||||
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
|
||||
* 未预期异常并保证 `handoff/done` 一定发出去。
|
||||
*/
|
||||
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
|
||||
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
||||
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
|
||||
let spinner: string | undefined
|
||||
try {
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx)
|
||||
return
|
||||
const { workspaceRoot, host, owner, repo, token, me } = ctx
|
||||
|
||||
let state: Record<string, unknown>
|
||||
try {
|
||||
state = await panel.readIssueState(issueNumber)
|
||||
}
|
||||
catch (err) {
|
||||
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到
|
||||
// 完成),用刚读到的最新 state 再判一次 canStartHandoff,避免重复移交。
|
||||
const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
|
||||
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
|
||||
if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId) })) {
|
||||
toast(panel, 'error', `#${issueNumber} 已在移交中或已完成,不能再次移交`)
|
||||
return
|
||||
}
|
||||
|
||||
const branch = str(state.branch)
|
||||
const worktreePath = str(state.worktreePath)
|
||||
const sessions: HandoffSessions = {
|
||||
...(str(state.sessionId) ? { sessionId: str(state.sessionId) } : {}),
|
||||
...(str(state.implementSessionId) ? { implementSessionId: str(state.implementSessionId) } : {}),
|
||||
...(str(state.testSessionId) ? { testSessionId: str(state.testSessionId) } : {}),
|
||||
...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}),
|
||||
}
|
||||
const profiles: HandoffProfiles = {
|
||||
...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}),
|
||||
...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}),
|
||||
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
|
||||
}
|
||||
|
||||
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
|
||||
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
|
||||
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
|
||||
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
|
||||
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
|
||||
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
||||
if (!branch) {
|
||||
toast(panel, 'error', `无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`)
|
||||
return
|
||||
}
|
||||
const status = await git(worktreeAbs, ['status', '--porcelain'])
|
||||
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
|
||||
if (!status.ok || !fetched.ok) {
|
||||
toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
|
||||
return
|
||||
}
|
||||
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
|
||||
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
|
||||
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
|
||||
if (!check.ok) {
|
||||
toast(panel, 'error', `无法移交 #${issueNumber}:${check.reason}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (branch) {
|
||||
const localRef = await git(workspaceRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
|
||||
// 本地没有这个分支就没什么可丢的,直接放行。
|
||||
if (localRef.ok) {
|
||||
const fetched = await git(workspaceRoot, ['fetch', 'origin', branch])
|
||||
const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`])
|
||||
const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`])
|
||||
if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) {
|
||||
toast(panel, 'error', `无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spinner = toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||
|
||||
// ② 关终端,等 jsonl 刷盘
|
||||
let disposedAny = false
|
||||
for (const [terminal, origin] of panel.terminalOrigin) {
|
||||
if (origin.issueNumber === issueNumber) {
|
||||
try {
|
||||
terminal.dispose()
|
||||
disposedAny = true
|
||||
}
|
||||
// 终端已经死了也无所谓,目的只是确保不再有活跃会话
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
for (const t of window.terminals) {
|
||||
if (t.name === `issue-${issueNumber}-冲突解决`) {
|
||||
try {
|
||||
t.dispose()
|
||||
disposedAny = true
|
||||
}
|
||||
// 同上:冲突解决终端不进 terminalOrigin,按名字关,关不掉不影响移交
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
if (disposedAny)
|
||||
await new Promise<void>(r => setTimeout(r, 600))
|
||||
|
||||
// ③ 打包 + 上传
|
||||
let attachmentId: number
|
||||
let manifest: HandoffManifest
|
||||
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
|
||||
const staging = await scratchDir('spx-handoff-')
|
||||
const outDir = await scratchDir('spx-handoff-out-')
|
||||
try {
|
||||
manifest = await stageHandoffBundle(
|
||||
{ issueNumber, from: me, branch, sessions, profiles },
|
||||
staging,
|
||||
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
|
||||
)
|
||||
const name = handoffAttachmentName(issueNumber)
|
||||
const archive = path.join(outDir, name)
|
||||
await packHandoff(staging, archive)
|
||||
for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) {
|
||||
if (old.name === name)
|
||||
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id })
|
||||
}
|
||||
const uploaded = await uploadIssueAttachment({ host, token, owner, repo, index: issueNumber, name, data: await fsp.readFile(archive) })
|
||||
attachmentId = uploaded.id
|
||||
logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) })
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
|
||||
dismiss(panel, spinner)
|
||||
toast(panel, 'error', `移交失败(打包/上传):${message}`)
|
||||
return
|
||||
}
|
||||
finally {
|
||||
// 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}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] })
|
||||
}
|
||||
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)
|
||||
toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
|
||||
return
|
||||
}
|
||||
|
||||
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
|
||||
// 远端移交已经生效,这里不回滚。
|
||||
let worktreeRemovalFailed = false
|
||||
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
||||
const settings = getSettings(panel.context)
|
||||
try {
|
||||
await panel.dispatchWorktreeHook('pre-remove', {
|
||||
workspaceRoot,
|
||||
worktreePath: worktreeAbs,
|
||||
branch: branch ?? '',
|
||||
issueNumber,
|
||||
mainBranch: settings.devBranch || 'main',
|
||||
customScriptPath: settings.worktreePreRemoveScript,
|
||||
})
|
||||
killProcessesUsingWorktree(worktreeAbs)
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
if (branch) {
|
||||
const settings = getSettings(panel.context)
|
||||
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, { keepWorktreePath: worktreeRemovalFailed }) })
|
||||
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:未预期错误`, details: message })
|
||||
toast(panel, 'error', `移交失败:${message}`)
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
}
|
||||
finally {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接管方:服务端复查 canAcceptHandoff(webview 按钮的显示条件可能过期)→
|
||||
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
|
||||
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
|
||||
*/
|
||||
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
|
||||
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
@@ -136,204 +370,6 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
||||
}
|
||||
const { workspaceRoot, host, owner, repo, token, me } = ctx
|
||||
|
||||
let state: Record<string, unknown>
|
||||
try {
|
||||
state = await panel.readIssueState(issueNumber)
|
||||
}
|
||||
catch (err) {
|
||||
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const branch = str(state.branch)
|
||||
const worktreePath = str(state.worktreePath)
|
||||
const sessions: HandoffSessions = {
|
||||
...(str(state.sessionId) ? { sessionId: str(state.sessionId) } : {}),
|
||||
...(str(state.implementSessionId) ? { implementSessionId: str(state.implementSessionId) } : {}),
|
||||
...(str(state.testSessionId) ? { testSessionId: str(state.testSessionId) } : {}),
|
||||
...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}),
|
||||
}
|
||||
const profiles: HandoffProfiles = {
|
||||
...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}),
|
||||
...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}),
|
||||
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
|
||||
}
|
||||
|
||||
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
|
||||
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
|
||||
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
|
||||
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) {
|
||||
toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
|
||||
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
|
||||
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
|
||||
if (!check.ok) {
|
||||
toast(panel, 'error', `无法移交 #${issueNumber}:${check.reason}`)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||
|
||||
// ② 关终端,等 jsonl 刷盘
|
||||
let disposedAny = false
|
||||
for (const [terminal, origin] of panel.terminalOrigin) {
|
||||
if (origin.issueNumber === issueNumber) {
|
||||
try {
|
||||
terminal.dispose()
|
||||
disposedAny = true
|
||||
}
|
||||
// 终端已经死了也无所谓,目的只是确保不再有活跃会话
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
for (const t of window.terminals) {
|
||||
if (t.name === `issue-${issueNumber}-冲突解决`) {
|
||||
try {
|
||||
t.dispose()
|
||||
disposedAny = true
|
||||
}
|
||||
// 同上:冲突解决终端不进 terminalOrigin,按名字关,关不掉不影响移交
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
if (disposedAny)
|
||||
await new Promise<void>(r => setTimeout(r, 600))
|
||||
|
||||
// ③ 打包 + 上传
|
||||
let attachmentId: number
|
||||
let manifest: HandoffManifest
|
||||
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
|
||||
const staging = await scratchDir('spx-handoff-')
|
||||
const outDir = await scratchDir('spx-handoff-out-')
|
||||
try {
|
||||
manifest = await stageHandoffBundle(
|
||||
{ issueNumber, from: me, branch, sessions, profiles },
|
||||
staging,
|
||||
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
|
||||
)
|
||||
const name = handoffAttachmentName(issueNumber)
|
||||
const archive = path.join(outDir, name)
|
||||
await packHandoff(staging, archive)
|
||||
for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) {
|
||||
if (old.name === name)
|
||||
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id })
|
||||
}
|
||||
const uploaded = await uploadIssueAttachment({ host, token, owner, repo, index: issueNumber, name, data: await fsp.readFile(archive) })
|
||||
attachmentId = uploaded.id
|
||||
logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) })
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
|
||||
dismiss(panel, spinner)
|
||||
toast(panel, 'error', `移交失败(打包/上传):${message}`)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
finally {
|
||||
// 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}`)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] })
|
||||
}
|
||||
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)
|
||||
toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
|
||||
// 远端移交已经生效,这里不回滚。
|
||||
let worktreeRemovalFailed = false
|
||||
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
||||
const settings = getSettings(panel.context)
|
||||
try {
|
||||
await panel.dispatchWorktreeHook('pre-remove', {
|
||||
workspaceRoot,
|
||||
worktreePath: worktreeAbs,
|
||||
branch: branch ?? '',
|
||||
issueNumber,
|
||||
mainBranch: settings.devBranch || 'main',
|
||||
customScriptPath: settings.worktreePreRemoveScript,
|
||||
})
|
||||
killProcessesUsingWorktree(worktreeAbs)
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
if (branch) {
|
||||
const settings = getSettings(panel.context)
|
||||
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 会话)`)
|
||||
finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* 接管方:下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
|
||||
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
|
||||
*/
|
||||
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
// 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<string, unknown>
|
||||
try {
|
||||
state = await panel.readIssueState(issueNumber)
|
||||
@@ -350,6 +386,15 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
||||
return
|
||||
}
|
||||
|
||||
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
|
||||
// 重新指派),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。
|
||||
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
|
||||
if (!issue || !canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) {
|
||||
toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
const spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||
const extracted = await scratchDir('spx-takeover-')
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user