pi-legion 5.0.1 → 5.2.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 +858 -0
- package/CONTRIBUTING.md +33 -2
- package/README.md +10 -5
- package/agents/centurion.md +32 -7
- package/agents/scout.md +11 -0
- package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
- package/docs/contracts/centurion-declared-phases-v1.md +104 -0
- package/docs/contracts/declarative-phase-machine-v5.md +43 -0
- package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
- package/docs/contracts/finished-cache-eviction-v1.md +169 -0
- package/docs/contracts/galaxy-scratch-v1.md +78 -0
- package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
- package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
- package/docs/contracts/legion-page-floor-v1.md +129 -0
- package/docs/contracts/legion-session-continuity-v1.md +118 -0
- package/docs/contracts/mailbox-delivery-v1.md +162 -0
- package/docs/contracts/notice-claim-v1.md +206 -0
- package/docs/contracts/partial-failure-exit-v1.md +235 -0
- package/docs/contracts/proc-sweep-v1.md +107 -0
- package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
- package/docs/contracts/stall-visibility-v1.md +218 -0
- package/docs/contracts/subagent-renewal-v1.md +5 -3
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
- package/docs/contracts/viz-frame-alloc-v1.md +123 -0
- package/docs/contracts/wait-mail-preempt-v1.md +117 -0
- package/docs/contracts/wait-not-stop.md +1 -1
- package/docs/design.md +4 -1
- package/docs/inprocess-supervisor.md +9 -3
- package/docs/legion-v2-contract.md +122 -20
- package/docs/vexillatio-contract.md +5 -1
- package/index.ts +1903 -130
- package/package.json +5 -5
- package/skills/pi-legion/SKILL.md +2 -1
- package/src/agents/agents.ts +26 -1
- package/src/agents/settings.ts +67 -2
- package/src/api.ts +17 -0
- package/src/extension/child-guard.ts +2 -1
- package/src/extension/join.ts +54 -0
- package/src/extension/legion-commands.ts +15 -4
- package/src/extension/legion-doctor.ts +48 -6
- package/src/extension/legion-status.ts +44 -2
- package/src/extension/legion-tools.ts +1044 -109
- package/src/extension/notify.ts +31 -2
- package/src/extension/playbook.ts +6 -3
- package/src/extension/runtime.ts +882 -81
- package/src/extension/spawn-hints.ts +52 -12
- package/src/extension/summary.ts +7 -15
- package/src/extension/timing.ts +4 -15
- package/src/legion/db.ts +23 -1
- package/src/legion/events.ts +7 -0
- package/src/legion/interrupt.ts +98 -0
- package/src/legion/mail-release.ts +266 -0
- package/src/legion/mail.ts +456 -29
- package/src/legion/nodes.ts +31 -0
- package/src/legion/phase-conditions.ts +7 -1
- package/src/legion/phase-machine.ts +160 -8
- package/src/legion/state-machine.ts +34 -2
- package/src/runs/acceptance.ts +105 -6
- package/src/runs/args.ts +16 -0
- package/src/runs/child-scope.ts +323 -0
- package/src/runs/extension-inheritance.ts +145 -19
- package/src/runs/orchestrator.ts +613 -55
- package/src/runs/store.ts +149 -5
- package/src/shared/proc-sweep.ts +317 -0
- package/src/shared/progress.ts +11 -2
- package/src/shared/session-map.ts +29 -0
- package/src/shared/session.ts +308 -6
- package/src/shared/types.ts +34 -0
- package/src/supervisor/inprocess-supervisor.ts +158 -12
- package/src/supervisor/race-timeout.ts +55 -0
- package/src/supervisor/routing-supervisor.ts +55 -4
- package/src/supervisor/rpc-supervisor.ts +215 -29
- package/src/supervisor/types.ts +439 -2
- package/src/tui/status.ts +2 -1
- package/src/tui/viz/canvas.ts +358 -36
- package/src/tui/viz/palette.ts +18 -2
- package/src/tui/viz/panels.ts +507 -104
- package/src/tui/viz/rate.ts +120 -15
- package/src/tui/viz/view.ts +72 -4
package/CONTRIBUTING.md
CHANGED
|
@@ -10,10 +10,33 @@ two as the only things a downstream should need.
|
|
|
10
10
|
issue. Include a repro, the expected behaviour, the actual behaviour, and the
|
|
11
11
|
environment (Node version, OS, and the Pi / pi-legion version).
|
|
12
12
|
|
|
13
|
-
**One concern per pull request.** Link the issue
|
|
13
|
+
**One concern per pull request.** Link the issue (none needed for a
|
|
14
|
+
`release:` / `chore:` / docs-only PR — say so in the PR body). Open the PR as
|
|
15
|
+
a draft.
|
|
14
16
|
Mark it **Ready for review** when the change is ready and you expect the
|
|
15
17
|
required check `ci` to pass.
|
|
16
18
|
|
|
19
|
+
**Link every issue you touch, not just the parent.** GitHub only auto-closes an
|
|
20
|
+
issue on a bare `Closes #<n>` line (`Fixes` / `Resolves` also work) in a PR
|
|
21
|
+
body or a commit message. A `fix(#<n>):` subject **names** the issue and closes
|
|
22
|
+
nothing. A fix that says `fix(#280)` while the report actually lives in `#306`
|
|
23
|
+
leaves `#306` open forever, and the next reader cannot tell whether the work
|
|
24
|
+
never landed or was merely forgotten — that is exactly what happened to `#306`
|
|
25
|
+
(fixed in `19ea0cf` … `ac26836`, shipped in 5.0.1, still open for nearly four
|
|
26
|
+
days, hand-closed by the maintainer). So:
|
|
27
|
+
|
|
28
|
+
- `Closes #<child>` for **the issue being fixed** — the one the reporter filed.
|
|
29
|
+
- `Refs #<parent>` (or `Refs #<sibling>`) for the context issue.
|
|
30
|
+
- `fix(#<child>): …` in a commit subject is a **naming convention only**; it
|
|
31
|
+
does not close anything. The actual close is a separate bare `Closes #<child>`
|
|
32
|
+
line, in the PR body or in the commit body — not packed into the subject's
|
|
33
|
+
parentheses.
|
|
34
|
+
- No `Closes` for a given issue? Then closing it stays a manual step after
|
|
35
|
+
merge — do it in the same pass, not "later".
|
|
36
|
+
|
|
37
|
+
**Before you ask for merge.** Re-read the issue you were handed and check the
|
|
38
|
+
PR closes *that* one, not an issue one level up.
|
|
39
|
+
|
|
17
40
|
**Merge gate.** Alice (the maintainer bot) merges only after code review has
|
|
18
41
|
passed and the required GitHub Actions check `ci` is green. Do not push
|
|
19
42
|
directly to `master`. Implementers do not merge their own pull requests.
|
|
@@ -53,6 +76,10 @@ A maintainer promotes the staged version on npm with 2FA. Do not use plain
|
|
|
53
76
|
**Communication.** Coordinate in the team channel, or with Alice. Do not ping
|
|
54
77
|
the maintainer user for a routine review or merge ask.
|
|
55
78
|
|
|
79
|
+
**Closing issues is part of the work.** Merge is not done until every issue
|
|
80
|
+
the change resolved says `CLOSED`, and every issue it only *partly* resolved
|
|
81
|
+
has the remainder split into a new issue (see `#317`, split out of `#306`).
|
|
82
|
+
|
|
56
83
|
## Setup
|
|
57
84
|
|
|
58
85
|
```bash
|
|
@@ -161,7 +188,11 @@ Or all at once (RPC mainline **1–11** only):
|
|
|
161
188
|
npm run test:docker
|
|
162
189
|
```
|
|
163
190
|
|
|
164
|
-
Override paths with `HOST_PI_AGENT`, `DOCKER_PI_HOME`, `PI_DOCKER_IMAGE`.
|
|
191
|
+
Override paths with `HOST_PI_AGENT`, `DOCKER_PI_HOME`, `PI_DOCKER_IMAGE`. Every
|
|
192
|
+
`verify-*.sh` honors `PI_DOCKER_IMAGE` (all eleven, not just 1 and 7, since #367's
|
|
193
|
+
follow-up), so a locally built `tui-sandbox:latest` works without retagging; the
|
|
194
|
+
historical default is only a fallback. `make rpc` in `test/docker` runs the eleven
|
|
195
|
+
against `$(IMAGE)`.
|
|
165
196
|
|
|
166
197
|
Each `inside-N-*.sh` is what actually runs **in** the container. Keep those
|
|
167
198
|
scripts self-contained: they should still make sense if you `docker exec`
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pi-legion
|
|
3
3
|
npm: "pi-legion"
|
|
4
|
-
description: "Pi Legion v2: recursive centurion teams on Pi. Children run in-process by default (rpc only for a worktree-owning centurion); shared SQLite WAL legion.db. Declarative per-team phase machine (built-in default: planning/implementing/reviewing/verifying), parent/child/sibling legion_mail, three-layer /legion UI, upward
|
|
4
|
+
description: "Pi Legion v2: recursive centurion teams on Pi. Children run in-process by default (rpc only for a worktree-owning centurion); shared SQLite WAL legion.db. Declarative per-team phase machine (built-in default: planning/implementing/reviewing/verifying), parent/child/sibling legion_mail, three-layer /legion UI, upward branch deliveries (MRs when a remote exists), squad resume."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# pi-legion
|
|
@@ -160,7 +160,7 @@ acceptance checklist in its task:
|
|
|
160
160
|
```text
|
|
161
161
|
subagent({
|
|
162
162
|
agent: "centurion",
|
|
163
|
-
task: "Goal: implement the account export flow. Acceptance: contract is recorded; implementation is reviewed; the verification commands pass; return
|
|
163
|
+
task: "Goal: implement the account export flow. Acceptance: contract is recorded; implementation is reviewed; the verification commands pass; return the squashed branch (name + sha) and verdict."
|
|
164
164
|
})
|
|
165
165
|
```
|
|
166
166
|
|
|
@@ -252,7 +252,7 @@ Child nodes receive these tree-aware tools:
|
|
|
252
252
|
```text
|
|
253
253
|
legion_mail({ to, subject, body, kind?, urgency? })
|
|
254
254
|
legion_tree({ scope?: "children" | "squad" | "subtree" | "tree", depth? })
|
|
255
|
-
legion_phase({ to, note?, evidence? })
|
|
255
|
+
legion_phase({ to, note?, evidence?, reason?, supersedes? })
|
|
256
256
|
```
|
|
257
257
|
|
|
258
258
|
- **`legion_mail`** sends asynchronous messages over permitted tree edges:
|
|
@@ -265,6 +265,10 @@ legion_phase({ to, note?, evidence? })
|
|
|
265
265
|
not interrupt the nodes being observed.
|
|
266
266
|
- **`legion_phase`** is available to centurions. It requests a state transition;
|
|
267
267
|
the supervisor checks the transition's invariants and records the result.
|
|
268
|
+
`reason` is the declared-failure verdict: with `to: "failed"` it is required and
|
|
269
|
+
the team's failure is recorded as a parent decision. `supersedes` lists failed
|
|
270
|
+
direct children that a settled sibling has taken over, which is what unlocks
|
|
271
|
+
the exit from `implementing` after a partial failure.
|
|
268
272
|
|
|
269
273
|
A squad moves through whatever phases its lead declares. With no declaration
|
|
270
274
|
that is the built-in **planning → implementing → reviewing → verifying**:
|
|
@@ -553,8 +557,9 @@ A centurion owns a Git worktree for its subtree; its worker/scout
|
|
|
553
557
|
children work in that subtree (`worktree: false`, typically in-process), and a
|
|
554
558
|
nested centurion gets a nested worktree. **Parallel isolated writers:** dispatch
|
|
555
559
|
N centurions from the root (each owns one worktree); do not give every worker
|
|
556
|
-
its own worktree. Each subtree is delivered as a
|
|
557
|
-
|
|
560
|
+
its own worktree. Each subtree is delivered as a squashed branch to its parent level
|
|
561
|
+
(an MR/PR is added when the repository has a remote); deliveries move upward through
|
|
562
|
+
the tree until the root reviews and merges into the
|
|
558
563
|
default branch.
|
|
559
564
|
|
|
560
565
|
Resuming follows the tree's saved sessions and node records. If a supervisor
|
package/agents/centurion.md
CHANGED
|
@@ -7,21 +7,46 @@ maxSubagentDepth: 3
|
|
|
7
7
|
timeoutMs: 1800000
|
|
8
8
|
worktree: true
|
|
9
9
|
# team: 可选默认队(须真实存在于 settings.teams);省略 = 全 roster(REV-15)
|
|
10
|
+
# #371: `level: verified` is gone. Nothing in the repo reads `acceptance.level` —
|
|
11
|
+
# the level a caller sees is derived at collect time from `verify:` commands — so
|
|
12
|
+
# the old line only promised a machine check this card could never receive. It
|
|
13
|
+
# cannot simply be DELETED, though: `parseAcceptance` uses `level` as the gate
|
|
14
|
+
# that decides the block exists at all, and dropping it would take `role` and
|
|
15
|
+
# `criteria` with it. `attested` is what this card's criteria can actually earn.
|
|
16
|
+
# `legion-team-delivery` is `optional` for the same reason: its evidence set has
|
|
17
|
+
# no `verification-output` and no `command`, so a `required` marker promised a
|
|
18
|
+
# check that could never run.
|
|
10
19
|
acceptance:
|
|
11
|
-
level:
|
|
20
|
+
level: attested
|
|
12
21
|
role: writer
|
|
13
22
|
criteria:
|
|
14
23
|
- id: legion-team-delivery
|
|
15
|
-
must:
|
|
16
|
-
evidence: [legion_tree, verification-log,
|
|
17
|
-
severity:
|
|
24
|
+
must: 完成子树阶段验证、收齐成员结果,并交付可审查的 squash 分支(分支名 + HEAD sha;仓库有 remote 时再附 MR/PR URL)
|
|
25
|
+
evidence: [legion_tree, verification-log, delivered-branch]
|
|
26
|
+
severity: optional
|
|
18
27
|
---
|
|
19
28
|
|
|
20
|
-
你是 centurion,一支子树团队的负责人。只负责自己的队伍;不得越级指挥、收割或绕过其他 centurion
|
|
29
|
+
你是 centurion,一支子树团队的负责人。只负责自己的队伍;不得越级指挥、收割或绕过其他 centurion 管理其组员。**root 声明了机器时**,你必须通过 `legion_phase` 请求阶段迁移;**没有声明就没有"阶段"这回事**(工具也不在),干完自己的活直接交 verdict。两种情况都要通过 `legion_mail` 沟通、通过 `legion_tree` 观察状态,不得把口头声明当作机器事实。
|
|
30
|
+
|
|
31
|
+
## 状态机由 root 在派发时声明(#371)
|
|
32
|
+
|
|
33
|
+
**状态机不是你这个角色自带的,是 root 在那次派发里显式给的东西。** root 用
|
|
34
|
+
`subagent({ agent: "centurion", phases: [...] })` 声明了机器,你才是一支有阶段的团队;它没声明,
|
|
35
|
+
你就**不是组长,只是被派来干活的一个 agent**——那意味着:`nodes.phase` 是空的、你的工具里**没有**
|
|
36
|
+
`legion_phase`、也没有人给你注入"收队/squash"提示。这时候的正确行为很简单:把活干完,写出结论,
|
|
37
|
+
交一个 verdict(`{"ok": true, "reason": "..."}`)就结束,不要去找"下一步该迁到哪个阶段",也不要
|
|
38
|
+
因为没收到 squash 提示而当作失败。
|
|
39
|
+
|
|
40
|
+
声明了机器就必须**走完**:阶段是承诺,不是可选装饰。被 `legion_phase` 拒绝时按拒绝理由核实机器事实
|
|
41
|
+
再重试,不得伪造证据、不得绕过状态机、不得把口头声明当作已迁移。状态机的形状(有哪些阶段、哪些
|
|
42
|
+
出边需要什么条件、`done` 是否由你自己收)以 root 声明的那份为准,不要假定阶段名——被拒时拒绝信息
|
|
43
|
+
会列出当前合法的下一步。
|
|
21
44
|
|
|
22
45
|
## 阶段职责
|
|
23
46
|
|
|
24
|
-
|
|
47
|
+
下面按内置四阶段(planning → implementing → reviewing → verifying → done)说明分层职责。阶段名可以
|
|
48
|
+
被 root 的声明改掉(例如收尾阶段叫 `ship`),但**分层不变**:看清声明里每个阶段挂的条件,再把对应
|
|
49
|
+
职责放进去。
|
|
25
50
|
|
|
26
51
|
不论阶段怎么命名,职责是这样分层的:
|
|
27
52
|
|
|
@@ -47,7 +72,7 @@ acceptance:
|
|
|
47
72
|
### 验证与收队(内置 `verifying` / `done`)
|
|
48
73
|
|
|
49
74
|
- 验证由 supervisor 自动运行契约中的 `legion-verify` 命令。全绿后状态机自动进入 done;不要自行宣称验证成功,也不要手动改数据库。
|
|
50
|
-
- done 后汇总全部子节点 verdict、验证结果和未决问题;把子树工作 squash
|
|
75
|
+
- done 后汇总全部子节点 verdict、验证结果和未决问题;把子树工作 squash 到自己的分支上,把**分支名 + HEAD sha** 写进最终 verdict。仓库有 remote 且 `gh`/`glab` 可用时,再开 MR/PR 并附链接;**没有 remote 就写一行「no remote: branch <name> at <sha>」,不要编造 URL,缺 MR 不算失败**。绝不在本级自行合并。
|
|
51
76
|
- 收队时保证组员结果已落盘;由父级 collect 本节点结果后 retire。worktree/session 是 resume 凭据,未获根级清理授权前不得删除。
|
|
52
77
|
|
|
53
78
|
## 止损与安全
|
package/agents/scout.md
CHANGED
|
@@ -8,6 +8,17 @@ inheritProjectContext: true
|
|
|
8
8
|
inheritSkills: false
|
|
9
9
|
async: true
|
|
10
10
|
timeoutMs: 600000
|
|
11
|
+
# A read-only recon role has no legitimate long command: its tools are read /
|
|
12
|
+
# grep / find / ls / bash over sources. Without this the SDK bash tool has no
|
|
13
|
+
# default timeout at all, so one hung probe (`wait-output`, a socket, a repo-wide
|
|
14
|
+
# rg on a network mount) pins the child indefinitely and reports nothing (#315).
|
|
15
|
+
# What this buys is BOUNDED failure, not a result: 600000 is exactly the turn
|
|
16
|
+
# budget above, so there is no headroom — a command that needs longer still will
|
|
17
|
+
# not finish this turn, it just stops pinning the child forever (the OS kills it
|
|
18
|
+
# and the child can still report what it already read). Deliberately scout-only —
|
|
19
|
+
# `worker` and `centurion` must run full builds, test suites and
|
|
20
|
+
# integration gates, where a false kill costs more than the hang it prevents.
|
|
21
|
+
toolTimeoutMs: 600000
|
|
11
22
|
# A timed-out scout means the question was too broad, not that work needs
|
|
12
23
|
# finishing — renewing it would just re-run an over-scoped search (#270 D5).
|
|
13
24
|
renewable: false
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# abort(软中断):`subagent({ action: "abort" })`(v1,冻结)
|
|
2
|
+
|
|
3
|
+
> 语义由 #314 冻结。实现期发现矛盾 → 停下报告,不得重新解释本契约。
|
|
4
|
+
> **本文件是 #314 唯一的 D 编号命名空间**:实现代码与测试注释里的 `#314 Dn` 只解析到这里的
|
|
5
|
+
> D1–D8。`#314` 之外的 `Dn`(例如 `docs/legion-v2-contract.md` 的 D11 = 可视化三层、
|
|
6
|
+
> D12 = SQLite 账本)与本契约无关;本契约没有 D9 及以后的条款。
|
|
7
|
+
|
|
8
|
+
## 背景
|
|
9
|
+
|
|
10
|
+
"让这个 child 别跑了"原本只有两条路,都不对味:
|
|
11
|
+
|
|
12
|
+
- **`stop`**:`SIGTERM` → `SIGKILL` + 后代清扫,**进程没了,上下文也一起没了**。
|
|
13
|
+
- **`steer`**:只是排队(#313),文本在 child 本轮工具批结束后的 turn 边界才被消费——一个卡在长命令里的 child **永远收不到**。
|
|
14
|
+
|
|
15
|
+
supervisor 一侧其实早就实现了 `abort`(`LegionSupervisor.abort` / `RpcSupervisor` / `InProcessSupervisor` / `Orchestrator.abort`),`docs/legion-v2-contract.md` §8.1 的命令通道也早把 `abort` 与 `prompt` / `steer` 并列,但这条路**零调用点**、工具面没有对应 action。本契约把这条缝接到工具面,并把结果冻结成三态回执。
|
|
16
|
+
|
|
17
|
+
## 冻结决定
|
|
18
|
+
|
|
19
|
+
### D1 — 三态,没有第四种
|
|
20
|
+
|
|
21
|
+
| `status` | 含义 | 副作用 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `interrupted` | 有一轮在跑,请求**已经送达** | 当前 turn 被打断;**可能有部分编辑残留**(已落盘的东西不会被回滚);child 仍活着、可 `continue` / `resume` |
|
|
24
|
+
| `no-turn` | 原先没有任何一轮在跑,也就什么都没做 | **队列不清**(`clearQueue` 一次都不调);`cleared` 恒为 `{0,0}` |
|
|
25
|
+
| `timeout` | 请求发出去了,但 child 没有在时限内 settle | 不杀、不 retire、不改 `turnActive` / `state` / `blocked` |
|
|
26
|
+
|
|
27
|
+
三者都是诚实收场,工具面一律返回 `ok(...)`。**`fail` 只留给抛错路径**:child 不存在 / 已 retired / 不可用时 `requireChild` / `assertCanSend` 抛出,工具面翻成 `abort failed: …` + `NOT_FOUND`。注意 `assertCanSend` 跑在 `turnActive` 判定**之前**,所以 retired 的 child 照旧抛错,不会被读成 `no-turn`——两者不能互相顶替(查不到人 ≠ 人闲着)。
|
|
28
|
+
|
|
29
|
+
回执文本必须让父 agent 一眼看出是哪一态,`timeout` 文案必须指向 `stop`(逐字文本见 D7)。
|
|
30
|
+
|
|
31
|
+
### D2 — 默认值
|
|
32
|
+
|
|
33
|
+
| 项 | 默认 | 说明 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `clearQueued` | **`true`** | abort 前先清 child 的 steering / follow-up 队列;工具面只暴露这一个开关(`clearQueued: false` 保留队列) |
|
|
36
|
+
| abort 上限 | **`DEFAULT_ABORT_TIMEOUT_MS = 5000`**(`src/supervisor/types.ts`) | **两个后端**共用的常量,不是各写一份字面量(工具面回执不再读它,见下一格);后端把非有限 / ≤0 的 `timeoutMs` 归一成它(否则一个坏值会把两步都压成 ~0 ms,让每次 abort 都变成假 `timeout`),合法的值再以 `MAX_ABORT_TIMER_DELAY_MS = 2_147_483_647` 封顶(Node 把超出 32 位有符号上限的延时限**钳成 1 ms**,不封顶等于让一个 `1e12` 这样的值凭空造出假 `timeout`) |
|
|
37
|
+
| 工具面 `timeoutMs` | **只对 `wait` 生效** | `abort` 不接受工具面传入的上限,一律用上面那个常量;`timeoutMs` 参数的说明也只看 `wait`。**后端采用的上限随 `AbortOutcome.timeoutMs` 交回**(归一 + 封顶之后的值,三态都带;`no-turn` 交回的是它**本该采用**的那个值),回执读它,不自己再取一次默认值(#325) |
|
|
38
|
+
| `cleared: { steering, followUp }` | 清队列那次调用的**返回值**计数 | 结构缺失 / 不可读(含抛错)→ 计 **0**,不猜 |
|
|
39
|
+
|
|
40
|
+
计数由 `countClearedQueue`(`src/supervisor/types.ts`)统一做,两个后端不会对同一份返回值给出两个数字。`cleared` 是"真的清掉了多少"的机器事实,不是估计值;`no-turn` 下两个计数恒为 0。
|
|
41
|
+
|
|
42
|
+
### D3 — 顺序:先清队列,再 abort
|
|
43
|
+
|
|
44
|
+
**清队列必须先于 abort 发出。** 理由:如果 abort 先落地,child 立刻就会开始执行队列里最老的那条指令——这正是"让它停下"的反面。先清后 abort,这次中断才算名副其实。
|
|
45
|
+
|
|
46
|
+
计数取自清队列调用的返回值,所以"先清"同时给出真实的 `cleared`;反过来顺序则无从计数。
|
|
47
|
+
|
|
48
|
+
两个后端都是 `requireChild` → `assertCanSend` → `turnActive` 判定**通过之后**才进清队列,所以"清 + 中断"这对调用是这条顺序的可观测证据:用例断言的是有序日志 `["clearQueue", "abort"]`,不是两个独立事实。
|
|
49
|
+
|
|
50
|
+
### D4 — 有界超时(**包含清队列**)
|
|
51
|
+
|
|
52
|
+
两个后端都**不可能等到"abort 完成"就一定返回**:
|
|
53
|
+
|
|
54
|
+
- `AgentSession.abort()` 的收尾是 `await waitForIdle()`,而一个**忽略取消**的工具调用永远不会 idle;
|
|
55
|
+
- RPC 一侧本身有自己的响应超时。
|
|
56
|
+
|
|
57
|
+
所以两个后端都把"干活"和计时器赛跑(`Promise.race`),到点返回 `timeout`,而不是挂死或抛错。
|
|
58
|
+
|
|
59
|
+
**清队列这一步同样受这个上限约束。** 走到 abort 这条路的前提就是 child 不响应;一个无界的前置步骤会让"打断"恰好在这个功能唯一的使用场景里迟到。RPC 后端尤其明显:清队列走 `RpcClient.send()`,唯一的界是客户端**硬编码的 30 s 请求超时**(`node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-client.js:465-466`),不 race 的话,回执与 CHANGELOG 里那句"5 s 上限"就是假话。
|
|
60
|
+
|
|
61
|
+
由此冻结三点:
|
|
62
|
+
|
|
63
|
+
1. **计时器赢在清队列上时,`cleared` 保持 `{0,0}`**(D2 的"拿不到就记 0"),但**中断照样发出**——清队列是 best effort,永远不许它挡住 abort。
|
|
64
|
+
2. **最坏总时长 ≈ 2× 上限**(清队列一次 + 中断一次)。这是刻意写明的上界,不是超时叠加的 bug。
|
|
65
|
+
3. **timeout 是一份"请求已发出、但对方没在时限内收场"的报告,不是失败**:不抛、不杀、不 retire,也不改 `turnActive` / `state` / `blocked`——状态收敛仍由 `agent_settled` / `updateTurnState` 自然完成。
|
|
66
|
+
|
|
67
|
+
**`turnActive` 快照只取一次,且取在清队列之前**(因此最多落后一个上限)。落在这段窗口里自己走完的 turn 照样会收到这次中断,回执照样是 `interrupted`——这是刻意的:那时队列已经清过了,对一个已经空闲的 child 发一次中断无害;改成"清完再验一次状态"就得如实回 `no-turn`,而那份回执恰好会瞒下它刚刚丢掉的那批消息。
|
|
68
|
+
|
|
69
|
+
**杀进程不是 abort 的职责**,是 `stop` 的。
|
|
70
|
+
|
|
71
|
+
### D5 — abort 与 stop 的边界
|
|
72
|
+
|
|
73
|
+
| | `abort` | `stop` |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| 作用 | 停掉**当前这个动作** | 拆掉**进程** |
|
|
76
|
+
| 手段 | 让 child 的 session 结束当前 turn | `SIGTERM` → `SIGKILL` + 后代清扫 |
|
|
77
|
+
| 上下文 | **保留**:context / transcript / session 文件都在,之后还能 `continue` / `resume` | **丢弃** |
|
|
78
|
+
| 结局 | child 仍活着、可续 | child 没了 |
|
|
79
|
+
|
|
80
|
+
"child 没被回收、仍可续"这条边界不是本契约新立的:`docs/contracts/esc-no-retire-v3.md` 已经冻结了"打断当前等待 ≠ 回收 child"(Esc 只结束父 turn,不 retire children);abort 站在同一侧——它停的是 turn,不是进程。
|
|
81
|
+
|
|
82
|
+
就"能碰到一个正卡在长命令里的 child"而言,abort 是**唯一的那一个 action**:`steer` 只能在 turn 边界排队(见 #313 与 `docs/legion-v2-contract.md` §8.3 的负语义),进不去正在执行的工具调用内部;abort 至少能把这次中断送进那个调用里。但它**不保证对方一定听**——一个忽略取消的工具永远不会 settle,这次调用照样可以答 `timeout`(D4);让进程死掉始终是 `stop` 的活。
|
|
83
|
+
|
|
84
|
+
### D6 — 不在本契约范围
|
|
85
|
+
|
|
86
|
+
- 不做 watchdog,不做自动 abort;
|
|
87
|
+
- 不做"卡住可见性"(#315);
|
|
88
|
+
- 不改 mailbox 枚举(#322);
|
|
89
|
+
- `stop` / `retire` 的语义(含 `force: true` 硬版本、SIGTERM→SIGKILL→后代清扫)原样不动;
|
|
90
|
+
- 不新增除 `abort` 之外的 action。
|
|
91
|
+
|
|
92
|
+
### D7 — 工具面
|
|
93
|
+
|
|
94
|
+
- `SubagentParams.action` 的 union 增加 `"abort"`(排在 `"steer"` 之后,其余顺序不动)。
|
|
95
|
+
- 唯一新增参数是 `clearQueued`:`Type.Optional(Type.Boolean())`,说明以 "abort only: …" 开头,默认 `true`;`clearQueued: false` 保留队列。**工具面不暴露 `timeoutMs`**——这个参数只属于 `wait`,abort 的上限固定为 `DEFAULT_ABORT_TIMEOUT_MS`(D2)。
|
|
96
|
+
- dispatch:`if (action === "abort") return abortChild(ctx);`;`abortChild` 调 `ctx.orchestrator.abort(ctx.name, opts)`,且**只在 `clearQueued === false` 时构造 opts**——默认路径不传对象,`true` 这个默认值只有后端一处来源。
|
|
97
|
+
- 回执逐字(`abortReceipt(name, outcome)`,`seconds` 取自 **`outcome.timeoutMs`** `/1000`:整秒写整数,否则保留一位小数。**回执没有自己的默认上限**:工具面永不传 `timeoutMs`,所以一个本地默认值只会在后端真的换了上限时报出一个没人用过的数字(#325)):
|
|
98
|
+
|
|
99
|
+
| 情形 | 文本 |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `interrupted` | `Aborted <name>'s current turn (context kept; partial edits may remain)` |
|
|
102
|
+
| `interrupted` 且 `cleared.steering + cleared.followUp > 0` | 上面那句 + ` — dropped <n> queued message(s)`(追加,绝不替换前半句;没有单数写法,数字自带含义) |
|
|
103
|
+
| `no-turn` | `Nothing to abort: <name> is not running a turn.` |
|
|
104
|
+
| `timeout` | `Abort sent to <name>, but it had not settled within <seconds>s — the running tool may ignore cancellation; use stop (or stop with force) if it must die.` |
|
|
105
|
+
|
|
106
|
+
- 三态都返回 `ok(...)`;只有抛错路径 `fail(...)`,文本 `abort failed: <error>`,错误码 `ErrorCodes.NOT_FOUND`。
|
|
107
|
+
- `TOOL_DESCRIPTION` 的 action 句与 `PARENT_PLAYBOOK` 都增加 abort 的说明。既有子串断言 `/collect, wait, list, retire/`、`/collect, wait, list, retire, stop/` 必须继续匹配:`, abort` 追加在 `stop` **之后**,不许插在 `retire` 与 `stop` 中间。
|
|
108
|
+
- guards 不动:`signal?.aborted && action !== "stop"` **不为 abort 修改**(issue 明确"不碰 guards");`node_aborted` 的 payload 仍是 `{ name }`。
|
|
109
|
+
|
|
110
|
+
### D8 — 分层透传
|
|
111
|
+
|
|
112
|
+
supervisor 层只有机器三态,"人话"只出现在工具面一层:
|
|
113
|
+
|
|
114
|
+
- `RoutingSupervisor.abort(name, opts)` 就是把 `(name, opts)` 转给 `backendFor(name)` 并**原样返回**后端的三态——不转译、不展平成成功。
|
|
115
|
+
- `Orchestrator.abort(name, opts?)` 保留原有的两道闸门(`requireChild` / `refuseQueuedControl`)与不变的 `node_aborted {name}` 事件,返回值同样原样透出:没有第四种状态,也不把 `timeout` / `no-turn` 折成 `interrupted`。
|
|
116
|
+
- `node_aborted` 是在 supervisor 调用**成功返回之后无条件** emit 的,所以 `no-turn` 与 `timeout` 也各发一条(payload 仍是 `{ name }`,既有行为不变):事件侧(可视化 / 总线)不能把 `node_aborted` 当成"确实有一轮被打断"的证据,那要看回执的三态。
|
|
117
|
+
- `collect` 里 `aborted by parent` 那套推理与本契约无关,不动。
|
|
118
|
+
|
|
119
|
+
## 交叉引用
|
|
120
|
+
|
|
121
|
+
- `docs/legion-v2-contract.md` §3.1(supervisor 接口的 `abort` 缝,含 `AbortOutcome` 注)/ §8.3(`steer` 排队语义,即 abort 要补的那个洞)
|
|
122
|
+
- `docs/contracts/esc-no-retire-v3.md`(打断当前等待 ≠ 回收 child:Esc 只结束父 turn,不 retire children;abort 同侧)
|
|
123
|
+
|
|
124
|
+
## 验收清单
|
|
125
|
+
|
|
126
|
+
`test/unit/abort-supervisor.test.ts` —— 第 1–8 条在 rpc / inprocess 两个后端各跑一遍(标题只差 `rpc` / `inprocess` 前缀,逐字同句);第 9 条一部分是共享常量的直接断言、一部分也是两个后端各一份:
|
|
127
|
+
|
|
128
|
+
1. **no-turn**:空闲 child → `no-turn`,`clearQueue` 与 `abort` 一次都没调,`cleared` 全零(`abort <backend>: an idle child is 'no-turn' and nothing is called`)。
|
|
129
|
+
2. **interrupted**:清队列返回 `{steering:["s1","s2"], followUp:["f1"]}` → `cleared: {steering:2, followUp:1}`,`abort` 恰好一次(`abort <backend>: clears the queue and reports what it dropped`)。
|
|
130
|
+
3. **顺序**:有序日志断言 `["clearQueue", "abort"]`(`abort <backend>: the queue is cleared before the turn is interrupted`)——这条是整个设计存在的理由。
|
|
131
|
+
4. **timeout 不 retire**:`abort` 永不 settle → 25ms 内返回 `timeout`(`cleared` 仍是真实计数),不抛、不 reject;`isAlive` 仍 true;随后 `waitSettled` 仍由 child 自己 `settle()` 的 `agent_settled` 收场(`abort <backend>: a hanging interrupt resolves as 'timeout' without retiring`)。
|
|
132
|
+
5. **clearQueued:false**:清队列返回值非空但**不被读**,`cleared` 全零,日志只有 `["abort"]`(`abort <backend>: clearQueued:false skips the clear and reports zeros`)。
|
|
133
|
+
6. **retired / 未知 child 仍抛 NOT_FOUND**:`assertCanSend` 在 `turnActive` 判定之前跑,已 retired 的 child 不许被读成 `no-turn`,且清队列与中断一次都不发(`abort <backend>: a retired child still throws NOT_FOUND and calls nothing`)。
|
|
134
|
+
7. **清队列抛错**:`clearQueue` 抛出 → `cleared` 计 `{0,0}`,**中断照样发出**(`interrupted`),不把整次 abort 变成失败(`abort <backend>: a throwing clear reports zeros and still interrupts`)。上限到点**之后**才 reject 的 `clearQueue` 同理:计时器已经赢了(`cleared` 保持 `{0,0}`,有序日志仍是 `["clearQueue", "abort"]`),那份迟到的拒绝只被观察、不冒泡(不得变成 `unhandledRejection`),结果仍是 `interrupted`(`abort <backend>: a clear that rejects after the bound still reports interrupted`)。
|
|
135
|
+
8. **清队列挂住**:`clearQueue` 永不 settle → 同一个上限到点即放手(用例用 25 ms 证明这个界是调用方的,不是传输层的),`cleared` 保持 `{0,0}`,中断仍然发出,有序日志同样是 `["clearQueue", "abort"]`(`abort <backend>: a hanging clear is bounded and still interrupts`,D4 的核心用例)。
|
|
136
|
+
9. **默认上限只有一个来源**:`DEFAULT_ABORT_TIMEOUT_MS === 5000`(`abort: the default bound is the shared 5s constant`),且不传 `timeoutMs` 时清队列与中断**各**用这个常量作界(`abort <backend>: an abort without timeoutMs bounds both steps with the default`,靠打补丁观察 `setTimeout` 的延时,不真等 5 s)。**坏值一律回落到它**:`0` / 负数 / `NaN` / `Infinity` 走 D2 的归一,`1e12` 这类超上限值走 `MAX_ABORT_TIMER_DELAY_MS = 2_147_483_647` 封顶(不封顶的话 Node 把延时钳成 1 ms,凭空造出假 `timeout`),用例 `abort <backend>: a junk timeoutMs falls back to the shared bound`。
|
|
137
|
+
10. **routing 透传**:`RoutingSupervisor` 把 `(name, opts)` 转给所属后端并原样返回三态——`abort routing: forwards (name, opts) to the owning backend and returns its outcome`(rpc)与 `abort routing: forwards opts to the inprocess backend and returns its outcome`(inprocess,用 `clearQueued:false` 证明 opts 真的送达)。
|
|
138
|
+
|
|
139
|
+
`test/unit/abort-action.test.ts` —— 工具面与回执:
|
|
140
|
+
|
|
141
|
+
11. **三条回执逐字**:`#314 abortReceipt: interrupted keeps the context and warns about partial edits`、`#314 abortReceipt: no-turn claims no interrupt`、`#314 abortReceipt: timeout points at stop and formats the bounded wait`。
|
|
142
|
+
12. **dropped 计数后缀**:`#314 abortReceipt: interrupted reports the queued messages it dropped`——`n` 是 steering + followUp 之和,后缀是追加而非替换。
|
|
143
|
+
13. **`use stop` 与 `seconds` 格式化**:timeout 回执含 `use stop`;`2500 → 2.5s`、`10000 → 10s`(`#314 abortReceipt: timeout points at stop and formats the bounded wait`)。`seconds` 只在 `outcome.timeoutMs` 里取(#325);「后端真的换了上限时回执报的是实际值」由 `#325 abortReceipt: the timeout receipt names the bound the supervisor really applied` 钉住(工具面不传 `timeoutMs`,supervisor 报 2.5 s ⇒ 回执必须写 2.5 s)。
|
|
144
|
+
14. **默认不传 opts 与参数自描述**:`#314 abort on a live child renders the interrupted receipt and passes no clearQueued`(`clearQueued` 为 `undefined`,不是 `true`);显式关掉的那条是 `#314 abort forwards an explicit clearQueued:false`;参数文案由 `#314 the clearQueued param documents itself as abort-only and default-true` 钉住(`abort only:` / `queued steering/follow-up` / `default true`)。
|
|
145
|
+
15. **no-turn / timeout 都是成功结果**:`#314 abort reports no-turn as a plain success`、`#314 abort timeout is a success whose receipt points at stop`(无 `details.error`、零 retire)。
|
|
146
|
+
16. **NOT_FOUND 两条**:`#314 abort of an unknown name is NOT_FOUND and never reaches the supervisor`(查名先于 supervisor)、`#314 abort reports a supervisor failure as 'abort failed:' + NOT_FOUND`(`abort failed: ` 前缀只由 `abortChild` 的 catch 产生)。
|
|
147
|
+
|
|
148
|
+
`test/unit/playbook.test.ts`:
|
|
149
|
+
|
|
150
|
+
17. **item 9**:`parent playbook 第 9 条` 在 `#314` 的两个用例里 —— `playbook draws the abort-vs-stop boundary and names abort where steer cannot land (#314)` 断言 abort 保留上下文 / `stop` 拆进程 / 卡在长命令里的 child 只有 abort 能碰到(工具忽略取消时这次调用仍可答 `timeout`,见 D5)/ `clearQueued` 默认丢队列 / timeout 不是失败;`playbook says steer queues at a turn boundary and stop is the interrupt (#313)` 里那条 contiguity 断言证明 item 9 夹在 item 8 与 "Utility agents …" 之间、且没有 item 10。`TOOL_DESCRIPTION` 的 action 串按 `…, retire, stop, abort` 追加,`/collect, wait, list, retire/` 与 `/collect, wait, list, retire, stop/` 两条旧断言继续匹配。
|
|
151
|
+
|
|
152
|
+
## 修订
|
|
153
|
+
|
|
154
|
+
- **复审轮 1(#314)**:
|
|
155
|
+
- R0-major:5 s 上限原先只管中断、不管它前面的清队列;RPC 后端上清队列的唯一界是 `RpcClient.send()` 的 30 s 硬编码请求超时,于是回执与 CHANGELOG 声称的 5 s 在真实卡死场景里是假话。→ 清队列并入同一上限(写进 D4,不另立条款)。
|
|
156
|
+
- R2-major:代码注释里的 `#314 Dn` 与本文件编号对不上。→ 本文件即唯一命名空间,D1–D8 覆盖整个改动,验收清单不占用编号。
|
|
157
|
+
- 上面两处在契约文档里曾各有一个临时编号(D11 / D12);它们**不是本契约的条款**,编号最终收敛为 D1–D8。
|
|
158
|
+
- **#325(实现收敛,不改 D1–D8 的语义)**:
|
|
159
|
+
- 两个 `race*Timeout` 私有 helper 收敛成一个 `raceWithTimeout`(`src/supervisor/race-timeout.ts`),两个后端共用;timer 清理、败者 rejection 吞掉、`setTimeout` 上界裁剪都在 helper 内,行为逐字不变。
|
|
160
|
+
- `AbortOutcome` 增加 `timeoutMs`(本调用生效的上限,归一 + 封顶后的值;`no-turn` 是它本该生效的那个值,三态都带),`abortReceipt` 改用它 —— 原先那个 `timeoutMs = DEFAULT_ABORT_TIMEOUT_MS` 参数是死参数:工具面永不传值,回执里的 `seconds` 恒为默认,后端真的换了上限时回执会报一个没人用过的数字。
|
|
161
|
+
- in-process 的 pre-abort `clearQueue` 包进与中断本体相同的 `runAtAbsoluteDepth(child.spawnDepth, () => runInChildContext(child.context, …))`:清队列会 settle 排队消息的 promise,其 continuation 在恢复时继承当时的 ALS store,跑在作用域外就会让宿主代码以为自己是 child(REV2-7 的同一条隐患)。
|
|
162
|
+
- **复审轮 2(#314,最终)**:四处边界澄清,不新增条款、不改 D1–D8 编号:D4 补 turnActive 快照只取一次(取在清队列之前)的窗口语义与“为什么不再验一次”;D2 补 `MAX_ABORT_TIMER_DELAY_MS` 封顶(Node 把过大延时限钳成 1 ms);D5 收敛“唯一能穿透”的措辞(abort 是唯一能把中断送进工具调用内部的 action,但对方若忽略取消仍会答 `timeout`,杀进程归 `stop`);D8 补 `node_aborted` 在 `no-turn` / `timeout` 下也照样 emit(既有行为,payload 仍是 `{ name }`)。验收清单第 7 / 9 条各补一条覆盖用例(迟到的 clear 拒绝、`0` / 负数 / `NaN` / `Infinity` 与 `1e12` 两类坏值回落),两条用例已在 rpc / inprocess 两个后端落地,标题与本文件逐字一致。
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# centurion 声明式启用契约 v1(root 声明才走状态机)
|
|
2
|
+
|
|
3
|
+
- 状态:**生效**,随本契约的实现 PR 落地。
|
|
4
|
+
- 上游:`docs/legion-v2-contract.md`(FROZEN-1,本契约按 §15 修订)、
|
|
5
|
+
`docs/contracts/declarative-phase-machine-v5.md`(现行相位机行为依据)、
|
|
6
|
+
`docs/contracts/viz-frame-alloc-v1.md`(无关,仅作行文参照)。
|
|
7
|
+
- 冲突时停下报告,不得自行解释。
|
|
8
|
+
|
|
9
|
+
## §0 一句话
|
|
10
|
+
|
|
11
|
+
**状态机不再是"角色叫 centurion"的自动后果,而是 root 在自己那次派发里显式声明的东西。**
|
|
12
|
+
声明了 = 今天的行为,一字不改;没声明 = 一个有权派活的普通 agent(该干完就干完)。
|
|
13
|
+
「不能派 centurion」不变,并首次以 REV 形式记录。
|
|
14
|
+
|
|
15
|
+
## §1 为什么
|
|
16
|
+
|
|
17
|
+
1. 今天 `nodes.phase` 的写入键是角色名字面量(`src/runs/orchestrator.ts:669`),
|
|
18
|
+
于是**每一次** centurion 派发都被强制五阶段:写契约 → 派 worker → 派 reviewer →
|
|
19
|
+
跑 verify → squash MR。写一行文件的活实测 7m55s。
|
|
20
|
+
2. 零派遣的团队在实现上**无合法收尾路径**:`done` 的唯一入边要 `verifying`,
|
|
21
|
+
`verifying` 的唯一入边要 `children_present`(`phase-machine.ts:97/111/123`),
|
|
22
|
+
所以 solo 只能走 `failed`(需自认失败)或父级 `aborted`(外部动作)。
|
|
23
|
+
实测:`legion_phase` 返回 `no child has been dispatched yet`,父会话只能人工背书
|
|
24
|
+
(acceptance 落到 `attested`)。
|
|
25
|
+
3. 卡上的 `acceptance.level: verified` 是**死字段**(全仓无读取),而
|
|
26
|
+
`legion-team-delivery` 的 evidence 不含 `verification-output`、也没有 `command`
|
|
27
|
+
⇒ 该条永远不被机器验证。
|
|
28
|
+
4. 「非 root 不得派 centurion」与冻结契约 §9(F13「孙代 centurion 递归成立」)、
|
|
29
|
+
§6.1(`maxSubagentDepth: 2` 注释「孙代 centurion 可再派一层」)、README `:53/:62/:558`
|
|
30
|
+
**直接冲突**,且没有任何 REV 记录(违反 §15-4「权限变更必须走修订」)。
|
|
31
|
+
|
|
32
|
+
## §2 声明形状
|
|
33
|
+
|
|
34
|
+
在 `subagent` 工具参数上新增 `phases`(仅 `action: "launch"` 有效),接受
|
|
35
|
+
`declarative-phase-machine-v5` 的同一份机器形状(也接受等效的缩进文本,复用现有解析器):
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{ "agent": "centurion", "task": "...",
|
|
39
|
+
"phases": [ { "id": "planning", "next": [{ "to": "implementing", "require": ["contract_file"] }] },
|
|
40
|
+
{ "id": "implementing", "next": [{ "to": "done" }] },
|
|
41
|
+
{ "id": "done", "terminal": true, "next": [] } ],
|
|
42
|
+
"start": "planning" }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
解析优先级(与卡片一致,新增内联层在最前):
|
|
46
|
+
|
|
47
|
+
1. launch 参数 `phases`
|
|
48
|
+
2. 角色卡 `phases:`(`agents/centurion.md` 当前没有)
|
|
49
|
+
3. 都没有 ⇒ **不挂机器**
|
|
50
|
+
|
|
51
|
+
## §3 代码级守卫(全部在 `Orchestrator.launch`,创建 worktree/run 目录**之前**抛出)
|
|
52
|
+
|
|
53
|
+
| 编号 | 规则 | 拒绝文案要点 | 与既有实现的关系 |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| **G1** | 非 root 派 `centurion` → 拒 | `centurion is dispatchable only from the root shell; '<parent>' at '<node>' cannot` | 已有(`orchestrator.ts:945-961`,文案改掉 "only the root session may dispatch a centurion" 的措辞以匹配 §4 的 REV) |
|
|
56
|
+
| **G2** | 任何**非 root** 传 `phases` → 拒 | `phases are declared by the root shell only; '<node>' cannot` | 新增;**拒绝而非忽略**(静默忽略会让调用方以为开了机器) |
|
|
57
|
+
| **G3** | root 给**非 centurion** 传 `phases` → 拒 | `phases belong to a centurion team; dispatch a worker for solo work` | 新增;挡住"给 worker 声明状态机"这种无意义组合 |
|
|
58
|
+
| **G4** | 内联 `phases` 必须通过现有校验器 **且** 从 `start` 可达某个终态 | 逐条转贴 `validatePhaseMachine` 的 path+message;可达性缺失时 `phase machine cannot reach a terminal phase from '<start>'` | 新增(校验器 `phase-machine.ts:228` 现在**不检查可达终态**,卡片里 `planning→implementing→done` 能过校验但永不收尾) |
|
|
59
|
+
| **G5** | 声明的机器里若有 `→ done` 边,节点可以自己走;否则仍拒 | `done is controlled by the parent or supervisor`(保留给没声明的机器) | 改 `legion-tools.ts:923-925` 的硬拦 + `:1014` 的 `mayDeclareFailure` 同族判断 |
|
|
60
|
+
| **G6** | `legion-verify` 在**机器的验证相位**跑,不再靠字面量 `"verifying"` | — | 改 `legion-tools.ts:1091` 与 `:557-600` 的 `successPhase` / `verificationPassed` 耦合 |
|
|
61
|
+
|
|
62
|
+
「拒绝而不是降级」的取舍沿用 #272 的先例(在产生工件前抛,避免留在树上的空 worktree)。
|
|
63
|
+
|
|
64
|
+
## §4 修订记录(本契约同时充当 REV)
|
|
65
|
+
|
|
66
|
+
- **REV-1(本契约)**:把「非 root 不得派 centurion」从**未记录的实现约束**提升为**契约条款**,
|
|
67
|
+
与 §9 F13 / §6.1 的"递归孙代"表述冲突,按 §15-4 走修订。裁决:**保持禁止**。
|
|
68
|
+
理由(老板决定,2026-10-31):centurion 的语义是"root 派出去的一支子树",嵌套 lead 不带来
|
|
69
|
+
新的交付语义,只带来深度与工件的递归;需要更多并行时由 root 直派多个 centurion。
|
|
70
|
+
`agents/centurion.md` 的 description 同步改为「仅根会话可派发」,与代码一致。
|
|
71
|
+
- 未改动:worktree 归属(§9 REV-17 保持不变,仍为 centurion 专属、仍强制 `true`、仍绑 rpc)。
|
|
72
|
+
|
|
73
|
+
## §5 不变量(必须全绿)
|
|
74
|
+
|
|
75
|
+
| 编号 | 不变量 | 守卫 |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| **I1** | 声明了机器的 centurion 行为**完全等于今天**:planning 需契约文件、implementing 需 ≥1 settled 子节点、reviewing 需 settled reviewer 的 `ok:true`、verifying 跑 `legion-verify`、`done` 后注入 squash 提示 | 现有 `test/unit/legion-tools.test.ts:114`、`test/integration/legion-tree.test.ts:164` **不改** |
|
|
78
|
+
| **I2** | 未声明机器的 centurion:`nodes.phase` 为 NULL、不注册 `legion_phase`、不注入收队提示,turn 结束交 verdict 即完成 | 新增用例 |
|
|
79
|
+
| **I3** | worktree 规则不变(只有 centurion 拿,仍 `:true`,组员强制 `false`,非 centurion 请求降级 + 警告一次) | `test/unit/worktree-centurion-only.test.ts`、`test/unit/launch-worktree-probe.test.ts` **不改** |
|
|
80
|
+
| **I4** | 深度公式 `min(maxDepth, depth+roleDepth, 4)` 不变 | `test/integration/nesting.test.ts` **不改** |
|
|
81
|
+
| **I5** | `implementing` 的相位级 `children_settled` 仍 AND 进每一条出边(含 failed) | 现有用例 |
|
|
82
|
+
| **I6** | 未声明的机器上 `done`/`aborted` 仍只能由 supervisor/父级驱动 | 现有 `test/unit/legion-phase-exit.test.ts:367` |
|
|
83
|
+
| **I7** | 声明不可伪造:非 root 传 `phases`、root 给非 centurion 传 `phases` 都在 launch 期被拒,且**不留下任何工件** | 新增用例(断言 worktree/run 目录不存在) |
|
|
84
|
+
|
|
85
|
+
## §6 验收
|
|
86
|
+
|
|
87
|
+
1. 新增 3 条 e2e:
|
|
88
|
+
- **solo 声明**:`phases: planning→implementing→done` 的 centurion 在**零子节点**下走完并 `done`,
|
|
89
|
+
父级 collect 得到 `accepted`,且不再需要 `attested` 兜底;
|
|
90
|
+
- **团队声明**:与 `legion-tree.test.ts:164` 同形,证明 I1;
|
|
91
|
+
- **不声明**:`nodes.phase IS NULL`、工具集里没有 `legion_phase`、verdict 正常返回(I2)。
|
|
92
|
+
2. G2/G3/G4 各一条拒绝用例:断言抛错文案、`spawns.length` 不变、worktree 目录不存在。
|
|
93
|
+
3. `npm run typecheck` / `npm test` / `npm run test:integration` 全绿;
|
|
94
|
+
`bench/perf-core/10-legion-frame-alloc.ts` 与本契约无关但不得回归。
|
|
95
|
+
4. `docs/legion-v2-contract.md` §15 追加 REV-1;`agents/centurion.md` 的 description 与正文
|
|
96
|
+
补「状态机由 root 声明」与「不声明时你就是个能干活的 agent」。
|
|
97
|
+
|
|
98
|
+
## §7 非目标
|
|
99
|
+
|
|
100
|
+
- 不改 `worktree ⇔ rpc` 的结构耦合(`orchestrator.ts:1008`)。
|
|
101
|
+
- 不新增角色、不改 roster 粒度、不动 `teams` 裁剪。
|
|
102
|
+
- 不让 centurion 越过父级合并(MR 合并权仍在父节点)。
|
|
103
|
+
- 不修 `acceptance.level` 的死字段语义(只在本契约 PR 里删掉卡上那行 + 把
|
|
104
|
+
`legion-team-delivery` 标 `optional`,避免"声明了 verified 却永不验证")。
|
|
@@ -100,6 +100,49 @@ Exactly three are terminal and are always available regardless of `next`:
|
|
|
100
100
|
- `failed` — parent decision, all children failed, or rework exhausted
|
|
101
101
|
- `aborted` — parent retire only
|
|
102
102
|
|
|
103
|
+
`aborted` is the emergency stop, so a parent retire must land even when the
|
|
104
|
+
transition itself cannot be resolved (#351). The abort asks the machine that the
|
|
105
|
+
node's **role card** declares; if no machine can be resolved for the node's
|
|
106
|
+
current phase name, the retire is forced anyway: the phase becomes `aborted`, one
|
|
107
|
+
`phase_change` event carries `{ forced: true, reason:
|
|
108
|
+
"parent_retire_unresolved_phase", denied: "<reason>" }` (no new event type — the
|
|
109
|
+
set stays closed at 16), and one warning names the node, the phase and the
|
|
110
|
+
refusal. Silently dropping the abort is what made a team unstoppable.
|
|
111
|
+
|
|
112
|
+
## The advisory clock and the completion gate (#358)
|
|
113
|
+
|
|
114
|
+
Two supervisor-side paths used to read the built-in phase vocabulary only:
|
|
115
|
+
|
|
116
|
+
- **The clock.** `checkOwnPhaseTimeout` resolved `settings.phaseTimeouts[phase] ??
|
|
117
|
+
DEFAULT_PHASE_TIMEOUTS[phase] ?? 0`. `DEFAULT_PHASE_TIMEOUTS` is keyed by the four
|
|
118
|
+
built-in names, so a declared phase got `0` — "never warn" — and `PhaseDefinition.timeoutMs`
|
|
119
|
+
was dead config. Resolution order is now: explicit operator override
|
|
120
|
+
(`settings.legion.phaseTimeouts`) → card `timeoutMs` → built-in default → `0` (never warn).
|
|
121
|
+
The operator stays first because a machine must not be able to take away a limit the
|
|
122
|
+
operator set — **"explicit" is the load-bearing word**: the run-time settings object has
|
|
123
|
+
every default filled in, so "the key is present" is not an operator decision, and a
|
|
124
|
+
resolver that reads the filled value puts a clock of 60m on `implementing` whether or not
|
|
125
|
+
anyone ever wrote one down. That is what made this order a fiction on the four built-in
|
|
126
|
+
names (the case a card is most likely to declare) until the explicitness the settings
|
|
127
|
+
parser already records was carried through the fill. Behaviour change worth naming: a
|
|
128
|
+
card that declares `timeoutMs` on `planning`/`implementing`/`reviewing`/`verifying` now
|
|
129
|
+
really wins over those defaults; with no card value and no explicit setting the four
|
|
130
|
+
built-in numbers are unchanged (15m / 60m / 30m / 15m). Timeouts still only *report*.
|
|
131
|
+
- **The completion gate.** `finalizeVerifiedTeamIfReady` hard-coded `phase === "verifying"`
|
|
132
|
+
and evaluated `verifying → done` against the built-in machine. It now asks the machine
|
|
133
|
+
which phase judges success: a phase with an exit to `done`, preferring an edge that
|
|
134
|
+
requires `verification_passed`, otherwise the **last** declared candidate (declaration
|
|
135
|
+
order is pipeline order, and the built-in machine's answer is `verifying`). A team with
|
|
136
|
+
no declared machine, or a machine with no exit to `done`, keeps the literal `verifying`.
|
|
137
|
+
**`phases:` order is pipeline order, and the derivation depends on it.** When more than
|
|
138
|
+
one phase can reach `done`, "the last declared candidate" is the one that completes the
|
|
139
|
+
team — a card that lists its phases in some other order moves the completion gate with
|
|
140
|
+
the declaration, not with the workflow, so the phase that really ends the pipeline would
|
|
141
|
+
never be collected from. Declare phases in the order the work happens; the tests pin
|
|
142
|
+
both the assumption (`test/unit/legion-custom-phase-finalize.test.ts`, "declaration
|
|
143
|
+
order, not pipeline order…") and the control that a non-final phase cannot complete a
|
|
144
|
+
team.
|
|
145
|
+
|
|
103
146
|
## Safety rails (unchanged)
|
|
104
147
|
|
|
105
148
|
These are invariants, not configuration:
|
|
@@ -426,3 +426,91 @@ live `pi list`**」,但 `withPathEnv` 执行的是 `process.env.PATH = pathVal
|
|
|
426
426
|
- **降级**:`rev-r1` 的 minor「点文件导致 `pi -e` exit 1」论据不成立(实测 EXIT=0)。
|
|
427
427
|
但「点文件不该被收」仍成立(pi 自己会跳过,多传一条无用条目)→ 并入 R2-a 的 A18。
|
|
428
428
|
- **保持驳回**:§7 R1-c 的「自排除误吃 `acme/src/legion/index.ts`」仍不成立,勿再报。
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 9. 修订 R3(#317 诊断改动,2026-10-28)
|
|
433
|
+
|
|
434
|
+
本节只补**可见性与诊断**。§2 D2-a 的 never-reject 契约、§8 R2-b 的 `FAILURE_TTL_MS` 退避、
|
|
435
|
+
以及继承策略本身(D3/D4/D5)**均未改变**:失败仍然不抛、不缓存 `[]`、仍按 TTL 重试。
|
|
436
|
+
F/A 表继续有效,以下为增量。
|
|
437
|
+
|
|
438
|
+
### R3-a 事实(本轮实施前核对)
|
|
439
|
+
|
|
440
|
+
| # | 事实 | 证据 |
|
|
441
|
+
| --- | --- | --- |
|
|
442
|
+
| F42 | 失败探测只写 `failedAt` 时间戳,全仓无任何日志;`resolveHostExtensions` 在 catch 里丢弃错误(含 `pi list` 的 stderr) | `src/runs/extension-inheritance.ts`(改前 `catch { return undefined; }`) |
|
|
443
|
+
| F43 | `collectChildExtensions` 用 `discoveredHostExtensions() ?? []`,于是「发现失败 / 宿主真为空 / 从未探测」三者对下游都是 `[]` | `src/runs/args.ts:217` |
|
|
444
|
+
| F44 | doctor 对 inherit 角色只输出占位串 `(all host-discovered extensions)`,三种状态不可区分 | `legion-doctor.ts` `extensionsForRole` |
|
|
445
|
+
| F45 | `PI_LEGION_CHILD_EXTENSIONS` 对 inprocess **同样生效**:`inprocess-supervisor.ts:176` → `collectChildExtensions` → `childExtraExtensions(env)` | 读源码,与 rpc 共用同一收集函数 |
|
|
446
|
+
| F46 | `INPROCESS_MODEL_HINT` 仍推荐 `inheritExtensions: true` / `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`;接线后前者是默认策略、后者是 no-op | `spawn-hints.ts` |
|
|
447
|
+
|
|
448
|
+
### R3-b 决策(D6–D9)
|
|
449
|
+
|
|
450
|
+
**D6 — 失败告警一次,且仍不 reject。** 探测失败时写 `failedAt` 并发一条 `[legion]` 前缀告警,
|
|
451
|
+
内容含 ≤`DISCOVERY_STDERR_LIMIT`(200 字符)的 `pi list` stderr 摘要;stderr 为空时按
|
|
452
|
+
**exit code → `error.message`** 顺序退化(code 在 message 之前,因为 `execFile` 的 `message` 恒为
|
|
453
|
+
`Command failed: pi list`,message 优先会让 code 分支不可达),最后才落到兜底句,绝不抛。
|
|
454
|
+
`failedAt` 兼任「已告知」位:stamp 存在时后续失败不再刷屏。
|
|
455
|
+
|
|
456
|
+
**告警上界是「每进程一次」,不是「每个 episode 一次」**(review 后修订):成功探测把结果写进
|
|
457
|
+
`discovered` 并永久短路后续探测,所以生产路径上不存在「恢复后再次失败」——唯一的回退手段是测试
|
|
458
|
+
seam `resetExtensionDiscoveryCache()`,它同时清掉 stamp,等价于重新开始。故措辞与代码都按
|
|
459
|
+
「每进程最多一条」表述(D6 旧措辞「恢复后再失败会再告警一次」已删除)。
|
|
460
|
+
**不新增任何持久化字段**,告警体自身也裹 try/catch —— 告警失败不得使 launch 失败。
|
|
461
|
+
|
|
462
|
+
**D7 — 只读内省。** 新增 `hostExtensionDiscoveryState(): { status: "ok" | "failed" | "unknown"; count: number; at?: number }`。
|
|
463
|
+
`unknown` = 本进程从未探测;`ok` + `count: 0` = 探测成功但宿主确实没有包扩展;`failed` 带 `at`
|
|
464
|
+
(上次失败时刻)。`discoveredHostExtensions()` 签名不变。该 getter 是纯内存读,**绝不触发探测**。
|
|
465
|
+
|
|
466
|
+
**D8 — doctor 新增一行。** `## Extensions` 段输出
|
|
467
|
+
`- discovered host extensions: N (last probe: ok | failed <n>s ago | never)`;`failed` 带距今秒数。
|
|
468
|
+
doctor 自身不探测(否则每次 doctor 都要付 0.3–0.9 s 的 `pi list`),所以 `never` 如实表示
|
|
469
|
+
「本进程还没探测过」,而不是「宿主为空」。
|
|
470
|
+
|
|
471
|
+
**D9 — spawn 提示只推荐仍然有效的杠杆。** `INPROCESS_MODEL_HINT` 改为只推荐
|
|
472
|
+
`PI_LEGION_CHILD_EXTENSIONS=<path>` 与把 provider 静态声明进 `models.json`,保留
|
|
473
|
+
`/legion doctor` / `subagent action=diagnose` 指引;当
|
|
474
|
+
`hostExtensionDiscoveryState().status !== "ok"` 时追加一句
|
|
475
|
+
`inheritance is enabled but host discovery is <failed|never probed> — the child got 0 host extensions`。
|
|
476
|
+
注入方式:`spawnFailureHint` 新增可选入参 `discoveryStatus`(保持纯函数、不做 IO;缺省读内存态,
|
|
477
|
+
生产路径 `enrichFailureWithAutoDoctor` 无需改动即可获得该提示)。`ok`(含 count 0)不追加。
|
|
478
|
+
该 note **对每个分支都追加**(inprocess / rpc `--no-extensions` / rpc 无 `--no-extensions`),
|
|
479
|
+
因此任何分支都不得再推荐 `inheritExtensions` / `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1` —— 否则
|
|
480
|
+
同一条提示会自相矛盾(review 后修订,见 R3-e)。
|
|
481
|
+
|
|
482
|
+
### R3-c 验收增量
|
|
483
|
+
|
|
484
|
+
| # | 断言 |
|
|
485
|
+
| --- | --- |
|
|
486
|
+
| A23 | 失败探测只告警一次;同窗口重复调用不再探测也不再告警;跨 `FAILURE_TTL_MS` 重试仍不重复告警 |
|
|
487
|
+
| A24 | 空 stderr 的失败仍告警、不抛、且消息里不出现 `undefined` |
|
|
488
|
+
| A25 | `hostExtensionDiscoveryState()` 区分 `ok`(count N 与 count 0)、`failed`(带 `at`)、`unknown` |
|
|
489
|
+
| A26 | doctor 输出在 fake `pi list` 失败 / 成功 / 未探测三种状态下分别含 `failed \d+s ago` / `ok` / `never`,且 doctor 不额外探测 |
|
|
490
|
+
| A27 | `INPROCESS_MODEL_HINT` 不再出现 `inheritExtensions` 与 `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`,且含 `PI_LEGION_CHILD_EXTENSIONS=<path>`、`models.json` |
|
|
491
|
+
| A28 | discovery 为 `failed` / `unknown` 时提示含 `the child got 0 host extensions`;`ok` 时不追加 |
|
|
492
|
+
| A29 | **真生产路径**:fake `pi list` 失败 → `ensureHostExtensions()` → `spawnFailureHint({stderr, spawnMode:"inprocess"})`(**不传** `discoveryStatus`)含 `host discovery is failed`;且 `import("…extension-inheritance.ts?fresh=1")` 重新求值的实例读同一 `globalThis` holder(`hostExtensionDiscoveryState().status === "failed"`)。**#329 修订**:原文写「同一断言对 `?fresh=1` 重新求值的 **spawn-hints** 模块实例同样成立」,那是空转——ESM 解析相对说明符时丢弃 query,`spawn-hints.ts?fresh=1` 内部的 `./extension-inheritance.ts` 仍解析到**同一个**已缓存实例,底层 store 换成模块内对象也能绿;故改为对 holder 的拥有者模块 `extension-inheritance.ts` 本身重新求值 |
|
|
493
|
+
| A30 | `describeProbeFailure` 退化链钉住:空 stderr + exit 4 → `pi list exited 4`;无 `pi`(`ENOENT`,非数字 code)→ `spawn pi ENOENT` |
|
|
494
|
+
| A31 | rpc `--no-extensions` 分支不再出现 `inheritExtensions` / `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`,`PI_LEGION_CHILD_EXTENSIONS` 仍在句首 |
|
|
495
|
+
|
|
496
|
+
### R3-d 文件归属增量
|
|
497
|
+
|
|
498
|
+
`src/runs/extension-inheritance.ts`、`src/extension/legion-doctor.ts`、
|
|
499
|
+
`src/extension/spawn-hints.ts`、`test/unit/{extension-inheritance,legion-doctor,spawn-hints}.test.ts`、
|
|
500
|
+
`CHANGELOG.md`、本节。`src/runs/args.ts` 在允许清单内但**本轮无需改动**(诊断信息从 discovery
|
|
501
|
+
模块直接读出,不改 `?? []` 的消费形状)。
|
|
502
|
+
|
|
503
|
+
### R3-e 刻意不做(避免重复争议)
|
|
504
|
+
|
|
505
|
+
- 不改 `#212` 的「inprocess 不给 **argv** 建议」结论:argv 建议仍不出现。但
|
|
506
|
+
`PI_LEGION_CHILD_EXTENSIONS` 不是 argv(F45),inprocess 同样生效,故 #317 起明确推荐它,
|
|
507
|
+
并把旧断言 `doesNotMatch(PI_LEGION_CHILD_EXTENSIONS)` 改写为「不得出现 argv 建议」。
|
|
508
|
+
- ~~rpc 分支提示保留既有措辞~~(**review 后修订**):本 PR 把 discovery note 注入了 rpc 分支,
|
|
509
|
+
而该分支仍推荐 `inheritExtensions: true` / `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1`,同一条提示
|
|
510
|
+
自相矛盾(note 说的是「继承已开但 discovery 失败 → 子节点 0 条扩展」)。故删掉该子句,
|
|
511
|
+
`PI_LEGION_CHILD_EXTENSIONS` 与 role `extensions:` 仍保留(A31)。
|
|
512
|
+
- 不做主机级 retry daemon、不改 `FAILURE_TTL_MS`、不改继承/排除策略。
|
|
513
|
+
- 不新增用例覆盖「恢复后再失败」(生产不可达):可达路径只有测试 seam reset(见 D6),加用例等于把
|
|
514
|
+
合成路径固化成契约;改为在 D6/代码注释/CHANGELOG 里如实表述。~~不把「每进程一次」的告警上界写成测试~~
|
|
515
|
+
(**#329 修订**):原文这句话与既有断言不符——「重复失败不刷屏」已由
|
|
516
|
+
`test/unit/extension-inheritance.test.ts:859` 钉住,未覆盖的只是「恢复后再失败」这一条。
|