dsh-tower 2.0.2 → 2.0.4
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/CHANGELOG.md +57 -0
- package/README.en.md +22 -16
- package/README.md +22 -16
- package/SECURITY.md +4 -1
- package/docs/README.md +30 -0
- package/docs/architecture/overview.md +3 -2
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +15 -6
- package/docs/guide/install-upgrade-migration.md +14 -5
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/scenarios-and-philosophy.md +150 -62
- package/docs/operations/security.md +23 -12
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.md +8 -6
- package/docs/reference/errors.md +7 -1
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +1 -1
- package/docs/zh/faq.md +12 -4
- package/docs/zh/guide/install-upgrade-migration.md +11 -4
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +108 -36
- package/docs/zh/operations/security.md +16 -6
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +8 -6
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +7 -6
- package/index.js +26 -17
- package/lib/config.js +103 -6
- package/lib/doctor.js +11 -5
- package/lib/errors.js +14 -2
- package/lib/git.js +36 -2
- package/lib/modeStore.js +54 -20
- package/lib/store.js +65 -10
- package/lib/tests.js +35 -1
- package/lib/tools.js +257 -47
- package/lib/tower.js +281 -55
- package/lib/workspaceLock.js +44 -20
- package/package.json +3 -2
- package/scripts/check-docs.mjs +35 -0
- package/skills/tower-recovery.md +7 -5
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
|
@@ -22,7 +22,7 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
22
22
|
|
|
23
23
|
```json
|
|
24
24
|
{
|
|
25
|
-
"review": { "maxRoundsPerReviewer": 3 },
|
|
25
|
+
"review": { "requireReview": true, "maxRoundsPerReviewer": 3 },
|
|
26
26
|
"merge": { "timeoutMs": 1800000 },
|
|
27
27
|
"worktrees": { "root": "D:/tower-worktrees" },
|
|
28
28
|
"spawn": { "reviewerPreset": "tower-reviewer", "maxDepth": 2 }
|
|
@@ -37,12 +37,12 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
37
37
|
| 键 | 默认值 | 含义 |
|
|
38
38
|
|---|---|---|
|
|
39
39
|
| `section` | `''` | 兼容别名:替换整段 Tower 模式提示文案。空值保留内置提醒。 |
|
|
40
|
-
| `allowAgentControl` | `
|
|
40
|
+
| `allowAgentControl` | `true`(`mode.allowAgentControl` 的别名) | 兼容别名:注册 `tower_control`,允许模型切换模式。 |
|
|
41
41
|
| `init.autoGitInit` | `false`(随包 patch:`true`) | 当 `repoRoot` 是绝对、已存在但不是仓库的目录时,`tower_init` 可以执行 `git init` 并创建一个空的初始提交。 |
|
|
42
42
|
| `init.snapshotDirty` | `false` | `tower_init` 可以把未提交的**已跟踪**改动快照为游离提交作为 mission 基线(不含未跟踪文件;该提交通过 `refs/tower/snapshot/*` 保持存活)。 |
|
|
43
43
|
| `mode.promptSection` | `''` | 替换用的提示文案(与 `section` 同时设置时优先)。 |
|
|
44
44
|
| `mode.sectionAppend` | `''` | 追加在提醒之后的额外文本(团队约定)。 |
|
|
45
|
-
| `mode.allowAgentControl` | `
|
|
45
|
+
| `mode.allowAgentControl` | `true` | 注册 `tower_control`;模型可带理由进入/退出。设为 `false` 则仅人类可控制模式。 |
|
|
46
46
|
| `mode.autoPauseGoal` | `true` | 进入模式时暂停活跃 goal;退出时不会自动恢复。 |
|
|
47
47
|
| `mode.requireResumeAuthorization` | `false` | 恢复因 `pauseCause:user` 暂停的 mission 时,必须提供 `authorization` 引用。 |
|
|
48
48
|
| `mode.workspaceLock` | `false`(随包 patch:`true`) | 每个仓库只允许一个 Tower 会话(`tower.lock`);其他会话得到 `workspace-busy`。 |
|
|
@@ -53,9 +53,11 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
53
53
|
| `mode.denyTools` | goal/todo/team/workflow/subagent 工具(默认 12 个) | 模式激活期间被拒绝的工具。 |
|
|
54
54
|
| `mode.storePath` | `$DSH_HOME/tower-mode` | `modes.json` 所在目录。 |
|
|
55
55
|
| `mode.maxEntries` | `200` | 最多持久化的模式条目数(1–2000)。 |
|
|
56
|
+
| `review.requireReview` | `false` | 合并前是否强制独立 clean 审查。默认 `false`(审查可选);设为 `true` 则要求 `no-review`/`not-clean`/`review-hold`/`review-not-approved`/`tip-moved`/`review-mission-mismatch` 全部通过。`tower_review` 始终可用。 |
|
|
56
57
|
| `review.maxRoundsPerReviewer` | `5` | 每个 reviewer 对每条分支的审查轮数上限。 |
|
|
57
58
|
| `merge.timeoutMs` | `900000`(15 分钟) | `tower_merge` 默认超时,包含钩子;单次调用的 `timeoutMs` 优先。 |
|
|
58
59
|
| `merge.maxBufferBytes` | `67108864`(64 MB) | 合并输出缓冲区。 |
|
|
60
|
+
| `git.timeoutMs` | `15000` | 除 merge 外 Git 命令的默认超时;merge 使用 `merge.timeoutMs`。 |
|
|
59
61
|
| `worktrees.root` | `''` | 默认 worktree 父目录;空值推导为 `<主检出父目录>/.dsh-tower-worktrees/<hash>`。 |
|
|
60
62
|
| `spawn.workerPreset` | `tower-worker` | `role=worker` 使用的 preset。 |
|
|
61
63
|
| `spawn.reviewerPreset` | `tower-reviewer` | `role=reviewer` 使用的 preset。 |
|
|
@@ -70,14 +72,14 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
70
72
|
| `send.maxPerMinute` | `120` | 每个仓库的 `tower_send` 每分钟速率限制(0 关闭);超出返回 `rate-limited`。 |
|
|
71
73
|
| `send.interruptDefault` | `false`(随包 patch:`true`) | `tower_send.interrupt` 的默认值(投递前先中断运行中的回合)。 |
|
|
72
74
|
| `tests.enabled` | `true` | worktree 的 `package.json` 配置了测试发现时运行它。 |
|
|
73
|
-
| `tests.admission` | `
|
|
75
|
+
| `tests.admission` | `off` | `spawn` 在启动 worker 前运行发现(fail-closed,不启动子代理);`complete` 在 `tower_complete` 时运行;`off` 只在显式 `tower_test` 时运行(默认;不会自动执行仓库代码)。 |
|
|
74
76
|
| `tests.timeoutMs` | `60000` | 每条发现命令的超时。 |
|
|
75
77
|
| `tests.maxBytes` | `16777216`(16 MiB) | 每条命令捕获的 stdout 上限。 |
|
|
76
78
|
| `tests.autoChecks` | `true` | 向完成报告附加自动 `tests` 检查。 |
|
|
77
79
|
| `tests.allowPackageDiscovery` | `true` | 允许仓库控制的 `tower.testDiscovery` 命令(会执行 worktree 代码;见安全页)。 |
|
|
78
80
|
| `gates.requireChecks` | `false` | 合并前要求至少一条 `tower_complete.checks` 证据(`no-check-evidence`)。 |
|
|
79
|
-
| `gates.requireTests` | `false` |
|
|
80
|
-
| `gates.allowReviewExemption` | `false` |
|
|
81
|
+
| `gates.requireTests` | `false` | 要求针对当前 mission 分支 tip 的通过测试回执(`tests-missing`);与审查门禁独立。 |
|
|
82
|
+
| `gates.allowReviewExemption` | `false` | 在 `review.requireReview:true` 时,允许用 `reviewPolicy:"none"` + 非空理由跳过审查 blocker;默认故意关闭;全局审查关闭时无影响。 |
|
|
81
83
|
| `docs.board` | `true` | 变更后写入 `MISSIONS.md`。 |
|
|
82
84
|
| `docs.boardLocation` | `common` | `common` = `<git-common-dir>/dsh-tower/MISSIONS.md`;`repo` = `<repoRoot>/MISSIONS.md`。 |
|
|
83
85
|
| `leases.ttlMs` | `900000` | 默认咨询式租约寿命;过期不会自动释放。 |
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[English](../../reference/errors.md) | **简体中文**
|
|
4
4
|
|
|
5
5
|
工具失败是抛出的错误,不是 `{ok:false}` 结果(门禁结果是例外:见 [blockers.md](blockers.md))。
|
|
6
|
-
每个抛出的错误都以稳定 code
|
|
6
|
+
每个抛出的错误都以稳定 code 作为消息前缀。本版本定义 31 个工具错误码。
|
|
7
7
|
|
|
8
8
|
```
|
|
9
9
|
[tower:mission-not-found] unknown mission "M9"
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
| `mission-not-found` | 不存在该 id 的 mission。 | 过期或拼错的 `M9` |
|
|
21
21
|
| `finding-not-found` | 不存在该 id 的 finding。 | `tower_findings({id:"F99"})` |
|
|
22
22
|
| `invalid-transition` | 请求的状态转换不被允许。 | 尝试 blocked→merged;finding 状态图之外的流转;reviewer 修改 finding;在还有未完成任务、blocker、无提交或未读消息时完成 mission。 |
|
|
23
|
+
| `worktree-dirty` | mission worktree 有未提交改动,不能完成。 | `tower_complete` 前遗留 staged/modified/untracked 文件 |
|
|
23
24
|
| `state-locked` | 另一个 Tower 写入者持有 state 锁。 | 锁超时后的并发写 |
|
|
24
25
|
| `state-corrupt` | Tower state 文件不可读或无效。 | 手工编辑过的 `state.json` |
|
|
25
26
|
| `config-invalid` | Tower 配置无效。 | 格式错误的仓库 `config.json`、违反 schema 的值 |
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[English](../../reference/events.md) | **简体中文**
|
|
4
4
|
|
|
5
5
|
Tower 在工具成功调用(变更与只读)后发出进程内 Cordis 事件,其他插件因此可以观察仓库,而不必轮询。
|
|
6
|
-
|
|
6
|
+
本版本发出 29 个进程内事件。事件**不会**通过网络发送,也不包含遥测。
|
|
7
7
|
|
|
8
8
|
```js
|
|
9
9
|
ctx.on('tower/merge', (payload) => {
|
|
@@ -33,14 +33,17 @@ ctx.on('tower/merge', (payload) => {
|
|
|
33
33
|
| `tower/status` | `tower_status` 读取面板之后 |
|
|
34
34
|
| `tower/preflight` | `tower_preflight` 评估门禁之后 |
|
|
35
35
|
| `tower/doctor` | `tower_doctor` 完成只读检查之后 |
|
|
36
|
+
| `tower/recover` | `tower_recover` 查看或清理 stale state 锁之后 |
|
|
36
37
|
| `tower/test` | `tower_test` 运行测试发现之后 |
|
|
37
38
|
| `tower/batch` | `tower_batch` 创建、查看或关闭 batch 之后 |
|
|
38
39
|
| `tower/resource` | `tower_resource` 获取/释放/查看租约之后 |
|
|
39
40
|
| `tower/import_legacy` | `tower_import_legacy` 保存旧版快照之后 |
|
|
40
41
|
| `tower/legacy_map` | `tower_legacy_map` 映射或列出旧 id 之后 |
|
|
42
|
+
| `tower/mode` | `tower_mode` 读取/变更会话模式之后(`value.mode`、`allowAgentControl`、`deniedWhileActive`) |
|
|
43
|
+
| `tower/control` | `tower_control` 进入/退出模式之后(`value.mode`,操作者/理由在 `value` 中) |
|
|
44
|
+
| `tower/reload` | `tower_reload` 返回重载调度/结果之后 |
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
`{tool, repoRoot, value}`。监听器抛错不会影响工具结果。
|
|
46
|
+
payload 是 JSON:`{tool, repoRoot, value}`。监听器抛错不会影响工具结果。
|
|
44
47
|
|
|
45
48
|
需要可持久审计的调用链时,请使用 `<git-common-dir>/dsh-tower/activity.jsonl` 与 `MISSIONS.md`;
|
|
46
49
|
事件是进程内的,不会重放。
|
|
@@ -49,8 +49,9 @@ stdout/stderr 文件、SHA-256 哈希、退出码、计数、required 名称、w
|
|
|
49
49
|
|
|
50
50
|
- `tower_test({repoRoot,missionId})` 按需运行发现并记录回执。
|
|
51
51
|
- `tests.autoChecks` 开启时,`tower_complete` 会添加一条 `tests` 检查。
|
|
52
|
-
- `gates.requireChecks` 要求任意完成检查;`gates.requireTests`
|
|
53
|
-
|
|
52
|
+
- `gates.requireChecks` 要求任意完成检查;`gates.requireTests` 要求针对当前 mission 分支 tip 的
|
|
53
|
+
通过回执(`tests-missing`)。测试门禁与审查独立:即使 `review.requireReview:false`,测试门禁仍
|
|
54
|
+
检查分支 tip。
|
|
54
55
|
|
|
55
56
|
## 信任边界
|
|
56
57
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[English](../../reference/tools.md) | **简体中文**
|
|
4
4
|
|
|
5
5
|
所有工具都接受 `repoRoot`(目标 Git 仓库内的绝对路径)并返回 JSON。
|
|
6
|
-
本构建最多
|
|
7
|
-
`mode.allowAgentControl`
|
|
8
|
-
`tower_reload`,以及 2.1 的验证/运维工具 `tower_doctor`、`tower_test`、`tower_batch`、
|
|
6
|
+
本构建最多 29 个工具:下列 19 个核心仓库工具(含 `tower_interrupt`)、`tower_mode`、默认注册的
|
|
7
|
+
`tower_control`(设 `mode.allowAgentControl:false` 可禁用)、`dev.hotReload` 开启时注册的
|
|
8
|
+
`tower_reload`,以及 2.1 的验证/运维工具 `tower_doctor`、`tower_recover`、`tower_test`、`tower_batch`、
|
|
9
9
|
`tower_resource`、`tower_import_legacy`、`tower_legacy_map`。
|
|
10
10
|
|
|
11
11
|
`tower_mission` 使用 `id`(别名 `missionId`);其他所有 mission 级工具使用 `missionId`
|
|
@@ -32,7 +32,7 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
32
32
|
| 工具 | 参数 | 用途 / 说明 |
|
|
33
33
|
|---|---|---|
|
|
34
34
|
| `tower_init` | `base?`、`autoGitInit?`、`snapshotDirty?`、`reanchor?` | 创建 `<git-common-dir>/dsh-tower/state.json`。`autoGitInit` 执行 `git init` 并创建空的初始提交;`snapshotDirty` 记录一个游离快照提交作为 mission 基线;`reanchor` 仅在没有非终态 mission 时修改基线。返回 `dirty`、`snapshotCommit`、`reanchored`。 |
|
|
35
|
-
| `tower_plan` | `missions[]`(`title`、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。`reviewPolicy:"none"`
|
|
35
|
+
| `tower_plan` | `missions[]`(`title`、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。`reviewPolicy:"none"` 需要非空理由;审查门禁开启时,preflight 还要求 `gates.allowReviewExemption`。 |
|
|
36
36
|
| `tower_mission` | `id`(别名 `missionId`)、`status?`、`task_done?`、`task_drop?`+`task_drop_reason?`、`blocker?`、`clear_blockers?`、`note?`、`scope?`、`pause_cause?`、`authorization?` | 更新单个 mission。worker 可安全使用的字段是 task/blocker/note;不能在这里设置 `merged`。暂停的 mission 记录 `pause_cause`(user/failure/resource/dependency);恢复用户暂停的 mission 会遵循 `mode.requireResumeAuthorization`。 |
|
|
37
37
|
| `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。`missionId` 返回完整详情(任务/备注/审查/finding/完成报告)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。 |
|
|
38
38
|
| `tower_spawn` | `missionId`(别名 `id`)、`role`(`worker`/`reviewer`)、`name?`、`instructions?`、`worktreeRoot?`、`wait?`、`waitMs?`、`takeover?`、`includeDirtyBase?` | 准备或复用 worktree 并启动子代理。worker 使用 `tower-worker`;reviewer 要求 mission 为 `completed`,并使用只读的 `tower-reviewer`。`takeover:true` 替换已不再运行的 worker 条目(ownerGeneration 递增)。每个 mission 只能有一个活跃 worker;`spawn.maxLiveWorkers` 限制整个仓库。 |
|
|
@@ -41,7 +41,7 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
41
41
|
| `tower_diff` | `missionId`(别名 `id`)、`maxBytes?` | 只读 merge-base diff:文件、提交、有上限的 patch。供 reviewer 安全使用。 |
|
|
42
42
|
| `tower_send` | `to`、`subject`、`body`、`scope?`、`interrupt?`、`consentRef?` | 向 roster 名称、`tower` 或 `all` 发送持久消息;对运行中的 agent 会即时投递。`interrupt:true` 先中断运行中的回合(Kimi 风格)再投递;`consentRef` 记录用于审计。受 `send.maxPerMinute` 限速。 |
|
|
43
43
|
| `tower_inbox` | `limit?`、`include_read?` | 读取并确认发给调用者的消息;未读消息会阻止 `tower_complete`。 |
|
|
44
|
-
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed
|
|
44
|
+
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed`,可进入 preflight 与可选审查)。要求当前 owner、无未完成任务/blocker、≥1 个提交、无未读消息。`checks` 记录 `{name, command?, exitCode?, evidence?}` 证据;`gates.requireChecks` 可以强制要求。配置了测试发现时还会自动运行并添加 `tests` 检查;失败会抛出 `[tower:tests-failed]`。 |
|
|
45
45
|
| `tower_interrupt` | `to?`(roster 名称)、`agentId?`、`reason?` | 通过 `subagents.interrupt` 中断一个运行中的 roster agent,并记录 `state:"interrupted"`。幂等(未运行 → `interrupted:false`)。 |
|
|
46
46
|
| `tower_finding` | `type`、`title`、`summary`、`details`、`suggested_fix`、`severity?`、`missionId?`、`location?` | 记录 scope 之外的问题;reviewer 用它代替直接改文件。新 finding 初始为 `open`。 |
|
|
47
47
|
| `tower_findings` | `id?`、`status?`、`note?`、过滤(`type?`、`severity?`、`missionId?`、`open?`、`limit?`) | 生命周期工具:列出/读取/流转。状态为 `open → triaged → fixed`/`wontfix`,允许重新打开。只有控制塔或 worker 能修改;reviewer 只能列出/读取。 |
|
|
@@ -51,9 +51,10 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
51
51
|
| `tower_rebase` | `missionId`(别名 `id`) | 在 worktree 内把分支 rebase 到基线。worker 运行中或 worktree 脏时拒绝。分支已包含在基线中时返回 `alreadyMerged` 且保持分支不动。 |
|
|
52
52
|
| `tower_teardown` | `force?`、`exclude?`、`dry_run?`(别名 `dryRun`) | 移除符合条件的 merged/abandoned worktree 与分支;运行中的 agent 始终保留。 |
|
|
53
53
|
| `tower_mode` | — | 报告模式、最近修改、被暂停的 goal、被拒绝的工具、store 路径、插件版本、HMR 可用性、workspace 锁持有者。 |
|
|
54
|
-
| `tower_control` | `action`(`enter`/`exit`)、`reason` |
|
|
54
|
+
| `tower_control` | `action`(`enter`/`exit`)、`reason` | 模型主动切换模式;默认注册,设 `mode.allowAgentControl:false` 可禁用;`reason` 必填。 |
|
|
55
55
|
| `tower_reload` | `repoRoot?` | 仅开发用:请求 DSH HMR 对账 profile 并从磁盘重载 Host 插件。`dev.hotReload` 开启时注册;在工具结果返回后调度(默认 50 ms);没有 HMR 时以 `reload-unavailable` 失败。传入 `repoRoot` 时调用者必须是控制塔。 |
|
|
56
56
|
| `tower_doctor` | —(需要 `repoRoot`) | 只读健康契约:git、state schema、config、preset/subagents/HMR、workspace 锁、roster、worktree realpath/归属、测试配置、leases。返回 `{ok, checks[{id,status,detail}], environmentContract}`。从不写状态。 |
|
|
57
|
+
| `tower_recover` | `action`(`status`/`clear-stale-lock`)、`confirm?` | 恢复诊断与显式 stale state-lock 修复。`clear-stale-lock` 需要 `confirm:"CLEAR_STALE_LOCK"`,只把 PID 已死的锁移到备份文件;不会重置状态或 worktree。 |
|
|
57
58
|
| `tower_test` | `missionId`(别名 `id`) | 运行 worktree 的 `package.json` 中 `tower.testDiscovery` 配置的发现命令并记录回执;失败时 `ok:false`。自动准入检查请用 `tower_complete`。 |
|
|
58
59
|
| `tower_batch` | `action`(`create`/`status`/`close`/`abandon`)、`missionIds?`、`exclusions?`、`requiredChecks?`、`receiptPaths?`、`batchId?`、`checks?`、`treeTip?`、`receiptHashes?`、`reason?` | 有限证据批次。create 冻结成员/检查/回执;close 校验每条必需检查与回执哈希;`merged` 成员也可参与 close;abandon 可退掉过期的 open batch 且不删除 mission。成员关系不可变。 |
|
|
59
60
|
| `tower_resource` | `action`(`acquire`/`release`/`status`)、`name?`、`holder?`、`token?`、`ttlMs?`、`force?` | 每个仓库的咨询式租约,位于 `<common>/dsh-tower/leases.json`。没有 OS CPU/内存硬上限;过期的租约需要显式 `force` 与正确 token。 |
|
package/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// dsh-tower — Host entry.
|
|
2
2
|
//
|
|
3
|
-
// Tower mode is a session-level stance.
|
|
4
|
-
// `/tower` command
|
|
5
|
-
// [message]`, and `/tower <message
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Tower mode is a session-level stance. It is intentionally operable by both
|
|
4
|
+
// sides: the `/tower` command (bare `/tower`, `/tower on [message]`, `/tower
|
|
5
|
+
// off [message]`, and `/tower <message>`; trailing text is forwarded to the
|
|
6
|
+
// model as the next user message) and, when config.allowAgentControl is true
|
|
7
|
+
// (the default), the model through `tower_control`. Every change is recorded
|
|
8
|
+
// with its actor and reason; set allowAgentControl:false for human-only mode
|
|
9
|
+
// control.
|
|
9
10
|
//
|
|
10
11
|
// `/tower reload` asks DSH HMR to reconcile this profile and reload the Tower
|
|
11
12
|
// plugin from disk when the HMR service is present (dev/linked installs).
|
|
@@ -31,6 +32,11 @@ export const Config = ConfigSchema
|
|
|
31
32
|
|
|
32
33
|
let MODE = new Map()
|
|
33
34
|
let denyTools = new Set(DEFAULT_DENY_TOOLS)
|
|
35
|
+
|
|
36
|
+
function modeOn(key) {
|
|
37
|
+
const entry = key ? MODE.get(key) : undefined
|
|
38
|
+
return Boolean(entry && entry.on === true)
|
|
39
|
+
}
|
|
34
40
|
const READ_ONLY_TOOL_NAMES = new Set([
|
|
35
41
|
'tower_status',
|
|
36
42
|
'tower_roster',
|
|
@@ -44,11 +50,11 @@ const BASE_REMINDER = [
|
|
|
44
50
|
'Tower mode is active for this session. You are the control tower: plan missions, spawn workers and reviewers, and integrate only through the Tower tools.',
|
|
45
51
|
'Mission tracking lives in tower_plan / tower_mission / tower_status, not todo_write or the Team task board.',
|
|
46
52
|
'Do not create or resume goals, do not spawn teammates, and never merge without a green tower_preflight.',
|
|
47
|
-
'
|
|
53
|
+
'Tower mode changes are explicit and recorded; the user can always use /tower on|off.',
|
|
48
54
|
].join(' ')
|
|
49
55
|
|
|
50
56
|
const AGENT_CONTROL_NOTE =
|
|
51
|
-
'
|
|
57
|
+
'You may also enter or leave Tower mode with tower_control when the user intent or task state clearly calls for it; always state a concrete reason.'
|
|
52
58
|
|
|
53
59
|
function readPluginVersion() {
|
|
54
60
|
if (pluginVersion !== null) return pluginVersion
|
|
@@ -187,12 +193,15 @@ function enterMode(ctx, key, agent, by, reason) {
|
|
|
187
193
|
async function exitMode(key, by, reason) {
|
|
188
194
|
const previous = MODE.get(key)
|
|
189
195
|
if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
|
|
190
|
-
|
|
196
|
+
const wasOn = previous.on === true
|
|
197
|
+
// Keep a timestamped off tombstone so a concurrent save from another
|
|
198
|
+
// session cannot resurrect this mode entry.
|
|
199
|
+
MODE.set(key, { on: false, at: Date.now() })
|
|
191
200
|
persistModes()
|
|
192
201
|
if (previous.lock) {
|
|
193
202
|
await releaseWorkspaceLock(previous.lock.commonDir, { token: previous.lock.token }).catch(() => {})
|
|
194
203
|
}
|
|
195
|
-
return { wasOn
|
|
204
|
+
return { wasOn, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
function forwardInstruction(agent, message, attachments) {
|
|
@@ -256,7 +265,7 @@ export function apply(ctx, rawConfig) {
|
|
|
256
265
|
const key = agentKey(exec?.agent)
|
|
257
266
|
if (!key) return
|
|
258
267
|
const entry = MODE.get(key)
|
|
259
|
-
if (!entry) return
|
|
268
|
+
if (!entry || entry.on !== true) return
|
|
260
269
|
const repo = await resolveRepo(repoRoot)
|
|
261
270
|
const existing = entry.lock
|
|
262
271
|
if (existing && existing.commonDir !== repo.commonDir) {
|
|
@@ -304,7 +313,7 @@ export function apply(ctx, rawConfig) {
|
|
|
304
313
|
},
|
|
305
314
|
basePreference(exec) {
|
|
306
315
|
const key = agentKey(exec?.agent)
|
|
307
|
-
return key ? MODE.get(key)?.baseBranch ?? null : null
|
|
316
|
+
return key && modeOn(key) ? MODE.get(key)?.baseBranch ?? null : null
|
|
308
317
|
},
|
|
309
318
|
}
|
|
310
319
|
|
|
@@ -324,7 +333,7 @@ export function apply(ctx, rawConfig) {
|
|
|
324
333
|
ctx.effect(() =>
|
|
325
334
|
ctx.tools.guard((execution) => {
|
|
326
335
|
const key = agentKey(execution.agent)
|
|
327
|
-
if (!key || !
|
|
336
|
+
if (!key || !modeOn(key)) return undefined
|
|
328
337
|
if (denyTools.has(execution.name)) {
|
|
329
338
|
return `tower mode: ${execution.name} is disabled while Tower mode is active; use the Tower tools or /tower off`
|
|
330
339
|
}
|
|
@@ -345,7 +354,7 @@ export function apply(ctx, rawConfig) {
|
|
|
345
354
|
typeof ctx.on === 'function'
|
|
346
355
|
? ctx.on('agent/pre-step', (payload, next) => {
|
|
347
356
|
const key = agentKey(payload?.agent)
|
|
348
|
-
if (key &&
|
|
357
|
+
if (key && modeOn(key)) {
|
|
349
358
|
// Once Tower is active, Tower wins regardless of entry policy: clear
|
|
350
359
|
// any plan state so the two policies cannot coexist.
|
|
351
360
|
const state = planModeState(ctx, payload.agent)
|
|
@@ -359,7 +368,7 @@ export function apply(ctx, rawConfig) {
|
|
|
359
368
|
ctx.systemPrompt.section({
|
|
360
369
|
name: 'tower:mode',
|
|
361
370
|
order: 15000,
|
|
362
|
-
text: (context) => (context &&
|
|
371
|
+
text: (context) => (context && modeOn(agentKey(context.agent)) ? reminder : ''),
|
|
363
372
|
})
|
|
364
373
|
|
|
365
374
|
// Workspace-lock heartbeat; only sessions holding a lock do I/O.
|
|
@@ -474,7 +483,7 @@ export function apply(ctx, rawConfig) {
|
|
|
474
483
|
return {
|
|
475
484
|
ok: true,
|
|
476
485
|
action: args.action,
|
|
477
|
-
mode:
|
|
486
|
+
mode: modeOn(key) ? 'on' : 'off',
|
|
478
487
|
changed: args.action === 'enter' ? !outcome.wasOn : outcome.wasOn,
|
|
479
488
|
changedBy: 'agent',
|
|
480
489
|
reason,
|
|
@@ -497,7 +506,7 @@ export function apply(ctx, rawConfig) {
|
|
|
497
506
|
const baseMatch = String(rawInput ?? '').trim().match(/^base\s+(\S+)\s*$/i)
|
|
498
507
|
if (baseMatch) {
|
|
499
508
|
const entry = MODE.get(key)
|
|
500
|
-
if (!entry) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
|
|
509
|
+
if (!entry || entry.on !== true) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
|
|
501
510
|
entry.baseBranch = baseMatch[1]
|
|
502
511
|
persistModes()
|
|
503
512
|
return { kind: 'success', text: `Tower base preference set to "${entry.baseBranch}"; tower_init without an explicit base will use it.` }
|
package/lib/config.js
CHANGED
|
@@ -30,7 +30,50 @@ export const DEFAULT_DENY_TOOLS = [
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
33
|
-
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev']
|
|
33
|
+
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev']
|
|
34
|
+
|
|
35
|
+
const SECTION_KEYS = {
|
|
36
|
+
init: ['autoGitInit', 'snapshotDirty'],
|
|
37
|
+
mode: [
|
|
38
|
+
'promptSection',
|
|
39
|
+
'sectionAppend',
|
|
40
|
+
'allowAgentControl',
|
|
41
|
+
'autoPauseGoal',
|
|
42
|
+
'requireResumeAuthorization',
|
|
43
|
+
'workspaceLock',
|
|
44
|
+
'workspaceLockScope',
|
|
45
|
+
'planModePolicy',
|
|
46
|
+
'workspaceStaleMs',
|
|
47
|
+
'heartbeatMs',
|
|
48
|
+
'denyTools',
|
|
49
|
+
'storePath',
|
|
50
|
+
'maxEntries',
|
|
51
|
+
],
|
|
52
|
+
review: ['requireReview', 'maxRoundsPerReviewer'],
|
|
53
|
+
merge: ['timeoutMs', 'maxBufferBytes'],
|
|
54
|
+
git: ['timeoutMs'],
|
|
55
|
+
worktrees: ['root'],
|
|
56
|
+
spawn: [
|
|
57
|
+
'workerPreset',
|
|
58
|
+
'reviewerPreset',
|
|
59
|
+
'maxDepth',
|
|
60
|
+
'provider',
|
|
61
|
+
'workerModel',
|
|
62
|
+
'reviewerModel',
|
|
63
|
+
'maxLiveWorkers',
|
|
64
|
+
'staleMs',
|
|
65
|
+
'maxWaitMs',
|
|
66
|
+
'requireLease',
|
|
67
|
+
],
|
|
68
|
+
send: ['maxPerMinute', 'interruptDefault'],
|
|
69
|
+
tests: ['enabled', 'admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
|
|
70
|
+
gates: ['requireChecks', 'requireTests', 'allowReviewExemption'],
|
|
71
|
+
docs: ['board', 'boardLocation'],
|
|
72
|
+
leases: ['ttlMs'],
|
|
73
|
+
dev: ['hotReload', 'reloadDelayMs', 'reloadRequiredIds'],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
|
|
34
77
|
|
|
35
78
|
const ModelSchema = () =>
|
|
36
79
|
Schema.object({
|
|
@@ -88,6 +131,9 @@ export const Config = Schema.object({
|
|
|
88
131
|
.default({})
|
|
89
132
|
.description('Tower-mode behavior and persistence.'),
|
|
90
133
|
review: Schema.object({
|
|
134
|
+
requireReview: Schema.boolean()
|
|
135
|
+
.default(false)
|
|
136
|
+
.description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
|
|
91
137
|
maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
|
|
92
138
|
})
|
|
93
139
|
.default({})
|
|
@@ -98,6 +144,11 @@ export const Config = Schema.object({
|
|
|
98
144
|
})
|
|
99
145
|
.default({})
|
|
100
146
|
.description('Merge execution policy.'),
|
|
147
|
+
git: Schema.object({
|
|
148
|
+
timeoutMs: Schema.natural().min(1000).default(15000).description('Default timeout for Git commands other than merge; merge uses merge.timeoutMs.'),
|
|
149
|
+
})
|
|
150
|
+
.default({})
|
|
151
|
+
.description('Git subprocess policy.'),
|
|
101
152
|
worktrees: Schema.object({
|
|
102
153
|
root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
|
|
103
154
|
})
|
|
@@ -142,8 +193,8 @@ export const Config = Schema.object({
|
|
|
142
193
|
tests: Schema.object({
|
|
143
194
|
enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
|
|
144
195
|
admission: Schema.union(['spawn', 'complete', 'off'])
|
|
145
|
-
.default('
|
|
146
|
-
.description('Run discovery before spawning a worker, when completing, or only on explicit tower_test.'),
|
|
196
|
+
.default('off')
|
|
197
|
+
.description('Run discovery before spawning a worker, when completing, or only on explicit tower_test (default off: repository code runs only on an explicit tower_test).'),
|
|
147
198
|
timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
|
|
148
199
|
maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
|
|
149
200
|
autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
|
|
@@ -233,14 +284,39 @@ function normalizeLegacy(raw = {}) {
|
|
|
233
284
|
return out
|
|
234
285
|
}
|
|
235
286
|
|
|
287
|
+
function assertModelKeys(value, label) {
|
|
288
|
+
const bad = Object.keys(value).filter((key) => !MODEL_KEYS.includes(key))
|
|
289
|
+
if (bad.length > 0) {
|
|
290
|
+
throw new TowerToolError('config-unknown-key', `${label} has unknown key(s) ${bad.join(', ')}`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
236
294
|
function assertKnownKeys(raw, label) {
|
|
237
295
|
const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
|
|
238
296
|
if (unknown.length > 0) {
|
|
239
297
|
throw new TowerToolError(
|
|
240
298
|
'config-unknown-key',
|
|
241
|
-
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
|
|
299
|
+
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
|
|
242
300
|
)
|
|
243
301
|
}
|
|
302
|
+
for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
|
|
303
|
+
const value = raw[group]
|
|
304
|
+
if (value === undefined) continue
|
|
305
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue
|
|
306
|
+
const bad = Object.keys(value).filter((key) => !allowed.includes(key))
|
|
307
|
+
if (bad.length > 0) {
|
|
308
|
+
throw new TowerToolError(
|
|
309
|
+
'config-unknown-key',
|
|
310
|
+
`${label} has unknown key(s) ${bad.map((key) => `${group}.${key}`).join(', ')}`,
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
if ((group === 'spawn') && value.workerModel && typeof value.workerModel === 'object') {
|
|
314
|
+
assertModelKeys(value.workerModel, `${label} spawn.workerModel`)
|
|
315
|
+
}
|
|
316
|
+
if ((group === 'spawn') && value.reviewerModel && typeof value.reviewerModel === 'object') {
|
|
317
|
+
assertModelKeys(value.reviewerModel, `${label} spawn.reviewerModel`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
244
320
|
}
|
|
245
321
|
|
|
246
322
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
@@ -281,7 +357,7 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
281
357
|
// Apply the canonical default only after alias resolution: a validated
|
|
282
358
|
// Cordis config may omit this key, and an explicit legacy top-level
|
|
283
359
|
// `allowAgentControl:true` must not be shadowed by a schema default.
|
|
284
|
-
if (mode.allowAgentControl === undefined) mode.allowAgentControl =
|
|
360
|
+
if (mode.allowAgentControl === undefined) mode.allowAgentControl = true
|
|
285
361
|
if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
|
|
286
362
|
return {
|
|
287
363
|
section: mode.promptSection ?? '',
|
|
@@ -289,6 +365,7 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
289
365
|
mode,
|
|
290
366
|
review: clone(parsed.review ?? {}),
|
|
291
367
|
merge: clone(parsed.merge ?? {}),
|
|
368
|
+
git: clone(parsed.git ?? {}),
|
|
292
369
|
worktrees: clone(parsed.worktrees ?? {}),
|
|
293
370
|
spawn: clone(parsed.spawn ?? {}),
|
|
294
371
|
send: clone(parsed.send ?? {}),
|
|
@@ -370,7 +447,27 @@ export async function readRepoConfig(commonDir) {
|
|
|
370
447
|
export async function resolveRepoConfig(repo) {
|
|
371
448
|
const base = runtimeConfig()
|
|
372
449
|
const layer = await readRepoConfig(repo.commonDir)
|
|
373
|
-
|
|
450
|
+
// The mode guard is a session/profile concern. A repository checkout may
|
|
451
|
+
// only tighten it, never weaken denyTools, workspaceLock, allowAgentControl,
|
|
452
|
+
// plan policy, or resume authorization.
|
|
453
|
+
const repoLayer = { ...layer.config }
|
|
454
|
+
delete repoLayer.mode
|
|
455
|
+
delete repoLayer.section
|
|
456
|
+
delete repoLayer.allowAgentControl
|
|
457
|
+
const merged = deepMerge(base, repoLayer)
|
|
458
|
+
const repoMode = layer.config.mode
|
|
459
|
+
if (repoMode && typeof repoMode === 'object' && !Array.isArray(repoMode)) {
|
|
460
|
+
merged.mode = { ...(merged.mode ?? {}) }
|
|
461
|
+
if (repoMode.requireResumeAuthorization === true) merged.mode.requireResumeAuthorization = true
|
|
462
|
+
if (repoMode.workspaceLock === true) merged.mode.workspaceLock = true
|
|
463
|
+
if (repoMode.autoPauseGoal === true) merged.mode.autoPauseGoal = true
|
|
464
|
+
if (repoMode.workspaceLockScope === 'all') merged.mode.workspaceLockScope = 'all'
|
|
465
|
+
if (repoMode.planModePolicy === 'refuse') merged.mode.planModePolicy = 'refuse'
|
|
466
|
+
if (repoMode.allowAgentControl === false) merged.mode.allowAgentControl = false
|
|
467
|
+
if (Array.isArray(repoMode.denyTools)) {
|
|
468
|
+
merged.mode.denyTools = [...new Set([...(base.mode?.denyTools ?? DEFAULT_DENY_TOOLS), ...repoMode.denyTools])]
|
|
469
|
+
}
|
|
470
|
+
}
|
|
374
471
|
if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
|
|
375
472
|
merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
|
|
376
473
|
}
|
package/lib/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@ import { promisify } from 'node:util'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import fsp from 'node:fs/promises'
|
|
6
6
|
import { git } from './git.js'
|
|
7
|
+
import { readStateLock } from './store.js'
|
|
7
8
|
import { inspectWorkspaceLock } from './workspaceLock.js'
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile)
|
|
@@ -78,11 +79,13 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
78
79
|
checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
|
|
79
80
|
|
|
80
81
|
if (state) {
|
|
81
|
-
const
|
|
82
|
+
const lock = await readStateLock(tower.paths)
|
|
82
83
|
checks.push(
|
|
83
|
-
|
|
84
|
-
? check('state-lock', '
|
|
85
|
-
:
|
|
84
|
+
!lock.record
|
|
85
|
+
? check('state-lock', 'pass', 'no state lock file')
|
|
86
|
+
: lock.alive === false
|
|
87
|
+
? check('state-lock', 'warn', `stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone); the next mutation reclaims it atomically`)
|
|
88
|
+
: check('state-lock', 'warn', `state lock held by pid ${lock.record.pid ?? 'unknown'}; retry after it finishes`),
|
|
86
89
|
)
|
|
87
90
|
const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
|
|
88
91
|
try {
|
|
@@ -121,7 +124,7 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
121
124
|
const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
|
|
122
125
|
checks.push(
|
|
123
126
|
missingWorktrees > 0
|
|
124
|
-
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, '
|
|
127
|
+
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'inspect tower_status, restore the worktree/branch, then re-run tower_doctor')
|
|
125
128
|
: dirtyWorktrees > 0
|
|
126
129
|
? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
|
|
127
130
|
: check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
|
|
@@ -165,6 +168,9 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
165
168
|
repoRoot: tower.root,
|
|
166
169
|
commonDir: tower.commonDir,
|
|
167
170
|
stateDir: tower.paths.dir,
|
|
171
|
+
gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
|
|
172
|
+
mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
|
|
173
|
+
testsAdmission: tower.config.tests?.admission ?? 'off',
|
|
168
174
|
},
|
|
169
175
|
mode: agent ? 'ready' : 'unknown',
|
|
170
176
|
}
|
package/lib/errors.js
CHANGED
|
@@ -20,6 +20,7 @@ export const ERROR_CODES = {
|
|
|
20
20
|
'config-unknown-key': 'Tower configuration has an unknown top-level key.',
|
|
21
21
|
'git-failed': 'A git command failed.',
|
|
22
22
|
'git-timeout': 'A git command timed out or exceeded its output buffer.',
|
|
23
|
+
'worktree-dirty': 'The mission worktree has uncommitted changes and cannot complete.',
|
|
23
24
|
'spawn-unavailable': 'The subagents service is unavailable.',
|
|
24
25
|
'provider-missing': 'No subagent provider is registered.',
|
|
25
26
|
'preset-missing': 'The configured agent preset is not registered.',
|
|
@@ -45,6 +46,9 @@ export class TowerToolError extends Error {
|
|
|
45
46
|
super(`[tower:${code}] ${message}`, options.cause ? { cause: options.cause } : undefined)
|
|
46
47
|
this.name = 'TowerToolError'
|
|
47
48
|
this.code = code
|
|
49
|
+
this.retryable = options.retryable === true
|
|
50
|
+
this.nextAction = options.nextAction ?? null
|
|
51
|
+
this.details = options.details ?? null
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -95,7 +99,12 @@ function originalMessage(error) {
|
|
|
95
99
|
export function classifyError(error) {
|
|
96
100
|
if (error instanceof TowerToolError) return error
|
|
97
101
|
if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
|
|
98
|
-
return new TowerToolError(error.code, originalMessage(error), {
|
|
102
|
+
return new TowerToolError(error.code, originalMessage(error), {
|
|
103
|
+
cause: error,
|
|
104
|
+
retryable: error.retryable === true,
|
|
105
|
+
nextAction: error.nextAction ?? null,
|
|
106
|
+
details: error.details ?? null,
|
|
107
|
+
})
|
|
99
108
|
}
|
|
100
109
|
if (error instanceof GitError || error?.name === 'GitError') {
|
|
101
110
|
const message = originalMessage(error)
|
|
@@ -104,8 +113,11 @@ export function classifyError(error) {
|
|
|
104
113
|
return new TowerToolError(code, message, { cause: error })
|
|
105
114
|
}
|
|
106
115
|
const message = originalMessage(error)
|
|
116
|
+
const RETRYABLE = new Set(['state-locked', 'rate-limited', 'git-timeout', 'workspace-busy', 'merge-intent-pending'])
|
|
107
117
|
for (const [code, pattern] of RULES) {
|
|
108
|
-
if (pattern.test(message))
|
|
118
|
+
if (pattern.test(message)) {
|
|
119
|
+
return new TowerToolError(code, message, { cause: error, retryable: RETRYABLE.has(code) })
|
|
120
|
+
}
|
|
109
121
|
}
|
|
110
122
|
return new TowerToolError('tool-internal', message, { cause: error })
|
|
111
123
|
}
|
package/lib/git.js
CHANGED
|
@@ -4,6 +4,40 @@ import path from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
const GIT_TIMEOUT_MS = 15000
|
|
6
6
|
const MAX_BUFFER = 16 * 1024 * 1024
|
|
7
|
+
let configuredTimeoutMs = GIT_TIMEOUT_MS
|
|
8
|
+
|
|
9
|
+
/** Override the default Git timeout from resolved Tower config. */
|
|
10
|
+
export function setGitTimeout(ms) {
|
|
11
|
+
const value = Number(ms)
|
|
12
|
+
if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BLOCKED_GIT_ENV = new Set([
|
|
16
|
+
'GIT_DIR',
|
|
17
|
+
'GIT_WORK_TREE',
|
|
18
|
+
'GIT_INDEX_FILE',
|
|
19
|
+
'GIT_OBJECT_DIRECTORY',
|
|
20
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
21
|
+
'GIT_COMMON_DIR',
|
|
22
|
+
'GIT_NAMESPACE',
|
|
23
|
+
'GIT_PREFIX',
|
|
24
|
+
'GIT_CEILING_DIRECTORIES',
|
|
25
|
+
'GIT_CONFIG_GLOBAL',
|
|
26
|
+
'GIT_CONFIG_SYSTEM',
|
|
27
|
+
'GIT_CONFIG_NOSYSTEM',
|
|
28
|
+
'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
29
|
+
'GIT_QUARANTINE_PATH',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
/** Git must not inherit a repository redirect from the DSH process environment. */
|
|
33
|
+
export function gitEnv(source = process.env) {
|
|
34
|
+
const env = {}
|
|
35
|
+
for (const [key, value] of Object.entries(source)) {
|
|
36
|
+
if (BLOCKED_GIT_ENV.has(key)) continue
|
|
37
|
+
env[key] = value
|
|
38
|
+
}
|
|
39
|
+
return env
|
|
40
|
+
}
|
|
7
41
|
|
|
8
42
|
export class GitError extends Error {
|
|
9
43
|
constructor(args, detail, code, meta = {}) {
|
|
@@ -23,10 +57,10 @@ export function git(cwd, args, options = {}) {
|
|
|
23
57
|
args,
|
|
24
58
|
{
|
|
25
59
|
cwd,
|
|
26
|
-
timeout: options.timeoutMs ??
|
|
60
|
+
timeout: options.timeoutMs ?? configuredTimeoutMs,
|
|
27
61
|
maxBuffer: options.maxBuffer ?? MAX_BUFFER,
|
|
28
62
|
windowsHide: true,
|
|
29
|
-
env:
|
|
63
|
+
env: gitEnv(),
|
|
30
64
|
},
|
|
31
65
|
(error, stdout, stderr) => {
|
|
32
66
|
if (error) {
|