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' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { window, workspace } from 'vscode' import { loginForToken } from '../../auth/identity' import { getToken } from '../../auth/secrets' import { packHandoff, unpackHandoff } from '../../cc/handoffArchive' import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle' import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest' import { claudeProjectsRoot } from '../../cc/sessionBundle' import { projectsDirFor } from '../../cc/sessionWatcher' import { detectRepo } from '../../git/remote' import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath' import { deleteIssueAttachment, downloadAttachment, getIssue, getIssueAttachment, listIssueAttachments, listRepoAssignees, updateIssueAssignees, uploadIssueAttachment, } from '../../gitea/api' import { logger } from '../../logging/logger' import { defaultCodexSessionsDir } from '../../sessions/codexSessions' import { getSettings } from '../../settings/store' import { makeNonce } from '../KanbanPanel' import { cleanupFeatureBranch } from './branchCleanup' import { canAcceptHandoff, canStartHandoff, checkWorktreePushed, handoffAcceptedStateExtra, handoffAcceptedUiPatch, handoffLocalTombstones, handoffStartedStateExtra, handoffStartedUiPatch, } from './handoff' export interface RepoCtx { workspaceRoot: string host: string owner: string repo: string token: string me: string } export function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string { const id = extra?.id ?? makeNonce() panel.postMessage({ type: 'toast/show', id, level, message, spinner: extra?.spinner, dismissOnTimer: extra?.dismissOnTimer ?? 6000 }) return id } export function dismiss(panel: KanbanWebviewPanel, id: string): void { panel.postMessage({ type: 'toast/dismiss', id }) } export async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { toast(panel, 'error', '请先打开一个工作区文件夹') return undefined } const remote = await detectRepo(workspaceRoot) if (!remote) { toast(panel, 'error', '当前工作区没有 Gitea 远程仓库') return undefined } const token = await getToken(panel.context, remote.host) if (!token) { toast(panel, 'error', '请先完成 Gitea 配置') return undefined } const me = await loginForToken(remote.host, token) if (!me) { toast(panel, 'error', '无法解析当前 Gitea 身份,token 是否失效?') return undefined } return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token, me } } function git(cwd: string, args: string[]): Promise<{ ok: boolean, stdout: string, stderr: string }> { return new Promise((resolve) => { execFile('git', ['-C', cwd, ...args], { timeout: 30_000, encoding: 'utf8' }, (err, stdout, stderr) => { resolve({ ok: !err, stdout: (stdout ?? '').toString(), stderr: (stderr ?? '').toString() }) }) }) } function str(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined } export function scratchDir(prefix: string): Promise { return fsp.mkdtemp(path.join(os.tmpdir(), prefix)) } export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise { const ctx = await resolveRepoCtx(panel) if (!ctx) { // ctx 解析失败已经 toast 过原因;这里必须补一条空结果,否则弹窗的 spinner 转不停。 panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] }) return } try { const users = await listRepoAssignees(ctx) panel.postMessage({ type: 'handoff/users-result', issueNumber, users: users.map(u => u.login).filter(l => l !== ctx.me).sort(), }) } catch (err) { const message = err instanceof Error ? err.message : String(err) toast(panel, 'error', `读取可指派用户失败:${message}`) panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] }) } } /** * 发送方:服务端复查 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 { 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 try { state = await panel.readIssueState(issueNumber) } catch (err) { toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) return } // 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到 // assignees 判断「是否我负责」——两者都得在 canStartHandoff 判定前就绪。 const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) if (!issue) { toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) return } if (issue.state === 'closed') { toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`) return } // 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到 // 完成、指派已变更),用刚读到的最新 state + issue 再判一次 canStartHandoff, // 避免重复移交或抢别人的工单。 const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' const assignees = (issue.assignees ?? []).map(a => a.login) if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) { 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(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, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), 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 { // 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 { state = await panel.readIssueState(issueNumber) } catch (err) { toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) finish() return } const attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10) if (!Number.isFinite(attachmentId)) { toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`) finish() return } // spinner / extracted 声明在 try 外面:getIssue、mkdtemp 任一失败都要靠 // 「是否已创建」来决定 catch/finally 里能不能 dismiss/rm,不能假设已存在。 let spinner: string | undefined let extracted: string | undefined try { spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 }) // 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被 // 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。 const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) if (!issue) { dismiss(panel, spinner) toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) return } if (!canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) { dismiss(panel, spinner) toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`) return } extracted = await scratchDir('spx-takeover-') // ① 下载 + 解包 + 校验清单 const meta = await getIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }) const data = await downloadAttachment({ token, url: meta.browser_download_url }) const archive = path.join(extracted, meta.name) await fsp.writeFile(archive, data) await unpackHandoff(archive, extracted) const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber) // ② 重建 worktree const branch = manifest.branch ?? str(state.branch) let worktreeAbs: string | undefined if (branch) { const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch)) worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch }) const settings = getSettings(panel.context) await panel.dispatchWorktreeHook('post-create', { workspaceRoot, worktreePath: worktreeAbs, branch, issueNumber, mainBranch: settings.devBranch || 'main', customScriptPath: settings.worktreePostCreateScript, }) } // ③ 装会话文件;发送方与接收方的 worktree 路径不同就顺带把会话里的 // cwd 类路径重写成接收方路径,否则 `codex resume` / MCP 起进程会照着 // 发送方那条本机不存在的路径找目录,直接 ENOENT。 const pathRewrite = manifest.worktreePath && worktreeAbs ? { from: manifest.worktreePath, to: worktreeAbs } : undefined await installHandoffBundle(extracted, manifest, { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir(), workspaceProjectsDir: projectsDirFor(workspaceRoot), ...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}), ...(pathRewrite ? { pathRewrite } : {}), }) // ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn) // reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists); // 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。 const reviewSessionId = manifest.sessions.reviewSessionId const reviewSessionFileExists = !!reviewSessionId && manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase()) const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs } await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local)) try { await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }) } catch (err) { logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除附件失败`, details: err instanceof Error ? err.message : String(err) }) } dismiss(panel, spinner) panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }), }) toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`) logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'panel', message: `接管 #${issueNumber} 失败`, details: message }) if (spinner) dismiss(panel, spinner) toast(panel, 'error', `接管失败:${message}`) } finally { // finish 先发:下面的 rm 失败不该让弹窗的 spinner 卡住不收。 finish() // rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。 if (extracted) await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {}) } }