Files
superwork/docs/superpowers/specs/issue-handoff/spec.md
T
cruldra fcee4143d4 ✨ feat(vscode): 会话交接改走阿里云 OSS 收件箱、换 xz 压缩,另加从 env 文件配置移交存储的 URI
- 会话包传到 OSS 上收件人的收件箱 spx-handoff/<owner>/<repo>/session/<收件人>/<sid>!<发送人>!<会话名>.tar.xz,对象私有;owner/repo/收件人转小写
- 待接收列表就是列自己的收件箱;同一会话再交给同一人先删旧包;接收后删包,包已被别处收走时提示并刷新
- 打包换成 xz 1 级,和工单移交共用 packHandoff / unpackHandoff,删掉 gzip 版
- 删掉邮箱工单整套(mailboxIssue、MAILBOX_*、isMailboxIssue 及看板 / webhook 里的过滤、附件名编解码)和不再用的 Gitea 附件、标签、建工单接口
- session-handoff/accept 与待接收项改用 OSS 对象 key 标识
- 没配移交存储时发送、接收、刷新都提示去配,刷新回空列表
- 进度提示照工单移交:同一条提示依次显示打包、上传、下载多少 MB、解包、装会话
- 新增 URI configure-handoff-store?envFile=<绝对路径>:读 OSS_ENDPOINT / OSS_BUCKET_NAME / OSS_ACCESS_KEY / OSS_SECRET_KEY 保存,和命令共用 saveHandoffStore;URI 入口按 path 统一分发,未知 path 报错
2026-10-08 07:17:26 +08:00

18 KiB
Raw Blame History

工单移交(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:

{
  "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 新字段

字段 类型 说明
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://),四项齐了按命令同样的方式保存并提示「移交存储已保存:()」;参数不对、文件读不到、缺项只报错(只报变量名),什么都不改。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,取本机字段(四个 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/(跳过 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(只删本地分支,远端分支必须保留给接管方);本机字段全部写 '' 墓碑。发送方磁盘上的会话文件保留。
  9. issue/patch { handoffBundleKey, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null } + 成功 toast。

进度 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. 写本机字段:清单里的四个 sid、三个 profile 路径、worktreePath。
  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。