dsh-plugin-worktrees 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +120 -0
- package/CHANGELOG.md +266 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.zh.md +151 -0
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +30 -0
- package/docs/DESIGN.md +783 -0
- package/docs/TASKS.md +164 -0
- package/lib/config.js +126 -0
- package/lib/engine-face.js +328 -0
- package/lib/git-port.js +773 -0
- package/lib/index.js +402 -0
- package/lib/merge-queue.js +832 -0
- package/lib/naming.js +107 -0
- package/lib/repo-gate.js +202 -0
- package/lib/state-store.js +512 -0
- package/lib/tools/worktree-cleanup.js +127 -0
- package/lib/tools/worktree-create.js +212 -0
- package/lib/tools/worktree-list.js +234 -0
- package/lib/tools/worktree-merge.js +396 -0
- package/lib/tools/worktree-queue.js +330 -0
- package/lib/tools/worktree-status.js +194 -0
- package/lib/worktree-service.js +673 -0
- package/package.json +55 -0
- package/scripts/link-harness-dsh-tools.sh +95 -0
- package/scripts/lint.js +142 -0
package/docs/DESIGN.md
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# dsh-worktrees — git worktree 并行写隔离 + 串行集成本插件 · 架构设计
|
|
2
|
+
|
|
3
|
+
> 状态:设计定稿(2026-08-16)。实现前配合 `docs/TASKS.md` 阅读。
|
|
4
|
+
> 移植源:task-weaver `packages/workspaces/`(6 文件 ~3.0k 行,逐文件实读)。
|
|
5
|
+
> 范式样板:`dsh-plugin-subagents`(apply() 模式、zod strict、一模块一工具、node:test 纪律)。
|
|
6
|
+
> 前期基线:`dsh-plugin-subagents/docs/task-weaver-integration-analysis.md` §3 处置清单、§4 DSH 落地硬约束(全部适用)。
|
|
7
|
+
> 本文所有「直搬/改写」论断均标注 task-weaver 源文件 + 函数名 + 行号(证据式设计)。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 0. 已拍板决策(承自分析文档 §0/§3,不再作为开放问题)
|
|
12
|
+
|
|
13
|
+
| # | 决策 | 来源 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| D1 | 插件名 `dsh-worktrees`,纯 tool 插件(MVP 无 UI、无 preset 适配、无 host 补丁) | 任务书 + 分析 §3.1 |
|
|
16
|
+
| D2 | 不拆 dsh-approvals;dsh-dag-orchestrator 随宿主生死 —— 本插件只为其留组合缝(§10) | 分析 §0 用户裁决 |
|
|
17
|
+
| D3 | 与 dsh-plugin-subagents 正交组合:subagents 的 per-call `cwd`(两枚补丁)以本插件返回的 worktree 路径为 cwd 派子代理 | 任务书核心卖点 |
|
|
18
|
+
| D4 | merge-queue 的 bun:sqlite 依赖面(经 ServiceContext)整体替换为 JSON 原子写状态文件(分析 §3.1「仅一处 bun:sqlite 需换」) | 分析 §3 |
|
|
19
|
+
| D5 | Plain JS ESM、零新增运行时依赖(git 用 child_process argv 数组) | 任务书硬约束 |
|
|
20
|
+
|
|
21
|
+
## 0.1 本设计新定案的决策(原开放问题,已在本文论证)
|
|
22
|
+
|
|
23
|
+
| # | 决策 | 论证位置 |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| D6 | GitPort 自建(直搬 `runGit`),不走宿主 `ctx.subprocess` | §4.2 |
|
|
26
|
+
| D7 | merge 应用动作用 `git merge --no-ff`(非 task-weaver 的 cherry-pick) | §6.3 |
|
|
27
|
+
| D8 | 队列状态存单个 JSON 文件(原子写 0600),串行化用进程内 promise 链;跨进程互斥显式声明为 MVP 非目标 | §7.1 |
|
|
28
|
+
| D9 | 集成分支默认会话级 `dsh-wt/integration/<session>`,不默认碰用户既有分支 | §6.2 / 开放问题 O1 |
|
|
29
|
+
| D10 | 工具面 6 件:`worktree_create / list / status / merge / queue / cleanup`(比任务书建议多一件 `worktree_queue`,理由 §5.1) | §5 |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. 目标与非目标
|
|
34
|
+
|
|
35
|
+
### 1.1 目标(MVP)
|
|
36
|
+
|
|
37
|
+
1. **并行写隔离**:一次并行任务 = 一个独立 git worktree(专属分支),每个并行 subagent 真隔离写,互不踩踏(对齐 task-weaver「parallel write tasks NEVER share a workspace」不变量,`workspace-service.ts` L14-17 注释)。
|
|
38
|
+
2. **串行集成**:完成后的分支经串行 merge queue 逐个合入集成分支;冲突保留现场(integration worktree + 冲突清单 + 源分支全部保留,照 `merge-queue.ts` L33-37 注释「保留源 Worktree、分支、patch、冲突清单和日志」)。
|
|
39
|
+
3. **与 subagents 组合**:`worktree_create` 返回值直接给出「以该路径为 cwd 派 subagent」的调用提示;两者零耦合、可各自独立安装。
|
|
40
|
+
4. **崩溃对账**:DSH 重启后 apply() 先对账(`git worktree list` 对照状态文件),残留 worktree / 中断 job 有一致说法(分析 §4-C4)。
|
|
41
|
+
5. **唯一 git 出口**:一切 git 经 GitPort(argv 数组、`shell:false`、超时、上限缓冲;照 `git-port.ts` L1-13 模块头硬规则)。
|
|
42
|
+
6. **可安装性**:`dsh plugin add` 一条路径生效(bundle patch 仅一行 insert,§8.3)。
|
|
43
|
+
|
|
44
|
+
### 1.2 非目标(scope 边界)
|
|
45
|
+
|
|
46
|
+
- **不做 UI**:aionui-panel 的 Explorer/SCM 面板 + dsh-git-graph 已覆盖可视化;本插件 MVP 纯 tool(任务书裁决)。
|
|
47
|
+
- **不做 DAG**:依赖编排是 dsh-dag-orchestrator 的事;本插件只保证队列 API 可被其复用(§10)。
|
|
48
|
+
- **不做审批**:DSH 用户在环 + 原生 approval policy 已覆盖(分析 §3 裁撤理由)。
|
|
49
|
+
- **不自动 push**:MVP 产出可审阅的集成分支提交,永不 push 远端(照 task-weaver「No auto-push (M2 produces a reviewable integration commit only)」,`merge-queue.ts` L25)。GitPort 接口**按构造排除** push/fetch/clone(源接口 `git-port.ts` L93-164 即无这些方法,照搬即得)。
|
|
50
|
+
- **不迁移 task-weaver 的三模式全量**:`read_only` / `direct`(目录项目排他写)/ lineage 租约(串行任务共享一个 worktree)全部裁掉 —— DSH 会话模型没有 run/artifact 投影可挂靠,MVP 只保留 `worktree` 模式(§9 迁移表逐条给理由)。
|
|
51
|
+
- **不做跨进程互斥**:一个 DSH 宿主进程内串行;多 dsh 实例同时操作同一 repo 的互斥显式声明为非目标(README 风险说明;O5 开放问题)。
|
|
52
|
+
- **不做 preset 适配 / 官方工具接管**:全部工具是新名字(`worktree_*` 前缀),全局层新增名字对所有会话可见,无需 disable 官方行(subagents DESIGN §2.3-B1 同一机制结论:host-plane 新名字工具天然全局可见)。
|
|
53
|
+
- **不做 subagent cwd 补丁的分发**:cwd 补丁属于 dsh-plugin-subagents 的 `patches/`;本插件 README 只做使用指引(正交组合的边界)。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 2. 现状与依赖(证据基线)
|
|
58
|
+
|
|
59
|
+
### 2.1 移植源:task-weaver `packages/workspaces/`(逐文件实读)
|
|
60
|
+
|
|
61
|
+
| 文件 | 行数 | 职责 | 关键接口(证据) |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| `git-port.ts` | 585 | 唯一 git 出口:argv spawn + 超时 + 8MiB 缓冲上限 + porcelain 解析 | `GitPort` 接口(L93-164,13 方法);`runGit`(L173-245);`BunGitPort`(L358-585);`parseGitStatusPorcelain`(L268-304);`parseGitNameStatus`(L317-349) |
|
|
64
|
+
| `workspace-service.ts` | 690 | 三模式工作区供给 | `prepareWorkspace`(L144-158);worktree 分支命名 `weaver/run/{run}/task/{task}/{attempt}`(L287);物理路径布局(L650-661);`repoIdFromRoot` FNV-1a(L682-690) |
|
|
65
|
+
| `merge-queue.ts` | 1154 | 串行集成队列 | `enqueue`(L205-251);`applyNext` 十步算法(L428-493 骨架 + `#applyNextBody` L495-845);`cancel/retry/markResolved`(L266-400);one-active 不变量(L433-451);冲突保留(L826-844 finally) |
|
|
66
|
+
| `lineage-lease.ts` | 97 | 串行任务共享 worktree 的租约 | `acquireLineageLease`(L39-84)—— 依赖 `ResourceLeaseRepositoryPort`,DSH 无此设施 → 裁掉 |
|
|
67
|
+
| `change-collector.ts` | 468 | 写任务完成 → 提交 + 产物登记 | `collectGit`(L162-273):status → no_changes 判定(L186-199)→ `commitAll`(L207)→ headCommit;directory 模式 + `hashDirectory`(L279-468)→ 裁掉 |
|
|
68
|
+
| `index.ts` | 17 | 包出口 | —— → 裁掉(插件入口是 apply()) |
|
|
69
|
+
|
|
70
|
+
**merge-queue 十步算法**(`applyNext` docstring L409-427 + 实现,本设计 §6 直接对照):
|
|
71
|
+
1. one-active 拒绝(queued OR applying OR conflicted 均视为占线;conflicted 持续占线直到 out-of-band resolve);
|
|
72
|
+
2. 取集成分支排他租约;
|
|
73
|
+
3. 取 orderIndex 最小的 queued job,CAS `queued → applying`;
|
|
74
|
+
4. 加载并校验源产物(git-commit-v1 payload);
|
|
75
|
+
5. 在集成分支 HEAD 预置 integration worktree(`ensureBranch` 自 HEAD 引导 + `addWorktreeAt`);
|
|
76
|
+
6. 记录 `integrationHeadBefore` + `sourceCommit`;
|
|
77
|
+
7. 应用源提交(task-weaver 用 cherry-pick);
|
|
78
|
+
8. 冲突 → CAS `applying → conflicted`,记录冲突文件清单,**保留 worktree**;
|
|
79
|
+
9. 成功 → 产生 integrated-commit 产物,CAS `applying → succeeded`;
|
|
80
|
+
10. 仅当无 conflicted 持线时释放租约;finally:成功/硬失败清理 integration worktree,**conflicted 保留**(L826-844)。
|
|
81
|
+
|
|
82
|
+
### 2.2 DSH 宿主机制(实读 `.d.ts` 核实)
|
|
83
|
+
|
|
84
|
+
- **工具注册**:`ctx.tools.register(defineTool({...}))`;`defineTool` 参数面 = `ParameterSchemaSpec`(每属性 `{type, required?, description?, enum?, items?}`,隐式开放对象根),输出面 = `ValueSchemaSpec`(`oneOf` 分支对象需 `additionalProperties: false` + 逐属性 `required`)—— `dsh-tools/lib/types/schema.d.ts` L55-94 + `DefineToolOptions` L177-239。工具返回值经无损 JSON 快照校验(subagents `lib/json-safe.js` 头注的 E3 教训:undefined 值键整体被拒 → 一切返回经 `toLosslessJson` 或条件展开构造)。
|
|
85
|
+
- **执行上下文**:`execute(args, exec)`;`exec.agent.session.header.cwd` 是父会话 cwd(subagents `lib/tools/subagent-submit.js` L107 实证用法);`exec.signal` 是取消信号(`ToolRunContext`,dsh-tools types L283-300)。
|
|
86
|
+
- **workspaceRegistry 门禁先例**:aionui-panel `src/host/gate.ts` `createWorkspaceGate`(L48-72):`realpath(root)` 规范化 → `ctx.workspaceRegistry.list()` 逐 workspace `isPathInside` 判定 → 未注册即拒(`workspace-unknown`)。本插件 repo 门禁照此(§5.2.0)。
|
|
87
|
+
- **workspaceRegistry 形状**:`dsh-workspace/lib/types/index.d.ts` —— `list(): Workspace[]`(同步投影,L86-92)、`resolveByPath(path)`(L139,realpath 失败即拒)。可选依赖(headless 缺席时降级为 session-cwd 子树门禁,§5.2.0)。
|
|
88
|
+
- **原子写先例**:`@deepseek-ai/dsh-atomic-write` `writeFileAtomic`(随机后缀兄弟文件 `wx` 独占创建 + rename,防 symlink 陷阱,保 mode)+ subagents `lib/registry.js`(tmp+rename、0600、best-effort chmod,L52-55)。本插件本地实现同款纪律(不新增 peer,§7.1)。
|
|
89
|
+
- **apply() 返回值必须 undefined**(Cordis disposable 校验 TypeError,分析 §4;subagents `lib/index.js` L25-26 注释)。
|
|
90
|
+
- **C6 peer 双实例陷阱**:`@deepseek-ai/*` 必须 peerDependencies + symlink 到 live harness 根(分析 §4-C6;subagents `patches/install.sh` A 段模式)。本插件 peer 面最小化:`cordis` + `dsh-tools`(§8.2)。
|
|
91
|
+
- **`Promise.withResolvers` 需 Node ≥22**(分析 §5-R1):本插件不用它,手写 deferred(照 subagents `lib/tools/subagent-wait.js` L70-77 的 Promise 构造器模式);`engines` 定 `>=18` 对齐家族。
|
|
92
|
+
|
|
93
|
+
### 2.3 组合对象:dsh-plugin-subagents 的 per-call cwd
|
|
94
|
+
|
|
95
|
+
- `subagent` 工具有 `cwd` 参数(`lib/tools/subagent.js` L171-173:绝对路径,需其 `patches/install` 就位);`assertCwd`(`lib/native-delegate.js` L28-38)要求绝对路径且为可访问目录 —— **本插件返回的 worktree 路径天然满足**。
|
|
96
|
+
- 组合时序:`worktree_create` → 拿 `path` → `subagent(prompt, cwd: path)` ×N 并行 → 各自完成后 `worktree_merge` ×N 串行 → `worktree_cleanup`。
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 3. 总体架构
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
┌────────────────────────────────────────────────────────────────────┐
|
|
104
|
+
│ DSH 宿主(Cordis) │
|
|
105
|
+
│ │
|
|
106
|
+
│ apply(ctx, config) ← lib/index.js │
|
|
107
|
+
│ ├─ validateConfig(zod strict,lib/config.js) │
|
|
108
|
+
│ ├─ reconcile(崩溃对账:状态文件 × git worktree list,§7.3) │
|
|
109
|
+
│ └─ 注册 6 工具(lib/tools/*.js → ctx.tools.register(defineTool)) │
|
|
110
|
+
│ │
|
|
111
|
+
│ 工具层(模型可见) 引擎层(工具共享,单实例持有) │
|
|
112
|
+
│ ┌──────────────────┐ ┌───────────────────────────┐ │
|
|
113
|
+
│ │ worktree_create │──create────▶│ WorktreeService │ │
|
|
114
|
+
│ │ worktree_list │──list/status│ (分支/路径命名、CRUD、 │ │
|
|
115
|
+
│ │ worktree_status │ │ cleanup 保护判定) │ │
|
|
116
|
+
│ │ worktree_merge │──enqueue──▶│ MergeQueue │ │
|
|
117
|
+
│ │ worktree_queue │──commands──│ (enqueue/applyNext/ │ │
|
|
118
|
+
│ │ worktree_cleanup │ │ cancel/retry/resolve) │ │
|
|
119
|
+
│ └──────────────────┘ ├───────────────────────────┤ │
|
|
120
|
+
│ │ StateStore(JSON 原子写) │ │
|
|
121
|
+
│ │ RepoGate(canonical + 门禁)│ │
|
|
122
|
+
│ │ GitPort(唯一 git 出口) │ │
|
|
123
|
+
│ └───────────────────────────┘ │
|
|
124
|
+
│ 正交组合(无代码耦合):subagent 工具的 cwd 参数 ← worktree path │
|
|
125
|
+
└────────────────────────────────────────────────────────────────────┘
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
分层纪律(照 subagents「工具薄、逻辑在共享模块」+ task-weaver 端口模式):
|
|
129
|
+
|
|
130
|
+
- **工具层只做**:参数 schema、门禁调用、把引擎结果组装成无损 JSON 返回。不含 git 调用、不含状态写。
|
|
131
|
+
- **引擎层**:`GitPort` 是唯一 git 出口;`WorktreeService` / `MergeQueue` 只经 GitPort 碰 git;`StateStore` 是唯一持久化出口。MergeQueue 与 WorktreeService 的依赖以构造注入(`{ git, store }`),**不 import 宿主服务** —— 这是给 dag-orchestrator 复用的缝(§10)。
|
|
132
|
+
- **单实例状态**:WorktreeService/MergeQueue/StateStore 只存在于 `apply()` 建立的唯一闭包(照 subagents 红线 10「共享状态单实例持有」)。
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 4. GitPort 直搬设计(`lib/git-port.js`)
|
|
137
|
+
|
|
138
|
+
### 4.1 直搬范围与改写点
|
|
139
|
+
|
|
140
|
+
`git-port.ts`(585 行)→ `lib/git-port.js`(预计 ~520 行):
|
|
141
|
+
|
|
142
|
+
**逐行直搬**(TS 类型抹除即可,无逻辑改动):
|
|
143
|
+
|
|
144
|
+
| 成员 | 源证据 | 说明 |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `DEFAULT_GIT_TIMEOUT_MS = 15_000` | L17 | 与 testkit fixture 同界 |
|
|
147
|
+
| `MAX_BUFFER = 8MiB` 双流独立上限 | L20 + L185-223 | 逐 chunk 记账、超限收尾部 + `[output capped]` 标记 |
|
|
148
|
+
| `runGit(cwd, args, {env, timeoutMs})` | L173-245 | `spawn("git", [...args], {shell:false, stdio:["ignore","pipe","pipe"]})`;SIGKILL 超时;非零退出是**正常结果**(`status` 字段),只有 spawn 失败/超时才 reject |
|
|
149
|
+
| `parseGitStatusPorcelain` | L268-304 | XY 码、rename `orig -> target` 取 target(M2 修复:x 或 y 为 R/C 都算)、引号剥离、`?`→added 映射 |
|
|
150
|
+
| `parseGitNameStatus` | L317-349 | diff-tree `--name-status -r`;R/C 双 tab 取 target;未知字母 → unknown |
|
|
151
|
+
| `resolveRef` | L395-406 | **`--verify --end-of-options <ref>^{commit}`** —— 调用方 ref 永不可能注入 flag(安全关键,照抄) |
|
|
152
|
+
| `isGitRepo` / `resolveHead` / `status` | L367-393 / L562-571 | |
|
|
153
|
+
| `createWorktree(repoRoot, branch, baseCommit?)` | L408-426 | `worktree add -b <branch> <path> [<base>]`;path 由调用方预先解析 |
|
|
154
|
+
| `removeWorktree` | L428-436 | `--force` + 幂等(缺失吞掉) |
|
|
155
|
+
| `commitAll(cwd, message)` | L438-474 | `add -A` → `commit -m` → HEAD + name-status 清单 |
|
|
156
|
+
| `checkout` / `cherryPick` / `mergeBase` | L476-509 | cherryPick 保留(retry 场景与未来 cherry 模式可用;MVP merge 路径不用它) |
|
|
157
|
+
| `listConflicts` | L511-526 | `diff --name-only --diff-filter=U`;非零 → 空清单(防御性) |
|
|
158
|
+
| `addWorktreeAt(repoRoot, path, ref)` | L528-539 | `worktree add <path> <ref>`(ref 须为已存在分支)—— integration worktree 预置用 |
|
|
159
|
+
| `ensureBranch(repoRoot, branch, startPoint)` | L541-560 | 不存在则自 startPoint 建 —— 集成分支首次引导用 |
|
|
160
|
+
| 构造器 `GIT_TERMINAL_PROMPT: "0"` | L363 | 永不交互式要凭据(无网络操作下双保险) |
|
|
161
|
+
|
|
162
|
+
**改名**:`BunGitPort` → `NodeGitPort`(类从未依赖 Bun 运行时 —— 源码 import 的是 `node:child_process`,改名只是诚实化;类名之外的零改动)。
|
|
163
|
+
|
|
164
|
+
**裁掉**:私有 `worktreePath(branch)`(L578-584,mkdtemp 于 os tmpdir 的默认布局)—— 路径决策权归 WorktreeService 的显式布局(§5.2.1);mkdtemp 临时路径策略与 DSH 的 `~/.dsh/worktrees/` 布局冲突。
|
|
165
|
+
|
|
166
|
+
**新增**(GitPort 源接口没有、本插件需要;全部 argv 数组 + `--end-of-options`/分隔符纪律):
|
|
167
|
+
|
|
168
|
+
| 成员 | argv | 用途 |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `listWorktrees(repoRoot)` | `["worktree", "list", "--porcelain"]` | status/list/崩溃对账;解析 `worktree <path>` / `HEAD <sha>` / `branch refs/heads/<name>` / `detached` 块(空行分隔);新解析函数 `parseWorktreeListPorcelain`(纯函数,进测试表) |
|
|
171
|
+
| `isAncestor(repoRoot, a, b)` | `["merge-base", "--is-ancestor", a, b]`(exit 0/1,非零非错误) | cleanup 保护判定:源分支头是否已进集成分支 |
|
|
172
|
+
| `validateBranch(repoRoot, name)` | `["check-ref-format", "--branch", name]` | 模型给的 task slug → 分支名前置校验(拒绝 `..`、空格、前导 `-`、控制符) |
|
|
173
|
+
| `mergeNoFf(cwd, branch, message)` | `["merge", "--no-ff", "--no-edit", "-m", message, "--", branch]` | `--` 终结符后置分支名,与 subagents 红线 7「任务文本永远在 `--` 之后」同一纪律 |
|
|
174
|
+
| `abortMerge(cwd)` | `["merge", "--abort"]` | hard-failure 回收路径(冲突保留路径**不**调用它 —— 现场必须留) |
|
|
175
|
+
|
|
176
|
+
> `mergeNoFf` 的 `--` 边界:`git merge` 的 branch 位置参数本身不是 flag 位置,但守恒纪律(任何调用方可控字符串不落 flag 位)统一用 `--` 显式终结,且分支名先经 `validateBranch`。
|
|
177
|
+
|
|
178
|
+
### 4.2 spawn 策略论证:自建 runGit vs 宿主 ctx.subprocess
|
|
179
|
+
|
|
180
|
+
**结论(D6):直搬 `runGit`(自建),不依赖 `ctx.subprocess`;留注入缝。**
|
|
181
|
+
|
|
182
|
+
对照实读:
|
|
183
|
+
|
|
184
|
+
| 维度 | 自建(git-port.ts `runGit` 直搬) | 宿主 `ctx.subprocess.spawn` |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| argv 纪律 | `spawn("git", argv, {shell:false})` —— **就是为 git 写的**,语义已含超时 SIGKILL + 双流 8MiB 记账上限 | `SubprocessSpawnSpec {argv, cwd, stdio, graceMs}` 全显式、`argv` 永不 shell 解释(`dsh-subprocess/lib/types/types.d.ts` L67-96)—— 纪律等价 |
|
|
187
|
+
| 环境策略 | `{...process.env, ...opts.env}` 全量继承 | `scrubbedParentEnv()` 剥离凭据形态 env —— **更严**,但语义变化:git 子进程拿不到 ambient 凭据(本插件无网络操作,实际影响≈0;且 `GIT_TERMINAL_PROMPT=0` 已在 GitPort 层面杜绝凭据交互) |
|
|
188
|
+
| 输出获取 | 同步收齐 stdout/stderr + status —— git porcelain 消费方的天然形状 | offset 流式 reader(`SubprocessOutputReader.readFrom`)—— 为长进程增量读取设计,短命 git 命令用它是杀鸡牛刀 |
|
|
189
|
+
| Windows | `git` 是**原生可执行文件**(git.exe),Node spawn 自带 PATHEXT 解析可直找,**不经 npm `.cmd` shim** —— subagents `lib/run.js` 解决的 `.cmd` shim 问题对 git 不存在 | 同样无 shim 问题 |
|
|
190
|
+
| 可测性 | 无需宿主 fake:node:test 直跑(task-weaver `git-port.test.ts` 即此形态,测试思路整体可迁) | 每个测试都要 fake `ctx.subprocess` |
|
|
191
|
+
| 耦合 | 零宿主服务依赖(插件自包含) | 增加 inject + peer 面 |
|
|
192
|
+
|
|
193
|
+
subagents `lib/run.js` 的定位差异(为何**不**直接复用它):`run.js` 解决的是「产品 CLI = npm `.cmd` shim + 长驻流式进程 + 进程树击杀 + 输出脱敏」(其头注 L5-29),捕获是「保留尾部 8MB 字符串」的流式模型;GitPort 需要的是「短命命令 + 精确 exit status + 双流独立字节上限」—— `runGit` 的模型恰好吻合且已经是被测代码。**两者不合并**,各自保持单一职责(run.js 不在本仓库)。
|
|
194
|
+
|
|
195
|
+
**注入缝**:`createGitPort({ spawnImpl })` —— 默认 `runGit`;测试与未来迁移可注入 fake。这是「今天自包含、明天可换宿主服务」的最小缝,不引入当前复杂度。
|
|
196
|
+
|
|
197
|
+
### 4.3 env 与安全补充
|
|
198
|
+
|
|
199
|
+
- 基座 `{...process.env, GIT_TERMINAL_PROMPT: "0"}`(源 L363 语义照搬);MVP 不做 env 白名单收紧(分析 §2「env 白名单投影」定为 opt-in 高价值项,属 subagents 插件领地,不在本插件重复建设)。
|
|
200
|
+
- 任务书红线「命令输出原样返回、可能含敏感信息」对 git 输出的适用性:git porcelain 输出只含路径与 oid;`stderr` 可能含仓库 URL(remote 报错时)—— 工具返回把 stderr 截断到 600 字符(照 subagents `runCommand` 的 `(stderr || stdout).slice(0, 600)` 惯例)。
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 5. 工具面设计(核心)
|
|
205
|
+
|
|
206
|
+
### 5.1 工具清单与论证
|
|
207
|
+
|
|
208
|
+
| 工具 | 一句话 | 任务书建议对照 |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `worktree_create` | 为一次并行任务建隔离 worktree(分支 + 路径 + 组合提示) | ✅ 建议 |
|
|
211
|
+
| `worktree_list` | 列出本插件管理的 worktree(含孤儿/ vanished 标记)与队列概览 | ✅ 建议 |
|
|
212
|
+
| `worktree_status` | 单个 worktree 的 git 状态 + 其 merge job 状态 | ✅ 建议 |
|
|
213
|
+
| `worktree_merge` | 收集未提交改动 → 入队 → 串行合入集成分支;冲突返回清单并保留现场 | ✅ 建议 |
|
|
214
|
+
| `worktree_queue` | 队列查询 + `cancel` / `retry` / `resolve` 命令 | ➕ **新增一件**(论证见下) |
|
|
215
|
+
| `worktree_cleanup` | 删 worktree + 分支;未合并保护 + force 双确认 | ✅ 建议 |
|
|
216
|
+
|
|
217
|
+
**为何新增 `worktree_queue`(D10)**:task-weaver 的冲突保留语义要求「conflicted job 持续占线,直到 out-of-band 解决」(`merge-queue.ts` L23-24 注释 + L836 条件 `holding.state !== "conflicted"`),且 `cancel/retry/markResolved` 是已实现并测试的三个命令(L266-400)。若没有释放动词,任何一次冲突都把该集成分支的队列**永久死锁**——这不是功能增项而是冲突保留模型的必要闭环。折叠进 `worktree_merge` 的 action 参数会污染其 schema(merge 的输入是 worktree_id,命令的输入是 job_id,两套域);独立成件保持每工具单一动词(subagents「一模块一工具」纪律)。
|
|
218
|
+
|
|
219
|
+
### 5.2 公共机制(先于各工具签名)
|
|
220
|
+
|
|
221
|
+
#### 5.2.0 repo 根解析与门禁(防路径逃逸,安全关键)
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
resolveRepoRoot(repoArg, exec, cfg):
|
|
225
|
+
1. repoArg 缺省 → exec.agent?.session?.header?.cwd(subagent-submit.js L107 同源);
|
|
226
|
+
仍缺 → throw repo_unresolved(要求显式 repo_root)
|
|
227
|
+
2. canonical = await realpath(repoArg) ← 符号链接规范化(aionui-panel gate.ts L57-60 同款)
|
|
228
|
+
失败 → throw repo_unknown("path does not resolve on disk")
|
|
229
|
+
3. isGitRepo(canonical) 失败 → throw not_a_git_repo
|
|
230
|
+
4. 门禁(按序判定,任一通过即可):
|
|
231
|
+
a. canonical 在父会话 cwd 子树内(isPathInside,gate.ts L30-45 的规范化 + 大小写不敏感比较照搬)
|
|
232
|
+
b. cfg.requireWorkspaceRegistration !== false 且 ctx.workspaceRegistry?.list 可用:
|
|
233
|
+
任一 workspace.path 包含 canonical
|
|
234
|
+
c. cfg.allowedRoots 中任一根(同样 realpath + 子树判定)包含 canonical
|
|
235
|
+
5. 全不通过 → throw repo_not_registered(报 canonical 与指引:注册 workspace 或配置 allowedRoots)
|
|
236
|
+
返回 { canonical, repoKey: repoIdFromRoot(canonical) }
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`repoIdFromRoot` 直搬 `workspace-service.ts` L682-690(FNV-1a 8-hex,反斜杠规范化 + 尾斜杠剥离)—— 同 repo 稳定同 key,worktree 父目录共享。**`allowAnyRoot` 不提供**:门禁没有「全开」档(fail closed 是默认,开洞必须走显式 `allowedRoots` 列表)。
|
|
240
|
+
|
|
241
|
+
#### 5.2.1 分支与路径命名
|
|
242
|
+
|
|
243
|
+
- **任务分支**:`dsh-wt/<session-short>/<task-slug>`
|
|
244
|
+
- `session-short` = 会话 id 尾段 8 字符(`exec.agent.session.id`);`task-slug` = 模型给的任务标识经 `sanitizeBranch`(`[^a-zA-Z0-9_.-]` → `_`,截 48 字符 —— 语义照搬 `merge-queue.ts` `sanitize` L174-176 的字符白名单思想,长度适配分支场景);全名过 `gitPort.validateBranch`。
|
|
245
|
+
- 对照 task-weaver `weaver/run/{run_id}/task/{task_id}/{attempt_id}`(L287):run→session、去 attempt(DSH 无 attempt 概念,重试语义由 retry 命令承担)。
|
|
246
|
+
- **集成分支**:默认 `dsh-wt/integration/<session-short>`(D9,O1 开放问题);`worktree_create` 可显式指定 `integration_branch`(该 repo 内同名集成分支复用同一队列)。首次 merge 时 `ensureBranch(repoRoot, branch, repoHead)` 自 HEAD 引导(源 L541-560 语义)。
|
|
247
|
+
- **物理路径**:`<worktreeRoot>/<repoKey>/<session-short>/<task-slug>`(`worktreeRoot` 默认 `~/.dsh/worktrees/`)—— 布局语义直搬 `worktreePath`(源 L650-661:`{runRoot}/worktrees/{repo_id}/{run_id}/tasks/...`),去掉 tasks/attempt 层级。全部 `node:path.join` 拼装(红线 2)。叶目录由 git 创建(`git worktree add` 要求目标不存在 —— 源 L633-635 注释明确此约束,父目录 mkdirSync recursive 预建)。
|
|
248
|
+
- **integration worktree 路径**(每次 apply 临时):`<worktreeRoot>/<repoKey>/.integration/<branchSanitized>-<jobId>` —— 唯一化照搬源 L638-641 的 `Date.now().toString(36) + random` 思路,改用 jobId(确定性更强,崩溃对账可反向定位 job)。
|
|
249
|
+
|
|
250
|
+
#### 5.2.2 记录形状(StateStore 持久化,§7)
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
// state.json 根:{ version: 1, worktrees: {}, jobs: {}, repos: {} }
|
|
254
|
+
worktree 记录:
|
|
255
|
+
{ id: 'wt_<8hex>', repoKey, repoRoot, branch, path, baseCommit,
|
|
256
|
+
integrationBranch, sessionId, task, // task-slug 原文(sanitized 前)
|
|
257
|
+
state: 'active'|'merging'|'merged'|'conflicted'|'abandoned'|'vanished',
|
|
258
|
+
headCommit?, mergeJobId?, note?,
|
|
259
|
+
origin?: 'tool'|'dag', correlationId?, // §10 引擎门面来源与相关性(可选,旧 state 兼容)
|
|
260
|
+
createdAt, updatedAt }
|
|
261
|
+
|
|
262
|
+
merge job 记录(状态机直搬 merge-queue.ts MergeJobState + 字段):
|
|
263
|
+
{ id: 'mgj_<8hex>', repoKey, integrationBranch, worktreeId,
|
|
264
|
+
sourceBranch, sourceHead, orderIndex,
|
|
265
|
+
state: 'queued'|'applying'|'succeeded'|'conflicted'|'failed'|'cancelled'|'resolved',
|
|
266
|
+
integrationWorktree?, integrationHeadBefore?, integratedCommit?,
|
|
267
|
+
conflictFiles?: string[], error?, message?,
|
|
268
|
+
origin: 'tool'|'dag', correlationId?, // §10 DAG 组合缝(day-one 字段)
|
|
269
|
+
createdAt, updatedAt }
|
|
270
|
+
|
|
271
|
+
repo 记录:
|
|
272
|
+
{ repoKey: { root, lastSeenAt } } // 崩溃对账的 repo 枚举源
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### 5.3 `worktree_create`
|
|
276
|
+
|
|
277
|
+
**参数 schema**(dsh-tools `ParameterSchemaSpec` 形状):
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
parameters: {
|
|
281
|
+
task: { type: 'string', required: true,
|
|
282
|
+
description: 'Short task slug identifying the parallel task this worktree isolates (e.g. "refactor-auth", "fix-231"). Sanitized into the branch name dsh-wt/<session>/<task>.' },
|
|
283
|
+
repo_root: { type: 'string',
|
|
284
|
+
description: 'Absolute git repository root. Defaults to the current session working directory. Must resolve inside a registered workspace, the session cwd subtree, or a configured allowedRoot.' },
|
|
285
|
+
base_ref: { type: 'string',
|
|
286
|
+
description: 'Git ref to branch from (branch / tag / commit oid / HEAD). Defaults to HEAD of the repository. Never falls back to a mutable HEAD when the queue expects a fixed base.' },
|
|
287
|
+
integration_branch: { type: 'string',
|
|
288
|
+
description: 'Branch that worktree_merge will integrate into (default: dsh-wt/integration/<session>, bootstrapped from repo HEAD on first merge). Must not be a branch already checked out in another worktree of this repo.' },
|
|
289
|
+
note: { type: 'string', description: 'Optional free-text note stored on the worktree record.' },
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**行为**:resolveRepoRoot → `validateBranch(sanitizeBranch(task))` → 重名检查(StateStore 内同 repoKey 已有同分支 → loud `branch_exists`,附建议 slug)→ `maxWorktrees` 上限检查(§6.1,超限 `max_worktrees_reached` 报当前计数与清理指引)→ `resolveRef(base_ref ?? 'HEAD')`(`--end-of-options` 防注入,源 L398;**禁 mutable-HEAD 回退**语义照搬源 L268-284「saved Run base … is required; current HEAD fallback is forbidden」—— base 一律解析成具体 oid 才入记录)→ `createWorktree` → StateStore 写 `worktree` 记录(active)。
|
|
294
|
+
|
|
295
|
+
**返回**(oneOf 不需要,单对象):
|
|
296
|
+
|
|
297
|
+
```js
|
|
298
|
+
output.schema: { type: 'object', additionalProperties: false, properties: {
|
|
299
|
+
kind: { type: 'string', required: true, const: 'worktree' },
|
|
300
|
+
id: { type: 'string', required: true },
|
|
301
|
+
repo_root: { type: 'string', required: true }, // canonical
|
|
302
|
+
branch: { type: 'string', required: true },
|
|
303
|
+
path: { type: 'string', required: true }, // worktree 绝对路径
|
|
304
|
+
base_commit: { type: 'string', required: true }, // 解析后的具体 oid
|
|
305
|
+
integration_branch: { type: 'string', required: true },
|
|
306
|
+
delegate_hint: { type: 'string', required: true }, // "Delegate parallel work here via the subagent tool with cwd: <path> (each parallel task gets its own worktree; integrate with worktree_merge)."
|
|
307
|
+
note: { type: 'string' }, // 可选键条件展开,不出现 undefined 值键
|
|
308
|
+
}}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
**错误形态**(全部 throw `Error('worktree_create: <code> — <detail>')`,dsh-tools 转 isError 结果):`repo_unresolved` / `repo_unknown` / `not_a_git_repo` / `repo_not_registered` / `invalid_base_ref`(含 git stderr 截断)/ `invalid_task_slug` / `branch_exists` / `max_worktrees_reached` / `worktree_creation_failed`(源 L348-356 错误族对应)。
|
|
312
|
+
|
|
313
|
+
**组合示例(编排序列,写进工具 description 尾部)**:
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
1. worktree_create(task: "api-types") → path P1
|
|
317
|
+
2. worktree_create(task: "docs-refresh") → path P2
|
|
318
|
+
3. subagent(prompt: "…implement X…", cwd: P1) ┐ 并行(互不踩踏)
|
|
319
|
+
subagent(prompt: "…update docs…", cwd: P2) ┘
|
|
320
|
+
4. worktree_merge(worktree_id: id1) → succeeded
|
|
321
|
+
5. worktree_merge(worktree_id: id2) → conflicted(冲突清单 + 现场保留)
|
|
322
|
+
6. worktree_queue(action: "list") → 观察;人工/subagent 在 retained worktree 解决
|
|
323
|
+
7. worktree_queue(action: "resolve", job_id: …) → 释放集成分支
|
|
324
|
+
8. worktree_queue(action: "retry", job_id: …) → 重新入队(可选另一条路)
|
|
325
|
+
9. worktree_cleanup(worktree_id: id1) → 删已合并 worktree + 分支
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### 5.4 `worktree_list`
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
parameters: {
|
|
332
|
+
repo_root: { type: 'string', description: 'Filter by repository (defaults: all repos known to the state store).' },
|
|
333
|
+
include_integration: { type: 'boolean', description: 'Also list temporary integration worktrees retained from conflicts (default false).' },
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
返回 `{ kind: 'list', repo_root?, worktrees: [ { id, task, branch, path, state, head_commit?, dirty?, merge_state?, orphaned? } ], queue_summary: { active_jobs: [{repo_root, integration_branch, job_id, state}], queued_count } }`。`orphaned: true` = 状态文件有记录但 `git worktree list` 无此路径(崩溃/人为删除后的对账产物,cleanup 可清记录)。
|
|
338
|
+
|
|
339
|
+
### 5.5 `worktree_status`
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
parameters: {
|
|
343
|
+
worktree_id: { type: 'string', description: 'Worktree id from worktree_create (alternative to path).' },
|
|
344
|
+
path: { type: 'string', description: 'Absolute worktree path (alternative to worktree_id).' },
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
返回 `{ kind: 'status', id, branch, path, state, head, dirty, base_commit, ahead_of_base: n, changes: [{path, status}], merge: { job_id, state, conflict_files?, integrated_commit?, integration_worktree? } | null }`。`changes` 来自 `gitPort.status` porcelain(源 L562-571)映射 added/modified/deleted/renamed/copied/type_changed/unknown(源 `GitStatusEntry` L70-88 picklist)。`merge: null` 用条件展开实现(非 undefined 值键 —— json-safe E3 纪律)。
|
|
349
|
+
|
|
350
|
+
### 5.6 `worktree_merge`(核心工具)
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
parameters: {
|
|
354
|
+
worktree_id: { type: 'string', required: true },
|
|
355
|
+
integration_branch: { type: 'string', description: 'Overrides the integration branch recorded at create time (rarely needed).' },
|
|
356
|
+
message: { type: 'string', description: 'Merge commit message. Default: "dsh-worktrees: integrate <task> (<short source head>)".' },
|
|
357
|
+
commit_message: { type: 'string', description: 'Commit message for the auto-collected commit of uncommitted changes in the task worktree (default: "dsh-worktrees: collect <task>").' },
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`isConcurrencySafe: () => false`(变更共享队列状态;宿主调度器据此不与兄弟调用重叠)。
|
|
362
|
+
|
|
363
|
+
**执行**(= task-weaver enqueue + applyNext 的折叠,§6 详述算法):
|
|
364
|
+
|
|
365
|
+
1. 载入 worktree 记录(`worktree_not_found`);
|
|
366
|
+
2. **收集**(change-collector `collectGit` 语义直搬,源 L162-222):`status(path)` → 有未提交改动 → `commitAll(path, commit_message)` → `resolveHead` 取 sourceHead;树净且 `mergeBase(sourceHead, base) == sourceHead` → 返回 `state: 'no_changes'`(非错误——探测型调用合法;对照源 L186-199 的 no_changes 判定,但 DSH 语境降级为正常结局而非 attempt 失败);
|
|
367
|
+
3. `enqueue`(新 job:queued、orderIndex 单调、origin 'tool');
|
|
368
|
+
4. `drain(repoKey, integrationBranch)`:串行 apply 直到队列空或遇 conflicted(§6.2);
|
|
369
|
+
5. 返回本 job 的结局(若前面还有 queued job 未轮到 → `state: 'queued', queued_ahead: n`)。
|
|
370
|
+
|
|
371
|
+
**返回 oneOf**:
|
|
372
|
+
|
|
373
|
+
```js
|
|
374
|
+
output.schema: { oneOf: [
|
|
375
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
376
|
+
kind: { type:'string', required: true, const: 'merge' },
|
|
377
|
+
job_id: { type:'string', required: true }, worktree_id: { type:'string', required: true },
|
|
378
|
+
state: { type:'string', required: true, enum: ['succeeded'] },
|
|
379
|
+
integrated_commit: { type:'string', required: true },
|
|
380
|
+
integration_branch: { type:'string', required: true } } },
|
|
381
|
+
{ ... state: const 'conflicted', conflict_files: { type:'array', items:{type:'string'}, required: true },
|
|
382
|
+
integration_worktree: { type:'string', required: true },
|
|
383
|
+
resolution_hint: { type:'string', required: true } }, // "The integration worktree is RETAINED at <path> with the conflict markers in place. Resolve out-of-band (edit there, or abandon), then worktree_queue(action:'resolve'|'retry')."
|
|
384
|
+
{ ... state: const 'queued', queued_ahead: { type:'integer', required: true } },
|
|
385
|
+
{ ... state: const 'no_changes' },
|
|
386
|
+
{ ... state: const 'failed', error: { type:'string', required: true } },
|
|
387
|
+
] }
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**错误**:`active_job_exists`(有 applying/conflicted job 持线 —— **不是** throw 而是映射到返回值:conflicted 持线时本工具返回 `state:'failed', error:'active_job_exists: job <id> holds <branch> (state=conflicted); resolve or retry it first'`。语义照搬源 L436-451 step 1,但 DSH 工具协议里这是可预期业务状态而非异常);`not_a_git_repo` / `git_operation_failed`(硬失败,源 L646-657 / L718-727 错误族)。
|
|
391
|
+
|
|
392
|
+
### 5.7 `worktree_queue`
|
|
393
|
+
|
|
394
|
+
```js
|
|
395
|
+
parameters: {
|
|
396
|
+
action: { type: 'string', required: true, enum: ['list', 'cancel', 'retry', 'resolve'] },
|
|
397
|
+
repo_root: { type: 'string', description: 'list: filter by repo (default all).' },
|
|
398
|
+
integration_branch: { type: 'string', description: 'list: filter by integration branch.' },
|
|
399
|
+
job_id: { type: 'string', description: 'cancel/retry/resolve: the merge job id.' },
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
命令语义**逐条直搬** merge-queue.ts(含幂等与非法状态转移):
|
|
404
|
+
|
|
405
|
+
| action | 允许起点 | 终态 | 幂等 | 源证据 |
|
|
406
|
+
|---|---|---|---|---|
|
|
407
|
+
| `cancel` | queued → cancelled;非 queued(applying/succeeded/conflicted/failed/resolved)→ `invalid_job_state` | cancelled | 再 cancel 已 cancelled = 成功无变化 | L266-297 |
|
|
408
|
+
| `retry` | failed/conflicted → queued(清 error + 陈旧 worktree 引用);queued 再 retry = 成功无变化 | queued | ✔ | L313-353 |
|
|
409
|
+
| `resolve` | conflicted → resolved(**不自动强推内容**,操作者拥有解决方案;保留 worktree 引用供定位) | resolved | 再 resolve 已 resolved = 成功 | L368-400 |
|
|
410
|
+
|
|
411
|
+
resolve/retry 后**释放分支**(后续 queued job 可继续被 drain)—— 源 `#releaseRetainedBranchLease`(L967-975)语义,DSH 实现为:job 终态非 active 后,若 promise 链空闲则触发一次 drain。retry 对 retained conflicted worktree **不删除**(源 L309-311 注释照搬:applyNext 总是预置新 integration worktree,旧目录留给操作者;记 `abandonedIntegrationWorktrees` 待 cleanup 扫)。
|
|
412
|
+
|
|
413
|
+
`list` 返回 `{ kind:'queue', jobs: [ {job_id, worktree_id, task, repo_root, integration_branch, state, order_index, conflict_files?, integrated_commit?, error?, created_at} ], branch_holders: [ {repo_root, integration_branch, job_id, state} ] }`(branch_holders = 当前持线的 active job,让模型一眼看到谁堵着)。
|
|
414
|
+
|
|
415
|
+
### 5.8 `worktree_cleanup`
|
|
416
|
+
|
|
417
|
+
```js
|
|
418
|
+
parameters: {
|
|
419
|
+
worktree_id: { type: 'string', description: 'Worktree id (alternative: path).' },
|
|
420
|
+
path: { type: 'string' },
|
|
421
|
+
force: { type: 'boolean', description: 'Also remove a worktree whose branch is NOT yet integrated (default false → protected).' },
|
|
422
|
+
acknowledge: { type: 'boolean', description: 'Second confirmation gate: must be true together with force to delete unmerged work. Never implied.' },
|
|
423
|
+
keep_branch: { type: 'boolean', description: 'Remove the worktree directory but keep the branch (default false).' },
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**保护判定**(安全红线 4):记录 state 非 `merged` 且 `!isAncestor(repoRoot, sourceHead, integrationBranchHead)` 且 `!(force && acknowledge)` → throw `cleanup_protected`,消息含:未合并判定证据(sourceHead / integration head / merge-base)、`--force + acknowledge:true` 用法、以及「该分支包含 N 个未集成提交」计数(`rev-list --count integration..source`?—— MVP 不加:以 isAncestor 布尔 + 两个 head oid 呈现证据即可,计数留给后置)。**双确认语义**:`force` 与 `acknowledge` 是两个独立布尔(模型要显式两次表达意图),单一 `force: true` 不放行。
|
|
428
|
+
|
|
429
|
+
成功返回 `{ kind:'cleanup', id, removed_worktree: true, removed_branch: true|false, retained_worktrees_scanned? }`;对 `vanished` 记录只清状态文件(`removed_worktree: false, note: 'record pruned; path already absent from git worktree list'`)。
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## 6. MergeQueue 引擎(`lib/merge-queue.js`)
|
|
434
|
+
|
|
435
|
+
### 6.1 持久化替换:ServiceContext → StateStore
|
|
436
|
+
|
|
437
|
+
task-weaver 的 merge-queue 一切状态经 `ServiceContext`(repos.mergeJobs / resourceLeases / events + `ctx.transaction`)。DSH 替换映射(D4/D8):
|
|
438
|
+
|
|
439
|
+
| task-weaver 设施 | DSH 替换 | 理由 |
|
|
440
|
+
|---|---|---|
|
|
441
|
+
| `mergeJobs` repo(insert/findById/casUpdateState/findActiveByRepo/findQueuedByRepo/nextOrderIndex) | StateStore 内存索引 + 全量 JSON 原子写 | 单宿主进程内单写者;CAS 由「写仅在串行链内发生」这条更强不变量取代(见下) |
|
|
442
|
+
| `resourceLeases`(排他租约) | 进程内 `Map<repoKey+branch, Promise>` 链(尾接续) | 同一进程内 promise 链就是严格串行;跨进程互斥显式 MVP 非目标(§1.2) |
|
|
443
|
+
| `ctx.transaction`(状态 CAS + 事件同事务) | 原子写 + 串行链内写 | 没有「读-改-写竞态」:所有 job 状态变更发生在持有分支链的临界区内 |
|
|
444
|
+
| durable events(merge.queued/started/…) | 不迁移 | DSH 会话日志即审计面;队列历史留在 state.json(终态 job 保留 N=200 条后修剪) |
|
|
445
|
+
| artifacts(git-commit-v1 / integrated-commit-v1) | 不迁移(字段内联进 job 记录) | DSH 无 artifact 体系;`sourceHead`/`integratedCommit` 直接是记录字段 |
|
|
446
|
+
| recovery markers(merge-applying 现场证据) | job 记录**先写后效**:`state:'applying' + integrationWorktree` 在任何 git 副作用前落盘 | 同一崩溃证据语义(crash → 对账见 applying + 路径 → 现场可寻),机制简化为一个字段的写序(§7.3) |
|
|
447
|
+
|
|
448
|
+
**CAS 的诚实降级说明**:源码 CAS 防御的是「两个并发 applyNext 抢同一 job」。DSH 的 promise 链从构造上排除并发 apply(同分支串行;不同分支不共享 job);跨进程场景(两个 dsh 实例)在 MVP 声明为不支持 —— README 与工具 description 都写明(「同一 repo 的 merge 操作请经由同一个 DSH 会话」)。后置升级路径:StateStore 加 `withFileLock`(dsh-atomic-write 已有同款原语可抄)。
|
|
449
|
+
|
|
450
|
+
### 6.2 enqueue / applyNext / drain
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
enqueue({ repoKey, integrationBranch, worktreeId, sourceBranch, sourceHead, message }):
|
|
454
|
+
jobId = id8(); orderIndex = ++counter[repoKey+branch] // 单调,源 nextOrderIndex 语义
|
|
455
|
+
state.jobs[jobId] = { state:'queued', ... }; persist()
|
|
456
|
+
return jobId
|
|
457
|
+
|
|
458
|
+
drain(repoKey, integrationBranch): // 新增于源的「每调用 apply 一个」:DSH 语义是排空
|
|
459
|
+
chain = chains.get(key(repoKey, integrationBranch)) ?? Promise.resolve()
|
|
460
|
+
next = chain.then(() => applyUntilBlockedOrEmpty(repoKey, integrationBranch))
|
|
461
|
+
.finally(() => { if (chains.get(k) === next) chains.delete(k) })
|
|
462
|
+
chains.set(key, next)
|
|
463
|
+
return next // worktree_merge await 它
|
|
464
|
+
|
|
465
|
+
applyUntilBlockedOrEmpty(...): // 串行链内执行;任何持久化写都在链内
|
|
466
|
+
loop:
|
|
467
|
+
active = findActive() // queued|applying|conflicted
|
|
468
|
+
if active && active.state !== 'queued': return // 持线(conflicted 占线,源 step 1)
|
|
469
|
+
queued = findQueued() // orderIndex 最小
|
|
470
|
+
if !queued: return
|
|
471
|
+
outcome = applyOne(queued) // §6.3 十步
|
|
472
|
+
if outcome.state === 'conflicted': return // 现场保留,后续 job 阻塞(源 L836)
|
|
473
|
+
if outcome.state === 'failed': continue // 硬失败不阻塞队列(源语义:failed 终态,链可继续)
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
> 与源 `applyNext`「每次恰好一个」的差异论证:task-weaver 的调度循环(SchedulerLoop)按 tick 驱动 applyNext;DSH 没有常驻调度循环(纯工具协议,工具返回即回合结束),排空语义让一次 `worktree_merge` 调用尽可能前进到队列自然停点(空或冲突),后续 queued job 无需额外驱动。若前面的 job 是别的调用入的队,本调用的 drain 也会把它应用掉 —— 返回本 job 的结局或 `queued_ahead`。
|
|
477
|
+
|
|
478
|
+
### 6.3 applyOne 十步(源 `#applyNextBody` L495-845 对照)
|
|
479
|
+
|
|
480
|
+
| # | 步骤 | 源证据 | DSH 落地 |
|
|
481
|
+
|---|---|---|---|
|
|
482
|
+
| 1 | 双检持线(链内再读一次 active) | L506-520 | StateStore 读(链内即安全) |
|
|
483
|
+
| 2 | 取队首 queued job | L522-533 | orderIndex 最小 |
|
|
484
|
+
| 3 | (源:加载校验 artifact) | L535-579 | **前置到 enqueue 前**(§5.6 步骤 2 的收集已产出 sourceHead —— 等价物校验提前完成;缺失时 markFailed 路径保留) |
|
|
485
|
+
| 4 | CAS queued → applying | L582-631 | 链内直接改态 + persist |
|
|
486
|
+
| 5 | 预置 integration worktree:`ensureBranch(integration, repoHead)` + `addWorktreeAt(唯一路径)` + `resolveHeadBefore` | L632-657 | 直搬;**先写 job.integrationWorktree/integrationHeadBefore 再跑 git**(崩溃证据写序,§6.1 表末行) |
|
|
487
|
+
| 6 | 记录 baseline 字段 | L659-671 | 同(同一次 persist) |
|
|
488
|
+
| 7 | 应用:源 `cherryPick(sourceCommit)` | L673-728 | **`mergeNoFf(integration worktree, sourceBranch, message)`(D7)** —— 论证见下 |
|
|
489
|
+
| 8 | 冲突分类:非零退出 → `listConflicts` → 非空或 /conflict/i → conflicted(记 conflictFiles + 保留 worktree);否则 failed | L688-727 | 直搬(含「listConflicts 失败仍按冲突标记判冲突」的防御语义 L696-704) |
|
|
490
|
+
| 9 | 成功:`resolveHead` 取 integratedCommit → job 记录终态 | L730-825 | 直搬(产物登记字段内联) |
|
|
491
|
+
| 10 | finally:非 conflicted → `removeWorktree(integration)`(best-effort);conflicted → 保留 | L826-844 | 直搬 |
|
|
492
|
+
|
|
493
|
+
**D7 论证:`merge --no-ff` 替代 cherry-pick**。task-weaver cherry-pick 单提交成立的前提是 change-collector 保证「每 attempt 恰一个提交」(`commitAll` 收拢全部改动为一提交)。DSH 的 subagent 在 worktree 里**可能多次提交**(模型自由度,插件不禁止):cherry-pick 单提交会静默丢失中间提交;先 squash 再 cherry-pick 则破坏提交结构。`git merge --no-ff` 对 1..N 提交一律成立、保留完整来源历史、每任务恰一个可审阅 merge commit(与源「produce a reviewable integration commit」语义对齐)、冲突检测路径完全相同(本机已实测:linked worktree 内 `git merge` 冲突 → `--diff-filter=U` 列出文件)。代价:集成分支历史含 merge node(非线性)—— 对「集成分支最终由人审阅合入目标分支」的 DSH 流程无碍。`cherryPick` 保留在 GitPort(未来 cherry 模式/精确重放场景),MVP merge 路径不用。
|
|
494
|
+
|
|
495
|
+
**硬失败回收**:merge 非零且非冲突(如污染 index 的意外状态)→ `abortMerge(integrationWorktree)` best-effort 清理后 removeWorktree —— 与源「failed 不保留现场」一致(保留仅属冲突)。
|
|
496
|
+
|
|
497
|
+
### 6.4 状态机与命令(直搬 + 收口)
|
|
498
|
+
|
|
499
|
+
```
|
|
500
|
+
enqueue applyOne
|
|
501
|
+
(new) ───────────▶ queued ─────────────▶ applying ──┬─▶ succeeded
|
|
502
|
+
│ │ ├─▶ conflicted ──resolve──▶ resolved
|
|
503
|
+
│cancel │ └─▶ failed ──┐
|
|
504
|
+
▼ │ └─retry─┐
|
|
505
|
+
cancelled ◀────────────┘ retry ──────────────┴──▶ queued
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
不变量(全部直搬源注释 L16-27):
|
|
509
|
+
- 每 repo+integrationBranch 至多一个 active(queued/applying/conflicted)job;
|
|
510
|
+
- 同集成分支永不并发 apply(promise 链);
|
|
511
|
+
- conflicted 状态保留 integration worktree + 源分支(不 force-delete);
|
|
512
|
+
- 无自动 push(接口无 push)。
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## 7. 状态与持久化(`lib/state-store.js`)
|
|
517
|
+
|
|
518
|
+
### 7.1 存储选型论证(D8)
|
|
519
|
+
|
|
520
|
+
**JSON 单文件原子写,不用 node:sqlite**:
|
|
521
|
+
|
|
522
|
+
- 写频度:队列操作是模型驱动的低频事件(每次 merge 数个写);读频度:工具调用时全量载入内存索引。单文件 < 数百 KB。sqlite 的优势(并发事务、大表、索引)在此完全用不上。
|
|
523
|
+
- 家族先例:subagents registry(durable、0600、tmp+rename、500 条上限)证明该模式在 DSH 插件语境成熟;dsh-atomic-write 的 `writeFileAtomic` 语义(wx 独占 + rename + mode 保持 + symlink 防护)是本地实现的规格书。
|
|
524
|
+
- 崩溃对账需求(分析 §4-C4「重启后 sqlite 对账」)在 JSON 模式下同样成立:对账源是「状态文件 × git worktree list」双事实对照,不依赖存储引擎。
|
|
525
|
+
- `engines: >=18` 兼容(node:sqlite 需 ≥22.5 —— 分析 §4 提到 node:sqlite 可用,但用它会抬升 Node 下限,与家族 CI 矩阵 18/20/22 冲突)。
|
|
526
|
+
|
|
527
|
+
**纪律**:写 = 内存对象深拷贝 → `JSON.stringify(,2)` → tmp 文件(`wx` + 0600 + best-effort chmod)→ rename;读 = 启动一次 + 每写后内存即真相;`__proto__` 键防护(照 registry.js);终态 job 修剪到最近 200 条;worktree 记录保留到 cleanup。文件位置默认 `~/.dsh/dsh-worktrees/state.json`(`statePath` 可配)。
|
|
528
|
+
|
|
529
|
+
### 7.2 并发(单进程内)
|
|
530
|
+
|
|
531
|
+
- 工具 execute 并发(同回合多工具调用):`worktree_merge` 声明 `isConcurrencySafe: false`;引擎层再有 promise 链兜底(分支内严格串行、跨分支并行不相互阻塞)。
|
|
532
|
+
- StateStore 写全部发生在串行链或工具的单步临界区内;`worktree_queue` 命令对 job 的改态也经同一分支链排队(cancel 除外 —— queued job 无 git 副作用,链外改态安全且避免死锁:drain 进行中 cancel 一个还没轮到的 job)。
|
|
533
|
+
|
|
534
|
+
### 7.3 崩溃恢复(apply() 内、工具注册前 —— 分析 §4-C4 次序)
|
|
535
|
+
|
|
536
|
+
```
|
|
537
|
+
reconcile(state, gitPort):
|
|
538
|
+
for repoKey of Object.keys(state.repos):
|
|
539
|
+
root = state.repos[repoKey].root
|
|
540
|
+
if (!await gitPort.isGitRepo(root)):
|
|
541
|
+
该 repo 全部 worktree 记录标 orphaned(repo 不可达);jobs 中 applying → failed("host restart; repo unreachable")
|
|
542
|
+
continue
|
|
543
|
+
live = parseWorktreeListPorcelain(await gitPort.listWorktrees(root)) // path 集合
|
|
544
|
+
for wt of state.worktrees where repoKey:
|
|
545
|
+
wt.path ∉ live → wt.state = 'vanished' // 记录在、现场无(人为 prune / 盘清)
|
|
546
|
+
for job of state.jobs where repoKey && state === 'applying':
|
|
547
|
+
job.state = 'failed'; job.error = 'host restarted during apply — the recorded integrationWorktree may hold a partial state'
|
|
548
|
+
// integrationWorktree 路径已记录(§6.3 步骤 5 的先写后效):若该路径仍在 live 中 → 保留并附注(操作者可检视);
|
|
549
|
+
// 若是干净成功前的崩溃,git worktree 本身保持集成分支不受损(merge 未完成不产生提交)
|
|
550
|
+
conflicted job:原样保留(其 worktree 就是现场,源语义)
|
|
551
|
+
孤儿 integration worktree(live 中 .integration/ 路径无对应 non-terminal job)→ best-effort removeWorktree
|
|
552
|
+
persist()
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
对账**只做标记与转移,不做破坏性删除**(除明确孤儿 integration worktree 的 best-effort 清理);破坏性动作永远留给显式 `worktree_cleanup`。
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## 8. 配置、目录结构与安装形态
|
|
560
|
+
|
|
561
|
+
### 8.1 config schema(zod strict,`lib/config.js`;未知键 fail loud)
|
|
562
|
+
|
|
563
|
+
```js
|
|
564
|
+
pluginConfigSchema = z.object({
|
|
565
|
+
register: z.object({ // 工具族开关,默认全 true
|
|
566
|
+
create: z.boolean().optional(), list: z.boolean().optional(),
|
|
567
|
+
status: z.boolean().optional(), merge: z.boolean().optional(),
|
|
568
|
+
queue: z.boolean().optional(), cleanup: z.boolean().optional(),
|
|
569
|
+
}).strict().optional(),
|
|
570
|
+
worktreeRoot: z.string().min(1).optional(), // 默认 ~/.dsh/worktrees/
|
|
571
|
+
statePath: z.string().min(1).optional(), // 默认 <worktreeRoot>/../dsh-worktrees/state.json
|
|
572
|
+
// 即 ~/.dsh/dsh-worktrees/state.json
|
|
573
|
+
maxWorktrees: z.number().int().positive().optional(), // 默认 16(跨 repo 全局计数)
|
|
574
|
+
defaultBaseRef: z.string().min(1).optional(), // 默认 'HEAD'
|
|
575
|
+
gitTimeoutMs: z.number().int().positive().optional(), // 默认 15000(源 L17)
|
|
576
|
+
mergeTimeoutMs: z.number().int().positive().optional(), // 默认 120000(merge 较慢,独立预算)
|
|
577
|
+
allowedRoots: z.array(z.string().min(1)).optional(), // 额外允许的 repo 根(realpath 后子树判定)
|
|
578
|
+
requireWorkspaceRegistration: z.boolean().optional(), // 默认 true(门禁 §5.2.0-4b)
|
|
579
|
+
autoCollect: z.boolean().optional(), // 默认 true:merge 前自动 commitAll 未提交改动
|
|
580
|
+
retainJobHistory: z.number().int().positive().optional(), // 默认 200
|
|
581
|
+
}).strict()
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
(zod 是唯一新增 dependency —— 与 subagents 相同的 `zod@^3.23.0`。)
|
|
585
|
+
|
|
586
|
+
### 8.2 目录结构(仿 dsh-plugin-subagents)
|
|
587
|
+
|
|
588
|
+
```
|
|
589
|
+
dsh-worktrees/
|
|
590
|
+
├── package.json # name dsh-worktrees;type module;engines >=18
|
|
591
|
+
│ # dsh.bundle.patch → ./cordis.patch.yml
|
|
592
|
+
│ # deps: zod;peerDependencies: @deepseek-ai/{cordis@^4.0.1, dsh-tools@^0.1.0-rc.6}
|
|
593
|
+
│ # (peer 面最小化:defineTool/ctx.tools 即全部宿主面;
|
|
594
|
+
│ # workspaceRegistry 经可选探测使用,不进 inject —— headless 兼容)
|
|
595
|
+
├── cordis.patch.yml # §8.3:单 insert 行
|
|
596
|
+
├── lib/
|
|
597
|
+
│ ├── index.js # apply():validateConfig → StateStore 载入 → reconcile → 工具注册;返回 undefined
|
|
598
|
+
│ ├── config.js # §8.1 zod strict
|
|
599
|
+
│ ├── git-port.js # §4:runGit + NodeGitPort + 4 个解析函数 + 新增 5 方法
|
|
600
|
+
│ ├── state-store.js # §7:原子写 + 内存索引 + 修剪
|
|
601
|
+
│ ├── repo-gate.js # §5.2.0:realpath + isPathInside + 门禁
|
|
602
|
+
│ ├── naming.js # §5.2.1:sanitizeBranch / branchName / worktreePath / repoIdFromRoot(FNV-1a 直搬)
|
|
603
|
+
│ ├── worktree-service.js # create/list/status/cleanup(workspace-service.ts 的 worktree 模式收窄版)
|
|
604
|
+
│ ├── merge-queue.js # §6:enqueue/drain/applyOne/commands + collect(collectGit 折叠)
|
|
605
|
+
│ └── tools/ # 一模块一工具(每个 export registerXxxTool(ctx, deps))
|
|
606
|
+
│ ├── worktree-create.js
|
|
607
|
+
│ ├── worktree-list.js
|
|
608
|
+
│ ├── worktree-status.js
|
|
609
|
+
│ ├── worktree-merge.js
|
|
610
|
+
│ ├── worktree-queue.js
|
|
611
|
+
│ └── worktree-cleanup.js
|
|
612
|
+
├── test/ # §11:node:test;真 git fixture(tmp init,无网络无远端)
|
|
613
|
+
│ ├── git-port.test.js
|
|
614
|
+
│ ├── naming.test.js
|
|
615
|
+
│ ├── repo-gate.test.js
|
|
616
|
+
│ ├── state-store.test.js
|
|
617
|
+
│ ├── worktree-service.test.js
|
|
618
|
+
│ ├── merge-queue.test.js
|
|
619
|
+
│ ├── tools.test.js # fake ctx(tools.register 收集器)+ 真 git fixture
|
|
620
|
+
│ └── reconcile.test.js
|
|
621
|
+
├── docs/DESIGN.md docs/TASKS.md
|
|
622
|
+
├── README.md README.zh.md CHANGELOG.md AGENTS.md LICENSE SECURITY.md
|
|
623
|
+
└── scripts/lint.js # node --check 全模块 + git 调用纪律静态检查(§11.3)
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
(无 `patches/` —— 本插件零宿主补丁;cwd 补丁属 subagents 插件。无 `roles/` —— 无角色语义。)
|
|
627
|
+
|
|
628
|
+
### 8.3 cordis.patch.yml(形态)
|
|
629
|
+
|
|
630
|
+
```yaml
|
|
631
|
+
# dsh-worktrees bundle patch — 单行 insert(新名字工具在全局层天然可见,
|
|
632
|
+
# 无需 disable 任何官方行;对齐 dsh-ssh 的单 insert 形态)。
|
|
633
|
+
- insert:
|
|
634
|
+
- id: worktrees
|
|
635
|
+
name: dsh-worktrees
|
|
636
|
+
config:
|
|
637
|
+
# 全部键省略走 lib/config.js 默认;示例:
|
|
638
|
+
# maxWorktrees: 16
|
|
639
|
+
# allowedRoots: ["~/Documents/dev/Agents"]
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
insert 行写法学自:dsh-ssh `cordis.patch.yml`(单 insert 双面插件)与 dsh-web-ui-all 聚合 patch(`- insert:` + `- id:` + `name:` 两键缩进形态)。`dsh plugin --profile <name> add dsh-worktrees` 后 reconcile 自动追加层序(分析 §4:解析 `dsh.bundle.patch` 依赖按序追加 bundles 尾部)。
|
|
643
|
+
|
|
644
|
+
### 8.4 peer 双实例防御(C6)
|
|
645
|
+
|
|
646
|
+
`@deepseek-ai/dsh-tools` 声明 peer;README 安装步骤含 `npm run setup:peer`(symlink 到 live harness 根,脚本照搬 subagents `scripts/link-harness-dsh-tools.sh` 模式);`apply()` 开头做 subagents 同款 `assertSingleDshToolsInstance` 自检(`TOOL_RUNTIME_SCHEDULER` Symbol 探测,失败即 logger.fatal + throw —— 分析 §4-C6 的既证失效面)。
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## 9. 迁移对照表(task-weaver 源 → 插件目标)
|
|
651
|
+
|
|
652
|
+
| 源文件(行数) | 目标 | 处置 | 一句话理由 |
|
|
653
|
+
|---|---|---|---|
|
|
654
|
+
| `git-port.ts` (585) | `lib/git-port.js` (~520) | **直搬 + 薄改** | argv/spawn/超时/porcelain 解析零宿主耦合;改动仅:TS 类型抹除、`BunGitPort`→`NodeGitPort` 改名、裁 `worktreePath()` mkdtemp 布局、新增 5 方法(listWorktrees/isAncestor/validateBranch/mergeNoFf/abortMerge) |
|
|
655
|
+
| `workspace-service.ts` (690) | `lib/worktree-service.js` (~300) + `lib/naming.js` (~90) | **改写收窄** | 只留 worktree 模式;read_only/direct/lineage/artifact-source 依赖 run/artifact 投影(DSH 无此设施);分支命名与路径布局适配会话语境;`repoIdFromRoot` FNV-1a 与「base 必须具体 oid、禁 HEAD 回退」(L268-284)逐行保留 |
|
|
656
|
+
| `merge-queue.ts` (1154) | `lib/merge-queue.js` (~450) | **改写**(算法骨架直搬) | 十步 apply、状态机、cancel/retry/resolve、冲突保留、one-active 全保留;ServiceContext/租约/事件/产物/recovery-marker 五处依赖面分别替换(§6.1 表);cherry-pick→merge --no-ff(§6.3 D7);applyNext→drain(§6.2) |
|
|
657
|
+
| `change-collector.ts` (468) | 折叠进 `lib/merge-queue.js` `collect()` (~60) | **部分直搬** | `collectGit` 的 status→no_changes→commitAll→headCommit 链(L162-222)成为 merge 前置收集;directory 模式 + `hashDirectory`(L279-468)与 artifact 登记整体裁掉(MVP git-only、无产物体系) |
|
|
658
|
+
| `lineage-lease.ts` (97) | —— | **裁掉** | 串行任务共享一个 worktree 的租约;DSH MVP 每个 subagent 独立 worktree(并行写隔离是卖点),串行复用场景后置;且 `ResourceLeaseRepositoryPort` 在 DSH 无对应物 |
|
|
659
|
+
| `index.ts` (17) | —— | **裁掉** | 包出口模式不适用;插件入口是 `lib/index.js` 的 `apply()` |
|
|
660
|
+
| `__tests__/git-port.test.ts` (593) | `test/git-port.test.js` | **直搬**(bun:test→node:test) | runGit 非零不抛/spawn 失败抛、真 tmp repo fixture(argv-only git 助手 + env 隔离)、两个解析函数全表驱动用例 |
|
|
661
|
+
| `__tests__/workspace-service.test.ts` (983) | `test/worktree-service.test.js` | **收窄迁移** | worktree 建制/错误族用例迁;read_only/direct/lineage/recovery-marker 套件不迁 |
|
|
662
|
+
| `__tests__/merge-queue.test.ts` (873) | `test/merge-queue.test.js` | **迁移 + 适配** | enqueue 单调序、clean merge、conflict(保留现场+清单)、one-active+冲突后阻塞、(投影/事件原子性套件不迁 —— 持久化已换) |
|
|
663
|
+
| `__tests__/merge-queue-commands.test.ts` (409) | `test/merge-queue.test.js` 内 describe | **直搬语义** | cancel/retry/resolve 的幂等与 invalid_state 矩阵 + 分支释放断言 |
|
|
664
|
+
| `__tests__/change-collector.test.ts` (644) | 折叠进 merge-queue 测试 | **部分迁移** | 脏树自动收集、no_changes 两用例;directory 套件不迁 |
|
|
665
|
+
|
|
666
|
+
**规模核算**:源 6 文件 3,014 行 + 测试 3,502 行 → 插件 lib 预计 ~2,100-2,400 行(裁两文件、五处依赖面替换、工具层新增 ~700)、test 预计 ~1,900-2,200 行。
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## 10. 与 dsh-dag-orchestrator 的组合缝(接口层,不设计 DAG)
|
|
671
|
+
|
|
672
|
+
已拍板 dag-orchestrator「随宿主生死(sqlite + 重启对账)」(分析 §0)。本插件留三道缝,均为 day-one 落地、零额外成本:
|
|
673
|
+
|
|
674
|
+
1. **引擎与工具分离 + 构造注入**:`lib/merge-queue.js` 导出 `createMergeQueue({ git, store, config })` —— 不 import 宿主服务、不经工具层。dag-orchestrator 的 merge task kind 可直接持有同一实例(经插件间协作面)或以同构 deps 自建指向同一 state.json……后者**禁止**(双写者破坏 §6.1 单写者不变量)——正确姿势是 dag-orchestrator **调用本插件的工具/引擎单例**。缝的契约:`enqueue(params)` 与 `drain(repoKey, branch): Promise<DrainOutcome>` 是稳定 API(语义 = §6.2)。
|
|
675
|
+
2. **job 记录的 origin/correlationId 字段**(§5.2.2):`origin: 'dag'` + DAG 节点 id —— 队列查询与对账可区分来源,dag 侧断言只操作自己的 job。工具面 `worktree_merge` 恒写 `origin: 'tool'`。
|
|
676
|
+
3. **冲突保留语义与 DAG merge 节点的映射**:DAG 的 merge 节点拿到 `conflicted` 结局即阻塞下游(分支被 conflicted job 持线 —— 引擎不变量保证),`resolve/retry` 对应 DAG 的人工介入/重跑边。dag-orchestrator 无需理解 git,只需要消费 `DrainOutcome` 的五态(succeeded/conflicted/queued/failed/no_changes)。
|
|
677
|
+
|
|
678
|
+
明确不做:DAG 拓扑感知排序(orderIndex 仍按到达序)、跨 repo 事务性集成、dag 侧的 worktree 生命周期接管(create/cleanup 仍归本插件工具)。
|
|
679
|
+
|
|
680
|
+
### 10.1 worktreesEngine 服务门面(已落地,`lib/engine-face.js`)
|
|
681
|
+
|
|
682
|
+
DAG 通过机会主义 `ctx.get('worktreesEngine')` 探测本插件(其 worktrees-seam.js 的 admit 形状:
|
|
683
|
+
`{getMergeQueue, getWorktreeService}` 两函数)。`apply()` 在工具注册前
|
|
684
|
+
`ctx.provide('worktreesEngine', createEngineFace({service, queue, store, git}))` —— 门面
|
|
685
|
+
**零自有状态**,全部方法委托给与工具层**同一**批 store/git 单例(红线 10 单写者)。
|
|
686
|
+
四层契约适配(消费侧验收形状已冻结,全部在门面层包一层,不改工具层契约):
|
|
687
|
+
|
|
688
|
+
| 层 | DAG 侧契约 | 本侧适配 |
|
|
689
|
+
|---|---|---|
|
|
690
|
+
| create | `create({task, repoRoot, baseRef, origin:'dag', correlationId})` → `{id, path, branch, correlationId, …}`(无 repoKey/sessionId) | WorktreeService.create 自身派生 repoKey(repoIdFromRoot(realpath))并把 origin/correlationId **持久化进 WorktreeRecord**(可选字段,旧 state.json 向后兼容);门面补稳定的 engineSessionId(分支名派生自它) |
|
|
691
|
+
| findActiveByTask | `findActiveByTask(repoRoot, task)` → active record \| null(重派发复用探针;record 的 correlationId 是复用所有权门的证据) | WorktreeService 直接实现:按 repoRoot+task 过滤 state='active' 的记录 |
|
|
692
|
+
| enqueue | 四键 `{worktreeId, integrationBranch, origin, correlationId}`,git 事实归 provider 解析;**幂等**:同 (worktreeId, integrationBranch) 的 active job 直接返回该 job(DAG 重试是 re-poll 不是 re-stack) | 门面查 record 补全七键(sourceHead=worktree 当前 HEAD、sourceBranch=record.branch、repoRoot/repoKey 来自 record),先 `check-ref-format` 校验 integrationBranch(P1-B),再委托内部 enqueue(内部 enqueue 保持同步七键形态不变) |
|
|
693
|
+
| drain | 五态 DrainOutcome `{succeeded|integratedCommit \| conflicted\|conflictFiles,integrationWorktree? \| failed\|error \| no_changes \| queued\|queued_ahead}`(`queued_ahead` 是 snake_case 契约键) | 门面包一层投影:blockedBy(conflicted) → conflicted;blockedBy(applying) → queued+queued_ahead(该分支 queued job 数);drained → 最新终态 job 的 succeeded/failed;无 job → no_changes。**内部 drain 的 `{drained}\|{blockedBy}` 工具层契约不变**。**脏源守卫(P3)**:投影 `succeeded` 前,对本次已集成的 terminal succeeded 源 worktree 复检 dirty(复用 `gitPort.status` 的 porcelain 判定,同工具层 collect);任一源仍有未提交变更 → `succeeded` 映射为 `failed`/`dirty_not_collected`(指引先提交或走工具层 autoCollect)。DAG 四键路径**不**自动 collect(agent executor 拥有 worktree 提交),语义等同工具层 `autoCollect=false` 分支;missing record/status flake 失败开放 |
|
|
694
|
+
|
|
695
|
+
worktree_record 形状扩展(§5.2.2 增补,均为可选字段):`origin?: 'tool'|'dag'`、
|
|
696
|
+
`correlationId?: string`(create 时的 DAG attempt id)。
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## 11. 测试策略
|
|
701
|
+
|
|
702
|
+
### 11.1 fixture:真 git 仓库、零网络零远端
|
|
703
|
+
|
|
704
|
+
直搬 `git-port.test.ts` 的 fixture 纪律(L36-107):`mkdtemp` 建 repo → `git init --initial-branch=main` → 显式 `user.name/email` env(`GIT_AUTHOR_*`/`GIT_COMMITTER_*`)→ 初始提交;独立 worktree 父目录(叶由 git 创建,父已存在 —— 源约束 L633-635)。fixture 的 git 助手独立于被测 runGit(bootstrap 失败易区分)。**不 clone、不 fetch、不 push**(CI 裸机可跑;红线 8)。
|
|
705
|
+
|
|
706
|
+
### 11.2 迁移的测试思路(源套件 → 目标用例)
|
|
707
|
+
|
|
708
|
+
| 源用例(证据) | 目标 |
|
|
709
|
+
|---|---|
|
|
710
|
+
| runGit 非零退出不抛(L129)/ spawn 失败 reject(L138) | `git-port.test.js` 直迁 |
|
|
711
|
+
| porcelain 两解析器全表(L464-590:rename target、y=R、引号、R/C tab) | 直迁(node:test 表驱动) |
|
|
712
|
+
| BunGitPort 对真 tmp repo:worktree 建/删幂等、commitAll、conflicts | 直迁 + 新方法(listWorktrees porcelain 解析、isAncestor、validateBranch 拒绝 `..`/前导 `-`) |
|
|
713
|
+
| enqueue orderIndex 单调(merge-queue.test L358-399) | 直迁 |
|
|
714
|
+
| clean merge(L401-473:集成分支预置、源 worktree 提交、apply succeeded) | 适配(merge --no-f 版本:断言集成 HEAD 是 merge commit、源分支提交全在) |
|
|
715
|
+
| conflict(L474-552):同文件两侧改动 → conflicted + conflictFiles + **worktree 保留断言** | 直迁(本机已实测该路径在 linked worktree 成立) |
|
|
716
|
+
| one-active + 冲突后阻塞(L553-657) | 直迁(drain 语义版:conflicted 后续 queued 不被应用) |
|
|
717
|
+
| cancel/retry/resolve 幂等与 invalid_state 矩阵(commands.test L166-382) | 直迁 |
|
|
718
|
+
| collect:脏树自动 commitAll / no_changes(change-collector.test L282-451) | 折叠进 merge-queue 测试 |
|
|
719
|
+
| workspace-service worktree 错误族(git 不可用、非法 base) | 收窄迁移 |
|
|
720
|
+
| **新增**:repo 门禁(realpath 规范化、workspace 命中/未注册拒绝、session-cwd 子树放行、allowedRoots)、cleanup 保护矩阵(未合并拒绝 / force+acknowledge 放行 / 单 force 拒绝)、reconcile(applying→failed、vanished 标记、孤儿 integration 清理、conflicted 保留)、maxWorktrees、状态原子写(tmp+rename、0600、proto 键防护)、并发 drain(两分支并行不互阻、同分支串行)、组合冒烟(create→模拟写→merge→cleanup 全链) | 新写 |
|
|
721
|
+
|
|
722
|
+
### 11.3 纪律
|
|
723
|
+
|
|
724
|
+
- `node --test`,无真实 CLI / 无密钥 / 无网络(沿 subagents AGENTS.md 红线);Windows 跑同套(git 原生 exe 无 shim 问题;路径断言用 `node:path` 规范化比较)。
|
|
725
|
+
- `scripts/lint.js`:`node --check` 全模块 + **git 纪律静态检查**——`lib/` 内只允许 `spawn(` 出现在 `git-port.js`(其它模块经 GitPort;违反即 lint 失败,机械护住红线 1)。
|
|
726
|
+
- 迁移用例不得削弱断言(沿 subagents「随迁不削弱」惯例)。
|
|
727
|
+
|
|
728
|
+
---
|
|
729
|
+
|
|
730
|
+
## 12. 安全红线(拟 10 条,实现期进 AGENTS.md)
|
|
731
|
+
|
|
732
|
+
1. **唯一 git 出口**:一切 git 经 `lib/git-port.js`(`spawn("git", argvArray, {shell:false})`);argv 永不 shell 字符串拼接;lint 静态强制(§11.3)。
|
|
733
|
+
2. **ref/分支名注入防御**:调用方可控 ref 一律 `rev-parse --verify --end-of-options`(源 L398 直搬);分支名前置 `check-ref-format --branch`;merge 的分支位置参数置 `--` 终结符之后。
|
|
734
|
+
3. **路径纪律**:`node:path.join`/`resolve` + `realpath` 规范化,禁字符串拼接与 `URL.pathname`(沿 subagents 红线 5);worktree 物理路径只落在 `worktreeRoot` 下(构造性保证,不接受调用方路径注入叶目录)。
|
|
735
|
+
4. **repo 门禁 fail closed**:repo 根必须 realpath 后落在「已注册 workspace ∪ 会话 cwd 子树 ∪ 显式 allowedRoots」;不提供 any-root 开关;未知一律 `repo_not_registered` 拒绝(aionui-panel gate 先例)。
|
|
736
|
+
5. **cleanup 不删未合并工作**:源分支头未进集成分支(`merge-base --is-ancestor` 判定)时,删除需 `force && acknowledge` 双布尔同时显式为 true;任何单一参数或默认值不放行。
|
|
737
|
+
6. **merge 永不自动 push**:GitPort 按构造无 push/fetch/clone 方法(源接口照搬即得);README 与工具 description 明示「集成结果留在本地集成分支,由人审阅推送」。
|
|
738
|
+
7. **冲突现场不可破坏**:conflicted job 的 integration worktree 与源分支保留;reconcile 与 retry 都不得删除之;只有显式 cleanup(过保护判定)或 resolve 后的显式动作可清。
|
|
739
|
+
8. **测试零网络零远端**:fixture 全本地 `git init`;任何用例不得 push/fetch/clone(CI 裸机可绿)。
|
|
740
|
+
9. **config zod strict**:未知键 fail loudly(沿家族惯例);`engines >=18`,不用 `Promise.withResolvers`(手写 deferred,分析 §5-R1)。
|
|
741
|
+
10. **单写者不变量**:state.json 的写只发生在引擎串行链或工具单步临界区内;插件不提供第二写入口(dag-orchestrator 必须经本插件单例,§10)。
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
## 13. 工程量估算与实施顺序
|
|
746
|
+
|
|
747
|
+
任务级拆解见 **`docs/TASKS.md`**(独立文件的理由:① 直承家族范式——subagents 的 TASKS.md 是给实现 subagent 的派发工件,DESIGN.md 是稳定架构记录,两者读者与变更节奏不同;② 实施期 TASKS.md 会高频打勾修订,不搅动 DESIGN.md 的评审基线;③ README 导航同时索引两者)。
|
|
748
|
+
|
|
749
|
+
**行数级估算**(对照 §9 规模核算):
|
|
750
|
+
|
|
751
|
+
| 模块 | 预估行数 | 主要来源 |
|
|
752
|
+
|---|---|---|
|
|
753
|
+
| `lib/git-port.js` | ~520 | git-port.ts 直搬 + 5 新方法 |
|
|
754
|
+
| `lib/merge-queue.js` | ~450 | merge-queue.ts 骨架 + collect 折叠 |
|
|
755
|
+
| `lib/worktree-service.js` + `naming.js` | ~390 | workspace-service.ts 收窄 |
|
|
756
|
+
| `lib/tools/*`(6 件) | ~660 | 新写(schema + 组装) |
|
|
757
|
+
| `lib/state-store.js` / `repo-gate.js` / `config.js` / `index.js` | ~410 | 新写(先例密集) |
|
|
758
|
+
| **lib 小计** | **~2,430** | |
|
|
759
|
+
| `test/*`(9 文件) | ~2,000 | 迁移 ~60% + 新写 ~40% |
|
|
760
|
+
| docs(README×2 / AGENTS / CHANGELOG) | ~500 | |
|
|
761
|
+
| **总计** | **~4,900** | 源 workspaces 含测试 6.5k → 净裁 ~25% |
|
|
762
|
+
|
|
763
|
+
**实施顺序**(依赖图与派发批次详见 TASKS.md):脚手架 → GitPort(一切的地基)→ naming/state-store/repo-gate(纯逻辑可并行)→ worktree-service + 三查询工具 → merge-queue → 三写工具 → apply() 总装 + reconcile → lint/CI/文档。
|
|
764
|
+
|
|
765
|
+
---
|
|
766
|
+
|
|
767
|
+
## 14. 开放问题(已全部拍板 · 2026-08-16 用户裁决)
|
|
768
|
+
|
|
769
|
+
> 拍板结果:O1 = 方案 A(会话级集成分支);O2 = 方案 A(`merge --no-ff`);O3/O4/O5 均按设计倾向落定。原问题与分析保留供追溯,拍板见最右列。
|
|
770
|
+
|
|
771
|
+
| # | 问题 | 本设计的倾向 | 影响 | 拍板(2026-08-16) |
|
|
772
|
+
|---|---|---|---|---|
|
|
773
|
+
| O1 | 集成分支默认策略:会话级 `dsh-wt/integration/<session>`(默认)vs 复用用户既有分支(如 main/dev) | 会话级(D9)—— 不碰用户分支、多会话不互踩、首次 `ensureBranch` 自 HEAD 引导零污染;代价是「集成→目标分支」最后一步由人完成 | 若用户要「直接合进 main」,改 `integration_branch` 参数显式指定即可(每次 create/merge 都可覆盖);也可加 config `defaultIntegrationBranch` | ✅ **A 会话级**(按倾向)。想直合 main 时按次传 `integration_branch` 覆盖,不锁死 |
|
|
774
|
+
| O2 | merge 应用动作:`merge --no-ff`(D7)vs task-weaver 原味 cherry-pick 单提交(需先 squash) | merge --no-ff —— 多提交分支无损、provenance 保留;cherry-pick 在 GitPort 保留为后置能力 | 影响集成分支历史形态(merge node vs 线性);若团队强偏好线性历史需回退到 squash+cherry-pick | ✅ **A merge --no-ff**(按倾向)。AI 过程可追溯优先;线性偏好者后置可切 cherry-pick |
|
|
775
|
+
| O3 | `worktree_merge` 是否内联排空队列(D8 现设计:drain 到自然停点)vs 仅入队返回(另设 pump 语义) | 内联 drain —— 工具协议无后台泵,排空让一次调用尽量前进;代价是大队列时单次调用耗时(120s mergeTimeout × N) | 若单次调用超时成为问题,需加 `maxDrainPerCall`(默认如 5)截断并返回 queued_ahead | ✅ 按倾向(内联 drain);不预设截断,遇真实慢再加 |
|
|
776
|
+
| O4 | peer 面是否包含 `@deepseek-ai/dsh-workspace`(workspaceRegistry 硬依赖)vs 现设计的可选探测(不进 inject) | 可选探测 —— headless 无该服务时降级为 session-cwd 子树门禁,不为门禁引入加载失败面 | 若 DSH 未来保证 workspaceRegistry 恒在,可转硬依赖简化判定 | ✅ 按倾向(可选探测) |
|
|
777
|
+
| O5 | 多 dsh 实例并发操作同一 repo 的互斥(现显式非目标) | MVP 声明不支持 + README 风险说明;后置用 `withFileLock`(dsh-atomic-write 同款原语)跨进程串行 | 并行重度用户(同时开两个 DSH 窗口操作同 repo)会撞双写;出现真实需求再升级 | ✅ 按倾向(MVP 显式不支持,出现真实痛点再升 withFileLock) |
|
|
778
|
+
|
|
779
|
+
---
|
|
780
|
+
|
|
781
|
+
## 附:与 dsh-plugin-subagents DESIGN.md 的章节对应
|
|
782
|
+
|
|
783
|
+
本文件结构仿其「目标→非目标→架构→组件→数据→配置→测试→红线」骨架;差异:§2 现状分析收窄为移植源+宿主面两节(无双前身仓库考证负担);§5 工具面为本文核心章(其对应章为 §5/§3.5);新增 §9 迁移对照表、§10 组合缝(其对应物为 §6.6 迁移)。
|