177 lines
18 KiB
Markdown
177 lines
18 KiB
Markdown
# 工单移交(handoff)设计
|
||
|
||
## 目标
|
||
|
||
同事在 A 机器上开发/测试完一个工单,把它整体交给 B 机器上的我继续(合并、解冲突、测试、续改)。B 机器上要能:
|
||
|
||
- 重建实施 worktree(分支从 origin 拉)
|
||
- `claude --resume` 同事的头脑风暴 / 实施 / 测试会话,`codex resume` 审查会话
|
||
- 之后所有既有流程(拖到完成、冲突解决、测试会话、恢复实施会话)照常工作
|
||
|
||
通道选 **阿里云 OSS**:会话文件打成 tar.xz 传到 OSS 存储桶,移交状态记在共享 state JSON 里。不引入 Tailscale / ssh 之类点对点传输。
|
||
|
||
不经 Gitea 传:Gitea 在内网,外网经阿里云 ECS 用 frp 转发,出口约 3Mbps,实测只有 0.35~0.4MB/s。OSS 北京区实测上传 32MB 4.6 秒、下载 1 秒。
|
||
|
||
## 事实前提
|
||
|
||
- Claude Code ≥ 2.1.223:`claude --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 无关。
|
||
- 会话 jsonl 里的重复内容隔得很远,gzip 只看 32KB 以内的重复,压不动(实例:原始 148MB,gzip 86MB);xz 1 级 32MB、5.4 秒,解包 1.6 秒。macOS 自带 bsdtar 内建 xz:`tar --options xz:compression-level=1 -cJf` 可用,不用另装软件。
|
||
- 目标存储桶是公共读的(正式服在用),会话包里有代码和对话:每个对象上传时必须带 `x-oss-object-acl: private`。不改存储桶本身的任何配置。
|
||
- 共享 state JSON 由 `vscode/schemas/state-json.schema.json` 约束(`additionalProperties: false`),spx 写入前校验;本机字段走 workspaceState(`issues/localState.ts`)。
|
||
- webhook `issues.assigned` 事件已会让受影响机器重载该工单。
|
||
- 仅 Gitea 工单支持移交;YouTrack 工单不显示相关按钮。
|
||
|
||
## 术语
|
||
|
||
- **发送方**:当前持有 worktree / 会话的机器(同事)。
|
||
- **接管方**:目标 assignee 的机器(我)。
|
||
- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff/<owner>/<repo>/issue-<N>-<UTC 时间>.tar.xz`(时间形如 `20261008T010203Z`,同一工单多次移交不撞名)。会话交接的包另放在收件箱目录下,见下文「会话交接(单个会话)」。
|
||
- **移交存储**:放会话包的 OSS 存储桶,见下文「移交存储配置」。
|
||
|
||
## 会话包格式
|
||
|
||
```
|
||
handoff.json 清单
|
||
claude/<sid>.jsonl 每个 claude 会话
|
||
claude/<sid>/** 该会话的子目录(存在才有)
|
||
codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
|
||
```
|
||
|
||
`handoff.json`:
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"issue": 123,
|
||
"from": "chw",
|
||
"createdAt": "2026-08-26T08:00:00Z",
|
||
"branch": "feature/foo",
|
||
"sessions": {
|
||
"sessionId": "…", "implementSessionId": "…", "testSessionId": "…", "reviewSessionId": "…"
|
||
},
|
||
"claude": [ { "id": "<sid>", "kind": "brainstorm" | "implement" | "test" } ],
|
||
"codex": [ { "id": "<uuid>", "relPath": "2026/08/26/rollout-….jsonl" } ]
|
||
}
|
||
```
|
||
|
||
`sessions` 只放存在的键;`claude` / `codex` 只列实际打进包里的文件。
|
||
|
||
profile(`profilePath` / `brainstormProfilePath` / `testProfilePath` / `prototypeProfilePath`,各类会话用哪份模型配置)是每台机器自己的选择,不进会话包:发送方不打包,接管方不写入,旧包里残留的 `profiles` 解析时直接忽略。接管方的工单没选过 profile 就用自己设置里的默认值。
|
||
|
||
## 共享 state JSON 新字段
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `handoffBundleKey` | string(minLength 1) | 会话包在 OSS 上的对象 key;存在即「待接管」 |
|
||
| `handoffFrom` | string(minLength 1) | 发送方 login |
|
||
|
||
两个字段均为共享字段(不进 `LOCAL_STATE_FIELDS`),清除沿用 `''` 墓碑约定。四处字段清单同步:`schemas/state-json.schema.json`、`gitea/stateJson.ts` 与 `youtrack/stateComment.ts` 的 `KNOWN_STATE_FIELDS`、`cli/cmd/spx/main.go` 的 `knownStateFields`;`cli` 走 `make install` 重装(schema 由 Makefile `sync-schema` 拷贝)。
|
||
|
||
`Issue` 类型(`gitea/types.ts` 与 `webview-ui/src/types.ts` 镜像)新增 `handoffBundleKey?`、`handoffFrom?`、`assignees: string[]`。`issueLoader.ts` 解析并透出这三个字段。`issues/update` 消息新增 `me?: string`(当前 Gitea login,token 无效时缺省)。
|
||
|
||
## 移交存储配置
|
||
|
||
命令「Superpowers: 配置移交存储」(`superpowers.configureHandoffStore`)依次弹四个输入框:endpoint(如 `oss-cn-beijing.aliyuncs.com`)、bucket、AccessKey ID、AccessKey Secret(密码框)。框里预填已有值;四项都填完才保存,中途 Esc 什么都不改。endpoint、bucket 存扩展设置(`settings/store.ts` 的 `handoffOssEndpoint` / `handoffOssBucket`),两个 AccessKey 存 VS Code SecretStorage(`auth/secrets.ts`),整台机器共用一份。
|
||
|
||
也可以从 env 文件一次配好:`vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile=<env 文件绝对路径>`,读文件里的 `OSS_ENDPOINT`、`OSS_BUCKET_NAME`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY`(luffy-agent-platform 后端 `.env` 的写法,`KEY=value`,注释和别的变量跳过;endpoint 去掉 `https://`),四项齐了按命令同样的方式保存并提示「移交存储已保存:<bucket>(<endpoint>)」;参数不对、文件读不到、缺项只报错(只报变量名),什么都不改。AccessKey 不进日志、不进提示。URI 参数值按入口的编码契约做两层 urlencode(见 `uri/uriHandler.ts`)。
|
||
|
||
四项缺一项就算没配置:移交、接管、同意移交申请,以及会话交接的发送、接收、刷新待接收列表,都直接 toast 提示去跑这个命令,什么都不改(同意申请时也不把它记成申请失败的原因;刷新时给 webview 回空列表)。
|
||
|
||
## 发送方:移交
|
||
|
||
入口:工单 tab 头部「移交」按钮。显示条件:`source !== 'youtrack'` 且 `column !== 'done'` 且无 `handoffBundleKey`。
|
||
|
||
点击弹 webview 内 `HandoffModal`:请求 `handoff/users`,扩展用 `GET /repos/{owner}/{repo}/assignees` 返回候选(排除我),单选后提交 `handoff/start { issueNumber, to }`。无需再弹原生确认框。
|
||
|
||
扩展端 `handleHandoffStart(panel, issueNumber, to)` 顺序:
|
||
|
||
1. 前置:移交存储已配置;workspaceRoot / `detectRepo` / token / `loginForToken` 得 `me`。读 `readIssueState`,取本机的会话 id、`worktreePath` 与共享 `branch`(profile 不取)。
|
||
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/`(跳过 `subagents/`,子代理对话不移交);`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/<relPath>`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar --options xz:compression-level=1 -cJf`。
|
||
5. 用阿里云官方 SDK `ali-oss` 的分片上传传到 OSS,带 `x-oss-object-acl: private`;中途失败把已传的分片清掉(abort)。
|
||
6. `mergeIssueState(issueNumber, { handoffBundleKey, handoffFrom: me })`。
|
||
7. `updateIssueAssignees(… [to])`。
|
||
8. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree` → `removeWorktreeDir`;`cleanupFeatureBranch` **不传 remote/token**(只删本地分支,远端分支必须保留给接管方);本机的会话 id、`worktreePath`、`prDiffFile` 写 `''` 墓碑(worktree 没删掉时保留 `worktreePath`),四个 profile 字段不动,工单以后接回来还是自己原来选的模型配置。发送方磁盘上的会话文件保留。
|
||
9. `issue/patch { handoffBundleKey, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null }` + 成功 toast;patch 里没有 profile 字段。
|
||
|
||
进度 toast 从核对分支开始,同一条提示随步骤改文案(核对分支、关闭终端、收集会话、`打包 x MB`、`上传 x MB / y MB`、写入状态、指派、清理本机)。打包时按包文件已写出的大小报,上传时每传完一片报一次。移交结束前不自动消失。
|
||
|
||
审查列里,负责人不是自己、也没有待接管包的 Gitea 工单,工单详情标题栏可以「申请移交」。同一张工单同时只有一条申请。同事收到提示,打开工单详情可以同意或拒绝。同意走上面的移交,对象就是申请人;申请人仍自己点接管。拒绝后申请消失,申请人看到被拒绝。申请人可以在回应前取消。同意后若移交失败,申请留着,两边都看到原因,修好可以再点同意。工单离开审查列时清掉未处理的申请。共享字段 `handoffRequestFrom` / `handoffRequestError`(拒绝时 error 为 `rejected`)。
|
||
|
||
回滚:打包/上传失败 → toast 终止(终端已关,无其他改动);写状态失败 → 删 OSS 对象;指派失败 → 清回两个共享字段、删 OSS 对象。结束 `toast/dismiss`。
|
||
|
||
## 接管方:接管
|
||
|
||
入口:工单 tab 头部「接管」按钮 + 看板卡片「待接管」角标。显示条件:`handoffBundleKey` 存在且 `assignees` 含 `me`。
|
||
|
||
扩展端 `handleHandoffAccept(panel, issueNumber)` 顺序:
|
||
|
||
1. 前置同上。读共享 state 取 `handoffBundleKey`、`branch`、`pr`。
|
||
2. 从 OSS 边下边写到 scratch 临时目录(文件名固定,不拿 key 拼本机路径;连续 60 秒收不到数据就放弃),`tar -xJf`,解析并校验 `handoff.json`(`version === 1`、`issue === issueNumber`)。
|
||
3. **重建 worktree**(`manifest.branch ?? state.branch` 存在时):`ensureWorktreeFromRemote({ workspaceRoot, worktreePath, branch })`——`git fetch origin <branch>`;该分支已有 live worktree → 直接用;模板路径被垃圾目录占着 → kill + 删;`git worktree add -B <branch> <path> origin/<branch>`。模板路径用设置 `worktreeDirectory`,slug = `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. 写本机字段:清单里的会话 id、`worktreePath`。profile 不写,本机原有的选择和设置里的默认值照旧生效。
|
||
6. `mergeIssueState(issueNumber, { handoffBundleKey: '', handoffFrom: '' })`;删 OSS 对象(失败只 warn)。
|
||
7. `issue/patch`(sid、worktreePath、worktreeExists、两个 handoff 字段置 null)+ 成功 toast。
|
||
|
||
进度 toast 和发送方一样,同一条提示依次显示 核对指派、`下载 x MB / y MB`、解包、建 worktree、装会话、写入状态,完成前不自动消失,结束时收掉。
|
||
|
||
幂等:3、4 可重复执行;6 之前失败会话包仍在,按钮仍显示,用户可重试。
|
||
|
||
## 会话交接(单个会话)
|
||
|
||
会话 tab 里的一个 claude 会话交给同事,和工单无关,也走移交存储。
|
||
|
||
- **收件箱**:每人每仓库一个 OSS 目录 `spx-handoff/<owner>/<repo>/session/<收件人>/`。会话包 key 为 `<收件箱><sid>!<发送人>!<会话名 slug>.tar.xz`,slug 把字母、数字、`._-` 以外的字符换成 `_`、最长 40 字。owner / repo / 收件人 / 发送人只收 `[\w.-]`,sid 必须是 UUID,否则拒绝生成。owner / repo / 收件人转小写(Gitea 不分大小写,各人 remote 地址的写法可能不同)。key 的生成与解析在 `cc/sessionHandoff.ts`(`sessionInboxPrefix` / `sessionHandoffKey` / `parseSessionHandoffKey`)。
|
||
- **包内容**:`handoff.json`(`kind: 'managed-session'`,含 from / to / workspacePath / session)+ `claude/<sid>.jsonl` + `claude/<sid>/`,xz 1 级,和工单移交共用 `packHandoff` / `unpackHandoff`。
|
||
- **发送**(`session-handoff/start { sessionId, to }`):关掉该会话的终端等 600ms → 收集会话、写清单、打包 → 列对方收件箱,删掉同 sid 的旧包 → 分片上传(对象私有)→ 本机会话记录写 `handedOffTo` / `handedOffAt`。
|
||
- **待接收列表**(`session-handoff/refresh` → `session-handoff/pending { items: [{ key, sid, from, name }] }`):列自己的收件箱,从 key 解析出 sid / 发送人 / 会话名,不下载。面板加载时和每次发送、接收后各拉一次,没有推送。
|
||
- **接收**(`session-handoff/accept { key }`):key 必须解析得出、且落在我在当前仓库的收件箱里,否则拒绝 → 下载(包名固定,不拿 key 拼本机路径)→ 解包、校验清单(收件人是我、会话 id 和 key 一致)→ 工作区路径不同就改写会话里的路径 → 装到 `projectsDirFor(workspaceRoot)`(先清同 sid 副本)→ 写会话 tab 记录 → 删 OSS 上的包(失败只 warn)。包已不在(别处收过)时提示并刷新列表。
|
||
- 进度提示和工单移交一样:同一条 toast 依次显示 关闭终端、收集会话、`打包 x MB`、`上传 x MB / y MB`;接收方 `下载 x MB / y MB`、解包、装会话、写入会话列表,完成前不消失。
|
||
|
||
## 附带修正
|
||
|
||
完成列流程里「把 jsonl 拷回主仓库」(`panel/handlers/issues.ts` ~L388)改为整包**搬迁**:jsonl + `<sid>/` 一起移动到 `projectsDirFor(workspaceRoot)`,并删除源文件——否则新版 claude 因重复 sid 拒绝 resume。与接管方第 4 步共用同一个安装函数。
|
||
|
||
## 模块划分
|
||
|
||
| 模块 | 职责 | 测试 |
|
||
|---|---|---|
|
||
| `cc/sessionBundle.ts` | 纯 fs:`findClaudeSessionFiles(sid, projectsRoot)`、`installClaudeSession(sid, srcDir, dstProjectsDir, projectsRoot)`(先删本机所有副本再拷)、`codexRolloutPathFor(sid, codexRoot)`、`installCodexSession(relPath, srcFile, codexRoot)` | 临时目录 vitest |
|
||
| `cc/handoffManifest.ts` | 清单类型、`buildHandoffManifest(...)`、`parseHandoffManifest(json)`、OSS 对象 key `handoffBundleKey(...)` | 纯函数 vitest |
|
||
| `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile, { onProgress })` / `unpackHandoff(file, dstDir)`,xz 1 级,工单移交和会话交接共用 | 真 tar 往返 vitest |
|
||
| `cc/sessionHandoff.ts` | 会话交接:收件箱前缀、包 key 的生成与解析,`managed-session` 清单 | 纯函数 vitest |
|
||
| `git/worktreePath.ts` | 从 `panel/handlers/sessions.ts` 抽出 `resolveWorktreeDir`、`deriveSlug`,新增 `slugFromBranch` | 纯函数 vitest |
|
||
| `git/worktree.ts` | 导出 `runGit`、`findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` | `test/git/` mock child_process |
|
||
| `gitea/api.ts` | `listRepoAssignees` | — |
|
||
| `oss/handoffStore.ts` | 纯 Node:`uploadHandoffBundle`(分片上传、私有权限、进度)、`downloadHandoffBundle`(流式写文件、进度、空闲超时)、`deleteHandoffBundle`、`listHandoffBundles`(按前缀列、翻页)、`isHandoffBundleMissing` | 真机往返脚本(不进仓库) |
|
||
| `oss/handoffStoreSetup.ts` | `readHandoffStore(ctx)`、「配置移交存储」命令、`saveHandoffStore`(命令和 URI 共用) | — |
|
||
| `oss/handoffStoreEnv.ts` | `parseHandoffStoreEnv(text)`:从 env 文本读四项 | 纯函数 vitest |
|
||
| `uri/uriHandler.ts` | 外部 URI 按 path 分发:`/create-session`、`/configure-handoff-store`,未知 path 报错 | — |
|
||
| `panel/handlers/handoff.ts` | 纯决策:`canStartHandoff(issue)`、`canAcceptHandoff(issue, me)`、`handoffStateExtra` / `handoffUiPatch` 两套 payload、`worktreePushedCheck` 结果判定、`transferProgressText` 进度文案 | vitest |
|
||
| `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers`;`progressToast`、`requireHandoffStore` 会话交接也用 | — |
|
||
| `panel/handlers/sessionHandoff.ts` | 会话交接:选人、发送、待接收列表、接收 | — |
|
||
| webview | `HandoffModal.tsx`、`IssueDetailPanel` 两个按钮、`IssueCard` 角标、`useIssues` 三个 postMessage、`me` 状态 | — |
|
||
|
||
消息(`src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts` 镜像):
|
||
|
||
- webview → 扩展:`handoff/users { issueNumber }`、`handoff/start { issueNumber, to }`、`handoff/accept { issueNumber }`
|
||
- 扩展 → webview:`handoff/users-result { issueNumber, users: string[] }`;`issues/update` 增 `me`;`issue/patch` 增 `handoffBundleKey`、`handoffFrom`、四个 sid(可 null)
|
||
|
||
## 验证
|
||
|
||
- `pnpm test` / `pnpm lint` / `pnpm typecheck`;`cd cli && make test`。
|
||
- OSS 真机往返(临时脚本,不进仓库,对象放 `spx-handoff/_selftest/` 下):上传 → 匿名 `curl` 必须 403 → 下载 → 解包一致 → 删除 → 匿名 `curl` 404、带密钥下载报 NoSuchKey。分片上传(≥100KB)和单次上传(<100KB)两条路都测私有权限。
|
||
- 会话交接真机往返(同上):上传进收件箱 → 按收件人前缀列到、key 解析回 sid / 发送人 / 会话名,相近名字的收件箱列不到 → 匿名 `curl` 403 → 同一 sid 再交一次只剩新包 → 下载解包一致 → 删除后收件箱为空、再下载报对象不存在 → `_selftest/` 下 0 个对象。
|
||
- 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。
|
||
|
||
## 不做
|
||
|
||
- 撤销移交、移交历史。
|
||
- 发送方保留 worktree。
|
||
- YouTrack 工单移交。
|
||
- 清理本机存量的重复 sid。
|