Files

11 KiB
Raw Permalink Blame History

工单移交(handoff)设计

目标

同事在 A 机器上开发/测试完一个工单,把它整体交给 B 机器上的我继续(合并、解冲突、测试、续改)。B 机器上要能:

  • 重建实施 worktree(分支从 origin 拉)
  • claude --resume 同事的头脑风暴 / 实施 / 测试会话,codex resume 审查会话
  • 之后所有既有流程(拖到完成、冲突解决、测试会话、恢复实施会话)照常工作

通道选 Gitea 工单附件:会话文件打成 tgz 挂在工单上,移交状态记在共享 state JSON 里。不引入 Tailscale / ssh 之类点对点传输。

事实前提

  • Claude Code ≥ 2.1.223claude --resume <id> 在任意目录可用,会搜本机全部 ~/.claude/projects/*/jsonl 内部旧 cwd 不存在也能续。同一 sid 出现在两个 project 目录 → 报 "No conversation found"
  • 一个 claude 会话 = <projectsDir>/<sid>.jsonl + 可选目录 <projectsDir>/<sid>/subagents/tool-results/)。两者必须一起搬。
  • codex 会话 = ~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl,全局目录,与 worktree 无关。
  • Gitea 1.27:附件 max_size=100MB,允许 .tgzPOST/GET/DELETE /repos/{owner}/{repo}/issues/{index}/assets[/{id}]
  • 共享 state JSON 由 vscode/schemas/state-json.schema.json 约束(additionalProperties: false),spx 写入前校验;本机字段走 workspaceStateissues/localState.ts)。
  • webhook issues.assigned 事件已会让受影响机器重载该工单。
  • 仅 Gitea 工单支持移交;YouTrack 工单不显示相关按钮。

术语

  • 发送方:当前持有 worktree / 会话的机器(同事)。
  • 接管方:目标 assignee 的机器(我)。
  • 会话包spx-handoff-issue-<N>.tgz

会话包格式

handoff.json                       清单
claude/<sid>.jsonl                 每个 claude 会话
claude/<sid>/**                    该会话的子目录(存在才有)
codex/YYYY/MM/DD/rollout-*.jsonl   审查会话(存在才有)

handoff.json

{
  "version": 1,
  "issue": 123,
  "from": "chw",
  "createdAt": "2026-08-26T08:00:00Z",
  "branch": "feature/foo",
  "sessions": {
    "sessionId": "…", "implementSessionId": "…", "testSessionId": "…", "reviewSessionId": "…"
  },
  "profiles": {
    "profilePath": "/Users/chw/.claude/profiles/x.json",
    "brainstormProfilePath": "…", "testProfilePath": "…"
  },
  "claude": [ { "id": "<sid>", "kind": "brainstorm" | "implement" | "test" } ],
  "codex": [ { "id": "<uuid>", "relPath": "2026/08/26/rollout-….jsonl" } ]
}

sessions / profiles 只放存在的键;claude / codex 只列实际打进包里的文件。profile 路径原样带发送方绝对路径,接管方沿用现有 resolveProfilePath 按文件名重映射。

共享 state JSON 新字段

字段 类型 说明
handoffAttachmentId stringminLength 1 会话包在 Gitea 的附件 id;存在即「待接管」
handoffFrom stringminLength 1 发送方 login

两个字段均为共享字段(不进 LOCAL_STATE_FIELDS),清除沿用 '' 墓碑约定。四处字段清单同步:schemas/state-json.schema.jsongitea/stateJson.tsyoutrack/stateComment.tsKNOWN_STATE_FIELDScli/cmd/spx/main.goknownStateFieldsclimake install 重装(schema 由 Makefile sync-schema 拷贝)。

Issue 类型(gitea/types.tswebview-ui/src/types.ts 镜像)新增 handoffAttachmentId?handoffFrom?assignees: string[]issueLoader.ts 解析并透出这三个字段。issues/update 消息新增 me?: string(当前 Gitea logintoken 无效时缺省)。

发送方:移交

入口:工单 tab 头部「移交」按钮。显示条件:source !== 'youtrack'column !== 'done' 且无 handoffAttachmentId

点击弹 webview 内 HandoffModal:请求 handoff/users,扩展用 GET /repos/{owner}/{repo}/assignees 返回候选(排除我),单选后提交 handoff/start { issueNumber, to }。无需再弹原生确认框。

扩展端 handleHandoffStart(panel, issueNumber, to) 顺序:

  1. 前置:workspaceRoot / detectRepo / token / loginForTokenme。读 readIssueState,取本机字段(四个 sid、三个 profile、worktreePath)与共享 branch
  2. worktree 守卫(有 worktreePath 且目录存在时):git status --porcelain 必须为空;git fetch origin <branch>HEAD 必须等于 origin/<branch>。否则 toast「有未提交/未推送改动」并终止,什么都不改。
  3. 关闭该工单全部终端(terminalOrigin 同 issueNumber + 名称匹配 issue-N-冲突解决),等 600ms 让 jsonl 刷盘。
  4. 在 scratch 临时目录收集会话包:每个 sid 在 ~/.claude/projects/*/ 里找 <sid>.jsonl(多份取最新 mtime),连同 <sid>/ 一起拷入 claude/reviewSessionId~/.codex/sessions 里按文件名尾部 uuid 找到 rollout 拷入 codex/<relPath>。找不到的会话不进清单,只 log。写 handoff.jsontar -czf
  5. 若工单已有同名旧附件(重复移交)先删。POST …/assets?name=spx-handoff-issue-N.tgz 上传。
  6. mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })
  7. updateIssueAssignees(… [to])
  8. 本机清理(失败只 warn,不回滚——远端移交已生效):pre-remove 钩子 → killProcessesUsingWorktreeremoveWorktreeDircleanupFeatureBranch 不传 remote/token(只删本地分支,远端分支必须保留给接管方);本机字段全部写 '' 墓碑。发送方磁盘上的会话文件保留。
  9. issue/patch { handoffAttachmentId, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null } + 成功 toast。

