dsh-tower 2.6.0 → 2.7.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/CHANGELOG.md +82 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +2 -1
- package/README.md +6 -1
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +97 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +25 -24
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +29 -1
- package/cordis.patch.yml +50 -0
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +6 -4
- package/docs/guide/install-upgrade-migration.md +26 -0
- package/docs/operations/runbook.md +1 -1
- package/docs/reference/blockers.md +8 -4
- package/docs/reference/component-surface.md +7 -2
- package/docs/reference/config.generated.md +6 -4
- package/docs/reference/config.md +1 -2
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +33 -1
- package/docs/reference/testing.md +4 -2
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +2 -2
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +1 -2
- package/docs/zh/reference/testing.md +3 -2
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/config/schema.js +47 -17
- package/domain/contracts/outputs.js +0 -1
- package/domain/contracts/tool-inputs/missions.js +9 -1
- package/domain/evidence.js +283 -0
- package/domain/gate.js +11 -31
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/doctor.js +2 -1
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +8 -3
- package/host/tool-registry.js +14 -12
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +13 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +2 -0
- package/scripts/component-surface.json +68 -20
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +21 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +68 -4
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/spawn.js +47 -49
- package/tools/test.js +2 -1
- package/types/index.d.ts +66 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,88 @@
|
|
|
4
4
|
|
|
5
5
|
## 未发布(Unreleased)
|
|
6
6
|
|
|
7
|
+
## 2.7.0(2026-11-10)
|
|
8
|
+
|
|
9
|
+
### 新增
|
|
10
|
+
|
|
11
|
+
- **#148 检查任务(survey)成为独立的只读任务形状**:`tower_plan` 接受 `kind: "survey"`,
|
|
12
|
+
记录 `readCommit` 与 `readDirty`,不创建分支、工作树或提交;完成时要求 HEAD 与记录的提交一致、
|
|
13
|
+
受版本控制的文件状态不变,并接受可选的结构化结果 `{paths, symbols, notes, plan}`。
|
|
14
|
+
`tower-survey` 是独立的可选组件行,随包提供只读 preset 与可选的 detached sandbox preset;
|
|
15
|
+
survey 不占用执行代理并发预算,也不会被要求合并或 rebase。完成的 survey 现在是终态,可归档、
|
|
16
|
+
满足依赖、不再阻塞 `tower_init --reanchor`,也不再被规划为“未完成工作”。
|
|
17
|
+
- **#148 可写的 detached survey sandbox**:`survey.sandbox: "detached"` 为需要写文件的检查任务
|
|
18
|
+
提供隔离工作树(无分支,HEAD 固定在 `readCommit`),完成后清理;只读检查仍使用共享检出。
|
|
19
|
+
- **#148 依赖的新鲜度与结果传递**:`survey-stale` 警告在依赖读取落后于当前基准时出现;
|
|
20
|
+
`gates.requireFreshSurvey: true` 可将它提升为合并阻塞。依赖 briefing 会带上 survey 的
|
|
21
|
+
`readCommit` 与有界结果(路径/符号/计划/备注)。
|
|
22
|
+
- **#149 形状注册表与维度审计机制**:`domain/shapes/{index,build,survey}.js` 成为任务生命周期的
|
|
23
|
+
唯一来源;生成式 `docs/reference/shapes.md` 列出 16 个状态维度的裁决、拥有模块与机械规则;
|
|
24
|
+
`scripts/shape-audit.mjs` 的 12 条规则随 `npm run check` 运行,阻止新的 kind/role 特例与
|
|
25
|
+
第二套状态词汇。
|
|
26
|
+
- **#149 单一证据读取器**:`domain/evidence.js` 统一读取完成检查、测试发现回执与调用方检查,
|
|
27
|
+
并保留来源标签;`tower_complete` 不再复制合成 check,`tower_test` 只返回回执。
|
|
28
|
+
- **#149 旧版导入的严格适配器**:`adapters/legacy.js` 声明一种载荷对应一种格式,拒绝混合
|
|
29
|
+
mission id 命名空间,把 `unresolved` / `dropped` / `remapped` 如实写入摘要与快照记录;导入
|
|
30
|
+
仍然只读,绝不激活或恢复任务。
|
|
31
|
+
- **#149 形状拥有路径权限**:`shape.reach` 声明 `ownsPaths` 与唯一围栏,`domain/scope.js` 的
|
|
32
|
+
`scopeFenceBlocker` 成为所有门禁、规划与工作树检查的共同来源。
|
|
33
|
+
- **#149 角色与形状正交**:`domain/roles.js` 的 `ROLE_CONTRACT` 统一解析 spawn、preset、roster、
|
|
34
|
+
briefing 与预算,不再出现“角色 ∧ 形状”条件;`reviewer` 仍只绑定完成的 build 任务。
|
|
35
|
+
- **#149 审查、批次、租约与阻塞状态的契约化**:reviewer 在记录 commit 的 detached checkout 上
|
|
36
|
+
审查,裁决绑定该提交;batch 有显式的 `open → closed|abandoned` 生命周期;过期租约需要显式
|
|
37
|
+
`force` 才能被他人恢复;`blocked` 只由 blockers 派生,不能直接写入。
|
|
38
|
+
|
|
39
|
+
### 变更
|
|
40
|
+
|
|
41
|
+
- **状态 schema 从 v3 直接迁移到 v5**:`state.json` 在首次写入时自动执行 v3 → v4 → v5 迁移,
|
|
42
|
+
保留预迁移备份并只推进一次 `revision`。v4 为 survey 增加 `readCommit`/`readDirty` 字段、
|
|
43
|
+
移除 worktree/branch/baseCommit/merge 字段;v5 从 blockers 派生 `blocked` 并规范化 batch 状态。
|
|
44
|
+
只读加载仍在内存中迁移,不回写文件;计划但从未启动的旧 survey 会用 `state.base` 锚定。
|
|
45
|
+
- **配置面 57 → 56 键**:移除 `tests.autoChecks`。旧配置会收到带迁移说明的
|
|
46
|
+
`config-unknown-key`,指向 `tower_test` 的 `mission.testRuns` 回执与 `domain/evidence.js`;
|
|
47
|
+
没有替代开关。可见组件仍为 12 个(内部 4 个),工具仍为 29 个,preset 增至 4 个。
|
|
48
|
+
- **直接写 `blocked` 状态被拒绝**:`tower_mission` 不再接受 `status: "blocked"`;使用
|
|
49
|
+
`blocker` 报告阻塞,使用 `clear_blockers` 解除。`paused` 与 `blocked` 不再重叠。
|
|
50
|
+
- **survey 的合并/变基/预检/差异动作被明确拒绝**:`tower_merge`、`tower_rebase`、
|
|
51
|
+
`tower_preflight` 与 `tower_diff` 对 survey 返回可操作的 `invalid-transition`,而不是
|
|
52
|
+
合成 `tip-moved`/`no-review` 等 build 语义。
|
|
53
|
+
- **PR 模板新增形状/角色问题**,并要求说明是否新增了应属于 shape 的条件;生成的形状参考
|
|
54
|
+
与 ADR 0006 同步更新。
|
|
55
|
+
|
|
56
|
+
- **#148/#149 形状注册表 W4:证据、旧版导入、scope 与角色的一次性契约。** `domain/evidence.js`
|
|
57
|
+
成为测试证据的唯一读取者:测试发现回执记录在 `mission.testRuns` 中并按分支 tip 绑定,
|
|
58
|
+
`tower_complete` 不再把同一证据复制成合成的 `tests` check,门禁、`tower_batch` 与 completion
|
|
59
|
+
校验都走同一个读取器。删除配置键 `tests.autoChecks`(写入旧键会收到带迁移说明的
|
|
60
|
+
`config-unknown-key`),配置键 57 → 56。`adapters/legacy.js` 改为严格适配器契约:声明格式与
|
|
61
|
+
状态重映射(Tower Codex `retired` → `abandoned`)、拒绝混合 mission id 命名空间、把
|
|
62
|
+
`unresolved` / `dropped` / `remapped` 如实写入摘要与快照记录,并只向消费方暴露规范的
|
|
63
|
+
`pausedMissionIds`。shape 以 `reach` 拥有路径权限,`domain/scope.js` 的 `scopeFenceBlocker`
|
|
64
|
+
是唯一围栏;角色与 shape 正交,`domain/roles.js` 的 `ROLE_CONTRACT` 让 spawn 预检、preset、
|
|
65
|
+
roster 与 gate 各自解析,不再出现"角色 ∧ shape"条件。`shape:audit` 新增四条规则
|
|
66
|
+
`evidence-single-read`、`legacy-vocabulary`、`scope-reach`、`role-shape-conflation`。
|
|
67
|
+
- **#149 生成的形状参考改为维度清单。** `docs/reference/shapes.md` 现在按 16 个维度列出裁决
|
|
68
|
+
(shape-owned / correctly generic / redesigned)、拥有模块与机械规则;W1–W4 的裁决同时记录在
|
|
69
|
+
ADR 0006 的 W4 附录中。
|
|
70
|
+
|
|
71
|
+
### 修复
|
|
72
|
+
|
|
73
|
+
- **完成的 survey 不再死锁管理动作**:`tower_init --reanchor` 不再因一个完成的 survey 被拒绝;
|
|
74
|
+
survey 可归档并计入 `archive.maxMissions`;规划不再把它算作未完成工作。
|
|
75
|
+
- **survey 完成可正常确认收件箱**:只读与 sandbox preset 现在包含 `tower_inbox`,避免存在未读
|
|
76
|
+
消息时完成被 `completion.js` 拒绝。
|
|
77
|
+
- **旧版计划中的 survey 可迁移**:当 v2.6.0 的 survey 只有 `kind`、没有 baseCommit/worktree 时,
|
|
78
|
+
迁移用 `state.base` 与真实 tracked fingerprint 锚定,不再让每次加载都以
|
|
79
|
+
`state-migration-failed` 失败。
|
|
80
|
+
- **detached sandbox 的 briefing 使用 sandbox 路径**:不再把 `workdir` 指向主检出,并把空 scope
|
|
81
|
+
说明改为“可写 sandbox”而非“只读检查”。
|
|
82
|
+
- **归档 survey 的依赖证据不再丢失**:归档索引保留有界的 `readCommit` 与结果;依赖 briefing 与
|
|
83
|
+
`requireFreshSurvey` 在归档后仍能判断新鲜度,不会因缺少证据而失败开放。
|
|
84
|
+
- **已记录的 sandbox 决定完成路径**:`mission.sandbox` 优先于当前配置,避免运行中把 detached 改回
|
|
85
|
+
`none` 时错误删除 sandbox 或切换完成规则。
|
|
86
|
+
- **review checkout 的陈旧路径可恢复**,teardown 会清理残留的审查检出;survey 不占 build 执行
|
|
87
|
+
代理预算。
|
|
88
|
+
|
|
7
89
|
## 2.6.0(2026-11-01)
|
|
8
90
|
|
|
9
91
|
### 新增
|
package/COMPATIBILITY.md
CHANGED
|
@@ -5,6 +5,7 @@ is shipped in the npm package; doctor reads it locally and never contacts a regi
|
|
|
5
5
|
|
|
6
6
|
| dsh version | tag / commit | verified on | what was run |
|
|
7
7
|
|---|---|---|---|
|
|
8
|
+
| 0.2.1-rc.2 | v2.7.0 | 2026-11-10 | mount smoke (28/28 default tools, 3 skills, 4 presets with distinct labels, 1 bundle row), `npm run test:property` (111), full static gate chain, focused shape/survey/evidence/scope/role/review/batch/leases tests, CI `test (22)`, `test (24)`, `test (22, windows)`, `mount`, `mount-fixtures` |
|
|
8
9
|
| 0.2.1-rc.2 | v2.6.0 | 2026-11-01 | mount smoke (28/28 default tools, 3 skills, 2 presets with distinct labels, 1 bundle row), `npm test` (729), `npm run test:property` (111), `npm run check` (docs + generated + component + surface + pack 294 + types + architecture + domain), `npm run smoke:mount` |
|
|
9
10
|
| 0.2.1-rc.2 | v2.5.0 | 2026-11-01 | mount smoke (28/28 default tools, 3 skills, 2 presets with distinct labels, 1 bundle row), `npm test` (696), `npm run test:property` (111), `npm run check` (docs + generated + component + surface + pack 293 + types + architecture + domain), `npm run release:check -- --mode tag --tag v2.5.0` |
|
|
10
11
|
| 0.2.1-rc.2 | v2.4.2 | 2026-10-24 | mount smoke (28/28 default tools, 3 skills, 2 presets with distinct labels, 1 bundle row), `npm test` (631), docs + seven generated checks, types coverage 148/148, components + philosophy + surface gates |
|
package/CONTRIBUTING.md
CHANGED
|
@@ -126,7 +126,7 @@ the push gates; CI schedules it nightly.
|
|
|
126
126
|
|
|
127
127
|
## Pull request checklist
|
|
128
128
|
|
|
129
|
-
1. `npm test` passes locally (
|
|
129
|
+
1. `npm test` passes locally (786 tests today, 785 run + 1 platform skip, including protocol end-to-end against a real temporary Git repository and mock spawn coverage), `npm run test:property` passes (111 generated property cases; also part of `npm run check`), and `npm run docs:check` passes.
|
|
130
130
|
2. New behavior has a test. Bug fixes should add a test that fails before the fix.
|
|
131
131
|
3. No secrets, tokens, personal paths, session logs, or generated artifacts are added. `.gitignore` covers local acceptance output; do not force-add it.
|
|
132
132
|
4. Keep the public API and state-file formats compatible; if a change is breaking, call it out in `CHANGELOG.md` and bump the version.
|
package/README.en.md
CHANGED
|
@@ -16,6 +16,7 @@ The repository root **is** the DSH bundle: `package.json` declares `dsh.bundle.p
|
|
|
16
16
|
|
|
17
17
|
- **Configurable Tower mode.** `/tower` toggles a session-level stance that narrows the tool surface and injects the Tower protocol. `/tower on|off <instruction>` also forwards your instruction to the model in the same step. By default the model may also enter/leave with `tower_control` and a concrete reason; set `mode.allowAgentControl:false` for human-only control.
|
|
18
18
|
- **Missions with real isolation.** Every mission gets its own branch and Git worktree; workers never touch your checkout.
|
|
19
|
+
- **Two mission shapes, one registry.** Every mission is a `build` or a `survey`, and the shape owns its statuses, completion evidence, provisioning, path reach and spawn contract instead of scattering `if (kind === …)` branches. Surveys are read-only: they pin a `readCommit` (by default in a detached sandbox) and complete without commits, a branch or a merge. `npm run shape:audit` fails a second vocabulary, a per-kind conditional outside `domain/shapes/`, an evidence read outside `domain/evidence.js`, and a role check combined with a shape check.
|
|
19
20
|
- **Review-gated merges.** The review policy is one tri-state control on the plugin page: `off`, `optional` (the default) or `required` (`review.mode` in config). With `required`, a reviewer bound to the current branch tip must submit a `clean` / `merge` verdict before merge; the merge gate re-checks scope, staleness, task completion, review history, and worktree ownership before a `--no-ff` merge. Independent review is optional by default; use `reviewPolicy:"none"` + reason plus `gates.allowReviewExemption:true` for a single mission when the policy is required. Multi-route review is recorded per round: while a non-clean round keeps the mission `active`, a later reviewer may still submit its verdict for the same reviewed tip and it appends to that round. A completed `survey` dependency satisfies the dependency gate (a build dependency still must be merged). Upgrade note: the old boolean review toggle is a one-release alias; the mapping is in [install, upgrade and migration](docs/guide/install-upgrade-migration.md).
|
|
20
21
|
- **Durable coordination.** Roster, inbox, findings, reviews, and mission state are persisted under `.git/dsh-tower/`, so a restart does not lose the plan.
|
|
21
22
|
- **Bounded state growth.** Terminal missions whose worktree is gone move to `.git/dsh-tower/archive/` once `archive.afterDays`/`archive.maxMissions` says so, keeping their tasks, reviews, findings and completion report; mutations then rewrite only active work. `tower_status includeArchived` lists compact archived summaries, and `tower_doctor` reports archive size, count and policy.
|
|
@@ -196,7 +197,7 @@ The list above is the 22 core tools plus 5 of the component-owned optional tools
|
|
|
196
197
|
- **Worktree ownership.** Dirty worktrees, a running worker, or a branch owned by a closed mission block teardown/merge.
|
|
197
198
|
- **Goal isolation.** Entering Tower mode pauses the active goal and records it; leaving does not resume it. Use `/tower off` and resume the goal yourself.
|
|
198
199
|
|
|
199
|
-
|
|
200
|
+
31 stable blocker/failure codes (including gate guards, `no-check-evidence`, `worker-stale`, test gates, and merge/rebase operation failures) — the complete list with triggers and fixes is in [docs/reference/blockers.md](docs/reference/blockers.md). Tool-level error codes are in [docs/reference/errors.md](docs/reference/errors.md); events are in [docs/reference/events.md](docs/reference/events.md).
|
|
200
201
|
|
|
201
202
|
## How it works
|
|
202
203
|
|
package/README.md
CHANGED
|
@@ -16,6 +16,11 @@ Tower 模式与 mission/审查工作流的灵感来自 Moonshot AI 的 **Kimi Co
|
|
|
16
16
|
|
|
17
17
|
- **可配置的 Tower 模式。** `/tower` 切换会话级模式:收窄工具面并注入 Tower 协议;`/tower on|off <指令>` 还能把指令作为下一步用户消息转发给模型。默认也允许 Agent 通过 `tower_control` 带理由进出;设 `mode.allowAgentControl:false` 可改为仅人类控制。
|
|
18
18
|
- **任务级隔离。** 每个 mission 使用独立分支和 Git worktree,worker 不会碰到你的主检出。
|
|
19
|
+
- **两种 mission 形状,一个注册表。** 每个 mission 是 `build` 或 `survey`,其状态、完成证据、
|
|
20
|
+
供给方式、路径权限与 spawn 契约都由 shape 拥有,不再散落 `if (kind === …)` 分支。survey 只读:
|
|
21
|
+
固定一个 `readCommit`(默认在 detached sandbox 中)即可完成,不需要提交、分支或合并。
|
|
22
|
+
`npm run shape:audit` 会拒绝第二套词汇、`domain/shapes/` 之外的按 kind 条件、
|
|
23
|
+
`domain/evidence.js` 之外的证据读取,以及角色检查与 shape 检查混合的写法。
|
|
19
24
|
- **审查门禁合并。** 审查策略是插件页上的一个三态控件:`off`、`optional`(默认)或 `required`(配置键 `review.mode`)。设为 `required` 时,审查者针对当前分支 tip 给出 `clean` / `merge` 结论;合并门禁重新校验 scope、分支新鲜度、任务完成度、审查记录与 worktree 归属,通过后才执行 `--no-ff` 合并。独立审查默认可选;策略为 `required` 时,单个 mission 可用 `reviewPolicy:"none"` + 理由并开启 `gates.allowReviewExemption:true` 做更窄豁免。升级提示:旧的布尔审查开关是保留一个版本的别名;映射关系见[安装、升级与迁移](docs/zh/guide/install-upgrade-migration.md)。
|
|
20
25
|
- **可恢复的协作状态。** roster、inbox、findings、reviews 与 mission 状态持久化在 `.git/dsh-tower/` 下,重启不会丢计划。
|
|
21
26
|
- **受控的状态增长。** worktree 已移除的终态任务在达到 `archive.afterDays`/`archive.maxMissions` 后移入 `.git/dsh-tower/archive/`,保留 tasks、reviews、findings 与完成报告;此后 mutation 只重写活跃工作。`tower_status includeArchived` 列出压缩后的归档摘要,`tower_doctor` 报告归档大小、数量与策略。
|
|
@@ -195,7 +200,7 @@ reviewer 行,策略为 `required` 时会以带解释的 `preset-missing` 锁
|
|
|
195
200
|
- **worktree 归属。** 脏工作树、运行中的 worker、已关闭 mission 的分支都会阻止合并或 teardown。
|
|
196
201
|
- **goal 隔离。** 进入 Tower 模式会暂停活跃 goal 并记录;退出不会自动恢复,需要你自行恢复。
|
|
197
202
|
|
|
198
|
-
|
|
203
|
+
31 个稳定 blocker/failure code(含 gate 守卫、`no-check-evidence`、worker-stale、tests 门禁与 merge/rebase 操作失败码)——完整清单、触发条件与修复动作见 [docs/reference/blockers.md](docs/reference/blockers.md)。工具本身抛出的错误码见 [docs/reference/errors.md](docs/reference/errors.md),事件见 [docs/reference/events.md](docs/reference/events.md)。
|
|
199
204
|
|
|
200
205
|
## 工作原理
|
|
201
206
|
|
package/adapters/leases.js
CHANGED
|
@@ -247,7 +247,7 @@ export async function acquireLease(commonDir, options = {}) {
|
|
|
247
247
|
'lease-busy',
|
|
248
248
|
`lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) until ${expiresAt}` +
|
|
249
249
|
(expired
|
|
250
|
-
? `; the lease expired at ${expiresAt} but is not taken over automatically —
|
|
250
|
+
? `; the lease expired at ${expiresAt} but is not taken over automatically — an explicit tower_resource release with force: true recovers it`
|
|
251
251
|
: ''),
|
|
252
252
|
)
|
|
253
253
|
}
|
|
@@ -269,9 +269,12 @@ export async function acquireLease(commonDir, options = {}) {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
/**
|
|
272
|
-
* Release a lease.
|
|
273
|
-
*
|
|
274
|
-
* `
|
|
272
|
+
* Release a lease. A live lease can only be released by the holder that owns
|
|
273
|
+
* its token. An expired lease is recoverable: the holder may still release it
|
|
274
|
+
* with the original token, and `force: true` lets the tower take over an
|
|
275
|
+
* EXPIRED lease whose holder crashed — that is explicit recovery, never silent
|
|
276
|
+
* takeover, and a live lease always refuses a foreign token. Returns `true` when
|
|
277
|
+
* removed and `false` when no such lease exists.
|
|
275
278
|
*/
|
|
276
279
|
export async function releaseLease(commonDir, options = {}) {
|
|
277
280
|
const name = options?.name
|
|
@@ -295,10 +298,21 @@ export async function releaseLease(commonDir, options = {}) {
|
|
|
295
298
|
const { doc } = await readLeaseDocument(commonDir)
|
|
296
299
|
const existing = doc.leases[name]
|
|
297
300
|
if (!existing) return false
|
|
301
|
+
const expired = now >= existing.expiresAt
|
|
298
302
|
if (existing.token !== token) {
|
|
299
|
-
|
|
303
|
+
if (expired && force) {
|
|
304
|
+
// Explicit recovery of a crashed holder: only an expired lease and only
|
|
305
|
+
// with `force`. Live leases below still refuse a foreign token.
|
|
306
|
+
} else {
|
|
307
|
+
fail(
|
|
308
|
+
'lease-token',
|
|
309
|
+
expired
|
|
310
|
+
? `lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) and expired at ${new Date(existing.expiresAt).toISOString()}; the token does not match — pass force: true to take over the expired lease`
|
|
311
|
+
: `lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) until ${new Date(existing.expiresAt).toISOString()}; a live lease can only be released by its holder`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
300
314
|
}
|
|
301
|
-
if (!force &&
|
|
315
|
+
if (!force && expired) {
|
|
302
316
|
fail(
|
|
303
317
|
'lease-busy',
|
|
304
318
|
`lease "${name}" expired at ${new Date(existing.expiresAt).toISOString()}; release it explicitly with force: true`,
|
package/adapters/legacy.js
CHANGED
|
@@ -12,16 +12,48 @@
|
|
|
12
12
|
import { fsp } from './store-fs.js'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import crypto from 'node:crypto'
|
|
15
|
+
import { isShapeKind } from '../domain/shapes/index.js'
|
|
15
16
|
|
|
16
17
|
const SCHEMA_VERSION = 1
|
|
17
18
|
const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
|
|
18
19
|
const DSH_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
|
|
19
|
-
// Tower Codex also retires missions; retired is
|
|
20
|
+
// Tower Codex also retires missions; `retired` is accepted input vocabulary and
|
|
21
|
+
// is remapped below. It is never a DSH mission status.
|
|
20
22
|
const CODEX_STATUSES = [...DSH_STATUSES, 'retired']
|
|
21
23
|
const DSH_ID_RE = /^M\d+$/
|
|
22
24
|
const CODEX_ID_RE = /^T\d{4,}$/
|
|
23
25
|
const SHA256_RE = /^[a-f0-9]{64}$/
|
|
24
26
|
|
|
27
|
+
// The strict adapter contract (#149 W4). One payload is exactly one legacy
|
|
28
|
+
// format; every status and field alias this module accepts is declared here.
|
|
29
|
+
// Missions are normalised to the shape registry's DSH vocabulary: a status
|
|
30
|
+
// remap is applied and reported in `remapped`, never silently invented, and a
|
|
31
|
+
// value the adapter cannot carry is reported in `unresolved`/`dropped`.
|
|
32
|
+
export const LEGACY_FORMATS = Object.freeze({
|
|
33
|
+
'tower-codex': Object.freeze({
|
|
34
|
+
idPattern: CODEX_ID_RE,
|
|
35
|
+
idHint: 'T####',
|
|
36
|
+
statuses: CODEX_STATUSES,
|
|
37
|
+
statusRemap: Object.freeze({ retired: 'abandoned' }),
|
|
38
|
+
baseBranchField: 'baseBranch',
|
|
39
|
+
requiresBaseBranch: true,
|
|
40
|
+
}),
|
|
41
|
+
dsh: Object.freeze({
|
|
42
|
+
idPattern: DSH_ID_RE,
|
|
43
|
+
idHint: 'M#',
|
|
44
|
+
statuses: DSH_STATUSES,
|
|
45
|
+
statusRemap: Object.freeze({}),
|
|
46
|
+
baseBranchField: 'base',
|
|
47
|
+
requiresBaseBranch: false,
|
|
48
|
+
}),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Only these fields cross the adapter boundary. Everything else a legacy
|
|
52
|
+
// mission carries is validated for safety, then reported as dropped: the
|
|
53
|
+
// adapter deliberately does not invent DSH semantics for it.
|
|
54
|
+
const CARRIED_MISSION_FIELDS = Object.freeze(['id', 'status', 'kind'])
|
|
55
|
+
const DROPPED_REASON = 'validated for safety but not carried into the summary; the exact payload bytes remain in the stored snapshot'
|
|
56
|
+
|
|
25
57
|
function fail(code, message) {
|
|
26
58
|
/** @type {Error & {code?: string}} */
|
|
27
59
|
const error = new Error(message)
|
|
@@ -86,6 +118,9 @@ function invalidResult(error, format = null) {
|
|
|
86
118
|
missionIds: [],
|
|
87
119
|
missionCount: 0,
|
|
88
120
|
paused: [],
|
|
121
|
+
unresolved: [],
|
|
122
|
+
dropped: [],
|
|
123
|
+
remapped: [],
|
|
89
124
|
events: null,
|
|
90
125
|
legacySnapshots: null,
|
|
91
126
|
repoRoot: null,
|
|
@@ -140,31 +175,89 @@ function validateReviews(reviews, id) {
|
|
|
140
175
|
return null
|
|
141
176
|
}
|
|
142
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Detect one legacy format per payload. A declared `format` is authoritative;
|
|
180
|
+
* otherwise every mission id must belong to exactly one namespace. Empty
|
|
181
|
+
* mission lists use the top-level shape (a Tower Codex snapshot has a
|
|
182
|
+
* baseBranch; DSH state uses `base`).
|
|
183
|
+
* @param {any} payload
|
|
184
|
+
* @returns {{ format: 'tower-codex'|'dsh'|null, error: string|null }}
|
|
185
|
+
*/
|
|
186
|
+
function detectFormat(payload) {
|
|
187
|
+
if (payload.format !== undefined) {
|
|
188
|
+
if (typeof payload.format !== 'string' || !Object.prototype.hasOwnProperty.call(LEGACY_FORMATS, payload.format)) {
|
|
189
|
+
return { format: null, error: `unsupported legacy format ${JSON.stringify(payload.format)} (expected tower-codex or dsh)` }
|
|
190
|
+
}
|
|
191
|
+
return { format: payload.format, error: null }
|
|
192
|
+
}
|
|
193
|
+
if (payload.missions.length === 0) {
|
|
194
|
+
return { format: isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh', error: null }
|
|
195
|
+
}
|
|
196
|
+
let codex = 0
|
|
197
|
+
let dsh = 0
|
|
198
|
+
for (const mission of payload.missions) {
|
|
199
|
+
const id = mission?.id
|
|
200
|
+
if (typeof id === 'string' && CODEX_ID_RE.test(id)) codex += 1
|
|
201
|
+
else if (typeof id === 'string' && DSH_ID_RE.test(id)) dsh += 1
|
|
202
|
+
}
|
|
203
|
+
if (codex === payload.missions.length) return { format: 'tower-codex', error: null }
|
|
204
|
+
if (dsh === payload.missions.length) return { format: 'dsh', error: null }
|
|
205
|
+
for (const mission of payload.missions) {
|
|
206
|
+
const id = mission?.id
|
|
207
|
+
if (typeof id !== 'string' || (!CODEX_ID_RE.test(id) && !DSH_ID_RE.test(id))) {
|
|
208
|
+
return {
|
|
209
|
+
format: null,
|
|
210
|
+
error: `unsupported legacy mission id ${JSON.stringify(id)} (expected T#### for Tower Codex or M# for DSH)`,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { format: null, error: 'legacy snapshot mixes mission id namespaces (T#### and M#); one snapshot is one format' }
|
|
215
|
+
}
|
|
216
|
+
|
|
143
217
|
/**
|
|
144
218
|
* @param {any} payload
|
|
145
219
|
* @param {'tower-codex'|'dsh'} format
|
|
146
|
-
* @
|
|
220
|
+
* @param {{ idPattern: RegExp, idHint: string, statuses: string[], statusRemap: Record<string, string>, baseBranchField: string, requiresBaseBranch: boolean }} spec
|
|
221
|
+
* @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[],
|
|
222
|
+
* unresolved:Array<{missionId:string, field:string, reason:string}>,
|
|
223
|
+
* dropped:Array<{field:string, reason:string}>,
|
|
224
|
+
* remapped:Array<{missionId:string, field:string, from:string, to:string}>}}
|
|
147
225
|
*/
|
|
148
|
-
function validateMissions(payload, format) {
|
|
149
|
-
const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
|
|
150
|
-
const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
|
|
226
|
+
function validateMissions(payload, format, spec) {
|
|
151
227
|
const missions = []
|
|
152
228
|
const missionIds = []
|
|
153
229
|
const paused = []
|
|
230
|
+
const unresolved = []
|
|
231
|
+
const remapped = []
|
|
232
|
+
const droppedFields = new Set()
|
|
154
233
|
const seen = new Set()
|
|
155
234
|
for (let index = 0; index < payload.missions.length; index += 1) {
|
|
156
235
|
const mission = payload.missions[index]
|
|
157
|
-
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
|
|
158
|
-
if (typeof mission.id !== 'string' || !
|
|
159
|
-
return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${
|
|
236
|
+
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
237
|
+
if (typeof mission.id !== 'string' || !spec.idPattern.test(mission.id)) {
|
|
238
|
+
return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${spec.idHint})`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
160
239
|
}
|
|
161
|
-
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
|
|
240
|
+
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
162
241
|
seen.add(mission.id)
|
|
163
|
-
if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
|
|
164
|
-
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
|
|
242
|
+
if (typeof mission.status !== 'string' || !spec.statuses.includes(mission.status)) {
|
|
243
|
+
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
244
|
+
}
|
|
245
|
+
const mappedStatus = spec.statusRemap[mission.status]
|
|
246
|
+
if (mappedStatus !== undefined) {
|
|
247
|
+
remapped.push({ missionId: mission.id, field: 'status', from: mission.status, to: mappedStatus })
|
|
248
|
+
}
|
|
249
|
+
const status = mappedStatus ?? mission.status
|
|
250
|
+
if (!DSH_STATUSES.includes(status)) {
|
|
251
|
+
return { error: `legacy status ${JSON.stringify(mission.status)} has no declared DSH mapping`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
165
252
|
}
|
|
166
|
-
|
|
167
|
-
|
|
253
|
+
let kind = 'build'
|
|
254
|
+
if (mission.kind !== undefined) {
|
|
255
|
+
if (!isShapeKind(mission.kind)) {
|
|
256
|
+
return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
257
|
+
}
|
|
258
|
+
kind = mission.kind
|
|
259
|
+
} else {
|
|
260
|
+
unresolved.push({ missionId: mission.id, field: 'kind', reason: 'legacy mission has no kind; assumed build' })
|
|
168
261
|
}
|
|
169
262
|
for (const problem of [
|
|
170
263
|
validateScope(mission.scope, mission.id),
|
|
@@ -172,13 +265,24 @@ function validateMissions(payload, format) {
|
|
|
172
265
|
validateDeps(mission.deps, mission.id),
|
|
173
266
|
validateReviews(mission.reviews, mission.id),
|
|
174
267
|
]) {
|
|
175
|
-
if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
|
|
268
|
+
if (problem) return { error: problem, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
176
269
|
}
|
|
177
|
-
|
|
270
|
+
for (const key of Object.keys(mission)) {
|
|
271
|
+
if (!CARRIED_MISSION_FIELDS.includes(key)) droppedFields.add(key)
|
|
272
|
+
}
|
|
273
|
+
missions.push({ id: mission.id, status, kind })
|
|
178
274
|
missionIds.push(mission.id)
|
|
179
|
-
if (
|
|
275
|
+
if (status === 'paused') paused.push(mission.id)
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
missions,
|
|
279
|
+
missionIds,
|
|
280
|
+
paused,
|
|
281
|
+
unresolved,
|
|
282
|
+
dropped: [...droppedFields].sort().map((field) => ({ field, reason: DROPPED_REASON })),
|
|
283
|
+
remapped,
|
|
284
|
+
error: null,
|
|
180
285
|
}
|
|
181
|
-
return { missions, missionIds, paused, error: null }
|
|
182
286
|
}
|
|
183
287
|
|
|
184
288
|
/**
|
|
@@ -189,9 +293,12 @@ function validateMissions(payload, format) {
|
|
|
189
293
|
*
|
|
190
294
|
* @returns {{ok:boolean, format:'tower-codex'|'dsh'|null, schemaVersion:number|null,
|
|
191
295
|
* missions:Array<{id:string,status:string,kind:string}>, missionIds:string[],
|
|
192
|
-
* missionCount:number, paused:string[],
|
|
193
|
-
*
|
|
194
|
-
*
|
|
296
|
+
* missionCount:number, paused:string[],
|
|
297
|
+
* unresolved:Array<{missionId:string, field:string, reason:string}>,
|
|
298
|
+
* dropped:Array<{field:string, reason:string}>,
|
|
299
|
+
* remapped:Array<{missionId:string, field:string, from:string, to:string}>,
|
|
300
|
+
* events:number|null, legacySnapshots:number|null, repoRoot:string|null,
|
|
301
|
+
* baseBranch:string|null, error:string|null}}
|
|
195
302
|
*/
|
|
196
303
|
export function validateLegacySnapshot(payload) {
|
|
197
304
|
if (!isPlainObject(payload)) return invalidResult('legacy snapshot must be a JSON object')
|
|
@@ -204,30 +311,26 @@ export function validateLegacySnapshot(payload) {
|
|
|
204
311
|
}
|
|
205
312
|
if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
|
|
206
313
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
|
|
211
|
-
} else {
|
|
212
|
-
const firstId = payload.missions[0]?.id
|
|
213
|
-
if (typeof firstId === 'string' && CODEX_ID_RE.test(firstId)) format = 'tower-codex'
|
|
214
|
-
else if (typeof firstId === 'string' && DSH_ID_RE.test(firstId)) format = 'dsh'
|
|
215
|
-
else {
|
|
216
|
-
return invalidResult(
|
|
217
|
-
`unsupported legacy mission id ${JSON.stringify(firstId)} (expected T#### for Tower Codex or M# for DSH)`,
|
|
218
|
-
)
|
|
219
|
-
}
|
|
314
|
+
const detected = detectFormat(payload)
|
|
315
|
+
if (detected.error || !detected.format) {
|
|
316
|
+
return invalidResult(detected.error ?? 'legacy snapshot format could not be determined')
|
|
220
317
|
}
|
|
318
|
+
const format = detected.format
|
|
319
|
+
const spec = LEGACY_FORMATS[format]
|
|
221
320
|
|
|
222
321
|
const rejected = (error) => invalidResult(error, format)
|
|
223
|
-
const validated = validateMissions(payload, format)
|
|
322
|
+
const validated = validateMissions(payload, format, spec)
|
|
224
323
|
if (validated.error) return rejected(validated.error)
|
|
225
324
|
|
|
226
325
|
if (isNonEmptyString(payload.repoRoot) === false) {
|
|
227
326
|
return rejected(`legacy ${format} snapshot has no repoRoot string`)
|
|
228
327
|
}
|
|
229
|
-
if (
|
|
230
|
-
return rejected(
|
|
328
|
+
if (spec.requiresBaseBranch && isNonEmptyString(payload[spec.baseBranchField]) === false) {
|
|
329
|
+
return rejected(`legacy ${format} snapshot has no baseBranch string`)
|
|
330
|
+
}
|
|
331
|
+
const baseBranchValue = payload[spec.baseBranchField]
|
|
332
|
+
if (baseBranchValue !== undefined && baseBranchValue !== null && !isNonEmptyString(baseBranchValue)) {
|
|
333
|
+
return rejected(`legacy ${format} snapshot has no baseBranch string`)
|
|
231
334
|
}
|
|
232
335
|
if (payload.events !== undefined && !Array.isArray(payload.events)) {
|
|
233
336
|
return rejected('legacy snapshot events must be an array')
|
|
@@ -247,10 +350,13 @@ export function validateLegacySnapshot(payload) {
|
|
|
247
350
|
missionIds: validated.missionIds,
|
|
248
351
|
missionCount: validated.missionIds.length,
|
|
249
352
|
paused: validated.paused,
|
|
353
|
+
unresolved: validated.unresolved,
|
|
354
|
+
dropped: validated.dropped,
|
|
355
|
+
remapped: validated.remapped,
|
|
250
356
|
events: Array.isArray(payload.events) ? payload.events.length : null,
|
|
251
357
|
legacySnapshots: Array.isArray(payload.legacySnapshots) ? payload.legacySnapshots.length : null,
|
|
252
358
|
repoRoot: payload.repoRoot.trim(),
|
|
253
|
-
baseBranch:
|
|
359
|
+
baseBranch: isNonEmptyString(baseBranchValue) ? baseBranchValue.trim() : null,
|
|
254
360
|
error: null,
|
|
255
361
|
}
|
|
256
362
|
}
|
|
@@ -342,6 +448,9 @@ export async function importLegacySnapshot(input = {}) {
|
|
|
342
448
|
missionIds: validation.missionIds,
|
|
343
449
|
paused: validation.paused,
|
|
344
450
|
pausedCount: validation.paused.length,
|
|
451
|
+
unresolved: validation.unresolved,
|
|
452
|
+
dropped: validation.dropped,
|
|
453
|
+
remapped: validation.remapped,
|
|
345
454
|
events: validation.events,
|
|
346
455
|
legacySnapshots: validation.legacySnapshots,
|
|
347
456
|
sourcePath: resolvedSource,
|
|
@@ -362,6 +471,9 @@ export async function importLegacySnapshot(input = {}) {
|
|
|
362
471
|
missionIds: validation.missionIds,
|
|
363
472
|
missionCount: validation.missionCount,
|
|
364
473
|
pausedMissionIds: validation.paused,
|
|
474
|
+
unresolved: validation.unresolved,
|
|
475
|
+
droppedFields: validation.dropped.map((entry) => entry.field),
|
|
476
|
+
remapped: validation.remapped,
|
|
365
477
|
bytes: buffer.length,
|
|
366
478
|
importedAt,
|
|
367
479
|
}
|
package/adapters/stateFile.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// `state.migrated {from,to}` to the activity log.
|
|
16
16
|
// A migration that throws therefore leaves state.json byte-identical.
|
|
17
17
|
import { inspectStateVersion, prepareState } from '../domain/state.js'
|
|
18
|
+
import { legacySurveyNeedsRead } from '../domain/state/migrations.js'
|
|
18
19
|
import { isStateLockHeld } from './store.js'
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -37,6 +38,7 @@ import { isStateLockHeld } from './store.js'
|
|
|
37
38
|
* now?: number,
|
|
38
39
|
* persist?: boolean,
|
|
39
40
|
* migrations?: readonly import('../domain/state/migrations.js').StateMigration[],
|
|
41
|
+
* resolveLegacySurvey?: (rawState: Record<string, any>) => Promise<import('../domain/state/migrations.js').LegacySurveyRead | null>,
|
|
40
42
|
* }} input
|
|
41
43
|
* @returns {Promise<StateLoadResult>}
|
|
42
44
|
*/
|
|
@@ -54,7 +56,15 @@ export async function loadStateFile(input) {
|
|
|
54
56
|
await store.writeJsonAtomic(backupPath, raw)
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
// A legacy survey planned but never provisioned recorded no base at all, so
|
|
60
|
+
// the document alone cannot anchor it. Resolve the repository base exactly
|
|
61
|
+
// once, in the same moment as the migration, and hand it to the pure step.
|
|
62
|
+
const legacySurveyRead =
|
|
63
|
+
version.status === 'migratable' && legacySurveyNeedsRead(raw) && typeof input.resolveLegacySurvey === 'function'
|
|
64
|
+
? await input.resolveLegacySurvey(raw)
|
|
65
|
+
: null
|
|
66
|
+
|
|
67
|
+
const prepared = prepareState(raw, paths.state, { migrations, legacySurveyRead })
|
|
58
68
|
if (!prepared.changed) {
|
|
59
69
|
return { state: prepared.state, version, migration: null }
|
|
60
70
|
}
|
package/app/archive.js
CHANGED
|
@@ -144,10 +144,34 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
144
144
|
force: options.force === true,
|
|
145
145
|
protectedIds,
|
|
146
146
|
})
|
|
147
|
-
|
|
147
|
+
// #148 W2: never archive a mission whose disposable sandbox is still on disk.
|
|
148
|
+
// Archiving would move the only record of the path out of hot state while the
|
|
149
|
+
// checkout leaks; the mission stays hot (protected) until cleanup succeeds on
|
|
150
|
+
// a later mutate or tower_teardown, and this sweep names the caused path.
|
|
151
|
+
const sandboxPending = selected
|
|
152
|
+
.filter((mission) => typeof /** @type {any} */ (mission).sandbox === 'string' && /** @type {any} */ (mission).sandbox.length > 0)
|
|
153
|
+
.map((mission) => ({ missionId: mission.id, sandbox: /** @type {any} */ (mission).sandbox }))
|
|
154
|
+
const pendingIds = new Set(sandboxPending.map((entry) => entry.missionId))
|
|
155
|
+
const archivable = selected.filter((mission) => !pendingIds.has(mission.id))
|
|
156
|
+
for (const entry of sandboxPending) {
|
|
157
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
158
|
+
at: now,
|
|
159
|
+
type: 'archive',
|
|
160
|
+
missionId: entry.missionId,
|
|
161
|
+
text: `deferred: detached sandbox at ${entry.sandbox} is still present; cleanup retries on tower_teardown`,
|
|
162
|
+
}).catch(() => {})
|
|
163
|
+
}
|
|
164
|
+
if (archivable.length === 0) {
|
|
165
|
+
return {
|
|
166
|
+
archived: [],
|
|
167
|
+
written: false,
|
|
168
|
+
error: null,
|
|
169
|
+
...(sandboxPending.length > 0 ? { sandboxPending } : {}),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
148
172
|
|
|
149
173
|
const archivedYear = archiveYearFor(now)
|
|
150
|
-
const entries =
|
|
174
|
+
const entries = archivable.map((mission) =>
|
|
151
175
|
archivedEntry(mission, {
|
|
152
176
|
archivedAt: now,
|
|
153
177
|
archivedYear,
|
|
@@ -165,9 +189,9 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
165
189
|
if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
|
|
166
190
|
|
|
167
191
|
// The archive now holds the full record: compact state.json in the same lock.
|
|
168
|
-
const ids = new Set(
|
|
192
|
+
const ids = new Set(archivable.map((mission) => mission.id))
|
|
169
193
|
const branches = new Set(
|
|
170
|
-
|
|
194
|
+
archivable
|
|
171
195
|
.map((mission) => mission.worktree?.branch)
|
|
172
196
|
.filter((branch) => typeof branch === 'string'),
|
|
173
197
|
)
|
|
@@ -184,7 +208,7 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
184
208
|
state.revision += 1
|
|
185
209
|
state.updatedAt = now
|
|
186
210
|
|
|
187
|
-
for (const mission of
|
|
211
|
+
for (const mission of archivable) {
|
|
188
212
|
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
189
213
|
at: now,
|
|
190
214
|
type: 'archive',
|
|
@@ -199,5 +223,6 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
199
223
|
files: written.files,
|
|
200
224
|
error: null,
|
|
201
225
|
year: archivedYear,
|
|
226
|
+
...(sandboxPending.length > 0 ? { sandboxPending } : {}),
|
|
202
227
|
}
|
|
203
228
|
}
|