Files

144 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 工单移交(handoff)设计
## 目标
同事在 A 机器上开发/测试完一个工单,把它整体交给 B 机器上的我继续(合并、解冲突、测试、续改)。B 机器上要能:
- 重建实施 worktree(分支从 origin 拉)
- `claude --resume` 同事的头脑风暴 / 实施 / 测试会话,`codex resume` 审查会话
- 之后所有既有流程(拖到完成、冲突解决、测试会话、恢复实施会话)照常工作
通道选 **Gitea 工单附件**:会话文件打成 tgz 挂在工单上,移交状态记在共享 state JSON 里。不引入 Tailscale / ssh 之类点对点传输。
## 事实前提
- 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 无关。
- Gitea 1.27:附件 `max_size=100MB`,允许 `.tgz``POST/GET/DELETE /repos/{owner}/{repo}/issues/{index}/assets[/{id}]`
- 共享 state JSON 由 `vscode/schemas/state-json.schema.json` 约束(`additionalProperties: false`),spx 写入前校验;本机字段走 workspaceState`issues/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`
```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.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` 镜像)新增 `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 / `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/``reviewSessionId``~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/<relPath>`。找不到的会话不进清单,只 log。写 `handoff.json``tar -czf`
5. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。
6. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`
7. `updateIssueAssignees(… [to])`
8. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree``removeWorktreeDir``cleanupFeatureBranch` **不传 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` 存在且 `assignees``me`
扩展端 `handleHandoffAccept(panel, issueNumber)` 顺序:
1. 前置同上。读共享 state 取 `handoffAttachmentId``branch``pr`
2. `GET …/assets/{id}``browser_download_url`,带 `Authorization: token` 下载到 scratch 临时目录,`tar -xzf`,解析并校验 `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, { handoffAttachmentId: '', handoffFrom: '' })``DELETE …/assets/{id}`(失败只 warn)。
7. `issue/patch`sid、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` | 纯 fs`findClaudeSessionFiles(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` 抽出 `resolveWorktreeDir``deriveSlug`,新增 `slugFromBranch` | 纯函数 vitest |
| `git/worktree.ts` | 导出 `runGit``findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` | `test/git/` mock child_process |
| `gitea/api.ts` | `listRepoAssignees``listIssueAttachments``uploadIssueAttachment`FormData + Blob)、`getIssueAttachment``downloadAttachment`arrayBuffer,不吞错)、`deleteIssueAttachment` | — |
| `panel/handlers/handoff.ts` | 纯决策:`canStartHandoff(issue)``canAcceptHandoff(issue, me)``handoffStateExtra` / `handoffUiPatch` 两套 payload、`worktreePushedCheck` 结果判定 | vitest |
| `panel/handlers/handoffFlow.ts` | 两个 orchestrator`handleHandoffStart``handleHandoffAccept``handleHandoffUsers` | — |
| 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``handoffAttachmentId``handoffFrom`、四个 sid(可 null
## 验证
- `pnpm test` / `pnpm lint` / `pnpm typecheck``cd cli && make test`
- 实施第一步先用 curl 验证 `Authorization: token` 能下载 `browser_download_url`(Gitea 私有仓库附件走 web 路由);不通则改走 `GET /attachments/{uuid}`
等价路径,写进 `downloadAttachment`
- 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。
## 不做
- 撤销移交、移交历史。
- 发送方保留 worktree。
- YouTrack 工单移交。
- 清理本机存量的重复 sid。