回滚:5 失败 → toast 终止(终端已关,无其他改动);6 失败 → 删附件;7 失败 → 清回 6 的两个字段、删附件。全程 spinner toast,结束 toast/dismiss

接管方:接管

入口:工单 tab 头部「接管」按钮 + 看板卡片「待接管」角标。显示条件:handoffAttachmentId 存在且 assigneesme

扩展端 handleHandoffAccept(panel, issueNumber) 顺序:

  1. 前置同上。读共享 state 取 handoffAttachmentIdbranchpr
  2. GET …/assets/{id}browser_download_url,带 Authorization: token 下载到 scratch 临时目录,tar -xzf,解析并校验 handoff.jsonversion === 1issue === issueNumber)。
  3. 重建 worktreemanifest.branch ?? state.branch 存在时):ensureWorktreeFromRemote({ workspaceRoot, worktreePath, branch })——git fetch origin <branch>;该分支已有 live worktree → 直接用;模板路径被垃圾目录占着 → kill + 删;git worktree add -B <branch> <path> origin/<branch>。模板路径用设置 worktreeDirectoryslug = branch 去掉 feature/ 前缀。之后跑 post-create 钩子。分支不存在于远端 → toast 终止(附件保留,可重试)。
  4. 安装会话brainstorm 会话装到 projectsDirFor(workspaceRoot)implement / test 装到 projectsDirFor(worktreeAbs)(无 worktree 时也装到 workspaceRoot)。安装前先删本机所有 ~/.claude/projects/*/<sid>.jsonl<sid>/(避免重复 sid),再拷 jsonl + 目录。codex 文件按 relPath 拷到 ~/.codex/sessions/ 下。
  5. 写本机字段:清单里的四个 sid、三个 profile 路径、worktreePath
  6. mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' })DELETE …/assets/{id}(失败只 warn)。
  7. issue/patchsid、worktreePath、worktreeExists、两个 handoff 字段置 null+ 成功 toast。

幂等:3、4 可重复执行;6 之前失败附件仍在,按钮仍显示,用户可重试。

附带修正

完成列流程里「把 jsonl 拷回主仓库」(panel/handlers/issues.ts ~L388)改为整包搬迁jsonl + <sid>/ 一起移动到 projectsDirFor(workspaceRoot),并删除源文件——否则新版 claude 因重复 sid 拒绝 resume。与接管方第 4 步共用同一个安装函数。

模块划分

模块 职责 测试
cc/sessionBundle.ts 纯 fsfindClaudeSessionFiles(sid, projectsRoot)installClaudeSession(sid, srcDir, dstProjectsDir, projectsRoot)(先删本机所有副本再拷)、codexRolloutPathFor(sid, codexRoot)installCodexSession(relPath, srcFile, codexRoot) 临时目录 vitest
cc/handoffManifest.ts 清单类型、buildHandoffManifest(...)parseHandoffManifest(json) 纯函数 vitest
cc/handoffArchive.ts packHandoff(stagingDir, outFile) / unpackHandoff(file, dstDir)execFile('tar') 真 tar 往返 vitest
git/worktreePath.ts panel/handlers/sessions.ts 抽出 resolveWorktreeDirderiveSlug,新增 slugFromBranch 纯函数 vitest
git/worktree.ts 导出 runGitfindLiveWorktreeForBranch;新增 ensureWorktreeFromRemote test/git/ mock child_process
gitea/api.ts listRepoAssigneeslistIssueAttachmentsuploadIssueAttachmentFormData + Blob)、getIssueAttachmentdownloadAttachmentarrayBuffer,不吞错)、deleteIssueAttachment
panel/handlers/handoff.ts 纯决策:canStartHandoff(issue)canAcceptHandoff(issue, me)handoffStateExtra / handoffUiPatch 两套 payload、worktreePushedCheck 结果判定 vitest
panel/handlers/handoffFlow.ts 两个 orchestratorhandleHandoffStarthandleHandoffAccepthandleHandoffUsers
webview HandoffModal.tsxIssueDetailPanel 两个按钮、IssueCard 角标、useIssues 三个 postMessage、me 状态

消息(src/panel/messages.tswebview-ui/src/lib/messages.ts 镜像):

  • webview → 扩展:handoff/users { issueNumber }handoff/start { issueNumber, to }handoff/accept { issueNumber }
  • 扩展 → webviewhandoff/users-result { issueNumber, users: string[] }issues/updatemeissue/patchhandoffAttachmentIdhandoffFrom、四个 sid(可 null

验证

  • pnpm test / pnpm lint / pnpm typecheckcd cli && make test
  • 实施第一步先用 curl 验证 Authorization: token 能下载 browser_download_url(Gitea 私有仓库附件走 web 路由);不通则改走 GET /attachments/{uuid} 等价路径,写进 downloadAttachment
  • 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。

不做

  • 撤销移交、移交历史。
  • 发送方保留 worktree。
  • YouTrack 工单移交。
  • 清理本机存量的重复 sid。