diff --git a/vscode/README.md b/vscode/README.md index 4d1b4c1..c9f6e20 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -23,7 +23,7 @@ 4. **测试**:工单面板里 ▶ 开 `issue-N-测试` 会话(`/pr-acceptance-testing`)。 5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。 6. **回退**:进行中 / 审查中的卡可「重置为待办」,关 PR、删 worktree、清分支和实施痕迹,spec / plan 保留。 -7. **移交**:工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree(`git worktree add -B … origin/`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。 +7. **移交**:前置条件:两台机器的 spx 都已 `cd cli && make install` 到含 `handoffAttachmentId` 的版本,否则移交期间旧 spx 的 state 写入会被 schema 拒绝。工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree(`git worktree add -B … origin/`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。 其他看板规则: @@ -38,12 +38,13 @@ | 工具 | 用途 | |---|---| -| `git` | worktree、分支、fetch / merge、tar(移交打包) | +| `git` | worktree、分支、fetch / merge | | `claude` | 规划、实施、测试、冲突解决、提交、PR 摘要(找不到时会提示常见位置 `~/.local/bin`) | | `codex` | 审查会话(开启自动审查时必需) | | `tea` | `spx` 从 `~/.config/tea/config.yml` 读取 Gitea host + token;审查 prompt 也用 `tea pulls` 看 PR | | `spx` | 本仓库 `cli/` 里的 Go CLI,见下文 | | `opencli` | prompts 里统一以 `opencli spx ...` 调用 spx | +| `tar` | 移交时打包 / 解包会话文件 | | `bash` | 执行钩子脚本 | - 仅支持 Gitea(`https:///api/v1`),不支持 GitHub。 diff --git a/vscode/cli/internal/state/schema.json b/vscode/cli/internal/state/schema.json index 9515b68..2682403 100644 --- a/vscode/cli/internal/state/schema.json +++ b/vscode/cli/internal/state/schema.json @@ -104,13 +104,11 @@ }, "handoffAttachmentId": { "type": "string", - "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。", - "minLength": 1 + "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" }, "handoffFrom": { "type": "string", - "description": "发起移交的 Gitea login。", - "minLength": 1 + "description": "发起移交的 Gitea login;清空时写空串。" } } } diff --git a/vscode/schemas/state-json.schema.json b/vscode/schemas/state-json.schema.json index 9515b68..2682403 100644 --- a/vscode/schemas/state-json.schema.json +++ b/vscode/schemas/state-json.schema.json @@ -104,13 +104,11 @@ }, "handoffAttachmentId": { "type": "string", - "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。", - "minLength": 1 + "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" }, "handoffFrom": { "type": "string", - "description": "发起移交的 Gitea login。", - "minLength": 1 + "description": "发起移交的 Gitea login;清空时写空串。" } } } diff --git a/vscode/src/cc/handoffManifest.test.ts b/vscode/src/cc/handoffManifest.test.ts index 1131d7f..73d4b8a 100644 --- a/vscode/src/cc/handoffManifest.test.ts +++ b/vscode/src/cc/handoffManifest.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest' +const UUID = '11111111-1111-4111-8111-111111111111' + const good = { version: 1, issue: 42, @@ -9,7 +11,7 @@ const good = { branch: 'feature/x', sessions: { implementSessionId: 'a' }, profiles: {}, - claude: [{ id: 'a', kind: 'implement' }], + claude: [{ id: UUID, kind: 'implement' }], codex: [], } @@ -30,7 +32,13 @@ describe('parseHandoffManifest', () => { expect(() => parseHandoffManifest(JSON.stringify({ ...good, version: 2 }), 42)).toThrow(/version/) }) it('claude 条目 kind 非法 → 抛错', () => { - expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: 'a', kind: 'x' }] }), 42)).toThrow(/claude/) + expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: UUID, kind: 'x' }] }), 42)).toThrow(/claude/) + }) + it('claude 条目 id 非 UUID(路径穿越)→ 抛错', () => { + expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: '../x', kind: 'implement' }] }), 42)).toThrow(/claude/) + }) + it('codex 条目 id 非 UUID → 抛错', () => { + expect(() => parseHandoffManifest(JSON.stringify({ ...good, codex: [{ id: 'not-a-uuid', relPath: '2026/08/26/rollout-x.jsonl' }] }), 42)).toThrow(/codex/) }) it('不是 JSON → 抛错', () => { expect(() => parseHandoffManifest('nope', 42)).toThrow() diff --git a/vscode/src/cc/handoffManifest.ts b/vscode/src/cc/handoffManifest.ts index 446e825..c8058d9 100644 --- a/vscode/src/cc/handoffManifest.ts +++ b/vscode/src/cc/handoffManifest.ts @@ -39,6 +39,9 @@ export function handoffAttachmentName(issueNumber: number): string { const KINDS: ReadonlySet = new Set(['brainstorm', 'implement', 'test']) +// Claude / Codex 会话 id 都是 UUID;顺手挡掉路径穿越(如 `../x`)之类的伪造条目。 +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + function optString(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined } @@ -63,7 +66,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando const o = e as Record const id = optString(o.id) const kind = optString(o.kind) - if (!id || !kind || !KINDS.has(kind)) + if (!id || !kind || !KINDS.has(kind) || !UUID_RE.test(id)) throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`) return { id, kind: kind as ClaudeSessionKind } }) @@ -71,7 +74,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando const o = e as Record const id = optString(o.id) const relPath = optString(o.relPath) - if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..')) + if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..') || !UUID_RE.test(id)) throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`) return { id, relPath } }) diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts index 26f01b9..2894754 100644 --- a/vscode/src/panel/handlers/handoff.test.ts +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -87,7 +87,7 @@ describe('payloads', () => { }) }) it('移交 UI patch 用 null 清字段并更新 assignees', () => { - expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({ + expect(handoffStartedUiPatch(9, 'chw', 'me', { keepWorktreePath: false })).toEqual({ handoffAttachmentId: '9', handoffFrom: 'chw', assignees: ['me'], @@ -95,10 +95,32 @@ describe('payloads', () => { implementSessionId: null, reviewSessionId: null, testSessionId: null, + profilePath: null, + brainstormProfilePath: null, + testProfilePath: null, + prDiffFile: null, worktreePath: null, worktreeExists: false, }) }) + it('移交 UI patch:keepWorktreePath=true 时省略 worktreePath / worktreeExists', () => { + const patch = handoffStartedUiPatch(9, 'chw', 'me', { keepWorktreePath: true }) + expect(patch).not.toHaveProperty('worktreePath') + expect(patch).not.toHaveProperty('worktreeExists') + expect(patch).toEqual({ + handoffAttachmentId: '9', + handoffFrom: 'chw', + assignees: ['me'], + sessionId: null, + implementSessionId: null, + reviewSessionId: null, + testSessionId: null, + profilePath: null, + brainstormProfilePath: null, + testProfilePath: null, + prDiffFile: null, + }) + }) it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => { expect(handoffAcceptedStateExtra({ sessions: { implementSessionId: 'i', reviewSessionId: 'r' }, diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index 043ee58..0c7874d 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -59,7 +59,7 @@ export function handoffLocalTombstones(opts: { keepWorktreePath: boolean }): Rec return extra } -export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record { +export function handoffStartedUiPatch(attachmentId: number, from: string, to: string, opts: { keepWorktreePath: boolean }): Record { return { handoffAttachmentId: String(attachmentId), handoffFrom: from, @@ -68,8 +68,12 @@ export function handoffStartedUiPatch(attachmentId: number, from: string, to: st implementSessionId: null, reviewSessionId: null, testSessionId: null, - worktreePath: null, - worktreeExists: false, + profilePath: null, + brainstormProfilePath: null, + testProfilePath: null, + prDiffFile: null, + // worktree 没删掉时(opts.keepWorktreePath)不碰这两个字段,让 UI 继续显示孤儿 worktree。 + ...(opts.keepWorktreePath ? {} : { worktreePath: null, worktreeExists: false }), } } diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 4f579de..75d5c92 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -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 { + 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 + } + + // 前置复查: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(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 { // 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 - 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(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 { - // 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 { 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 { diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 5b72809..a860a5b 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -42,7 +42,7 @@ export type ExtensionToWebview = | { type: 'issues/loading' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'handoff/done', issueNumber: number } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 6b6590e..edd4671 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -57,7 +57,7 @@ export type ExtensionToWebview = | { type: 'issues/loading' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'handoff/done', issueNumber: number }