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/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,864 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [5.2.0] — 2026-11-04
|
|
15
|
+
|
|
16
|
+
一次以「回复说真话」为中心的发布。批量派发的每个子节点可以带自己的 handle;`stop` 与超时的回复不再把
|
|
17
|
+
「已经跑完」「还在跑」说成相反的东西;`collect` 与 `stop` 撞车时不再丢最后一轮产物;文档里「`timeoutMs`
|
|
18
|
+
会终止子节点」的说法被容器实测推翻并改正。
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`tasks[]` 的每个条目现在可以带自己的 `name`,而且这个 handle 一定生效。** 以前条目里的 `name` 被
|
|
23
|
+
静默丢弃——schema 里没有这个字段、`launchStep` 也从不读它,于是调用方明明写了名字,批量出来的子节点仍被
|
|
24
|
+
自动编号成 `scout-N`/`worker-N`,没有任何提示;现在条目名优先、其次顶层 `name=`、都没有才自动编号。名字不合
|
|
25
|
+
语法、同一批内重名(含大小写/空格差异)、或单条目同时给了两个不同的名字,都在**任何子节点启动之前**整批拒绝,
|
|
26
|
+
不会留下「一半按调用方的名字、一半按自动名」的半启动状态。`vexillatio` 自己为每一步分配唯一 handle,因此对
|
|
27
|
+
条目里的 `name` 直接报错拒绝,而不是静默忽略。
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **`stop` 的回复现在带状态,不再把「已经跑完」说成「被停掉」。** 名字在子节点 retire 之后才到达时,回复是
|
|
32
|
+
`No non-retired children to stop. worker-2 is already retired (execution: success, retired at …)`;命中
|
|
33
|
+
settle→retire 那一小段窗口时,回复 `Retired 1 already-settled child: x (execution: success)` 而不是
|
|
34
|
+
`Stopped 1 child`——后者等于宣称一次从未发生的打断。retire 行为本身不变,只有措辞。
|
|
35
|
+
|
|
36
|
+
- **超时不再被说成会终止子节点,`stop` 竞态也不再丢最后一轮产物。** 角色 `timeoutMs` 实际只是父侧等待预算:
|
|
37
|
+
容器实测 `worker.timeoutMs=10s` 的 child 存活 56s 并正常 `success`,且 pi 1.4 的 CLI / settings 都没有 turn 级超时;
|
|
38
|
+
文档与 renewal 契约据此改正,`collect deadline (cumulative since launch)` 改为 `(per collect attempt)`(原文案
|
|
39
|
+
本来就是错的)。`finishCollect` 在「child 已被 retire」时仍写 `<name>.output.md`,不再因 `stop` 撞上 collect
|
|
40
|
+
而丢掉那一轮文本。失败通知携带 `stopReason` / `errorMessage` / `errorKind`,当合成 reason 表达不了 provider
|
|
41
|
+
错误时多打一行 `error:`;`wait` 超时回执的 `hint` 改为「只是停止等待,child 还在跑」,不再把 `stop` 摆在建议位。
|
|
42
|
+
|
|
43
|
+
## [5.1.0] — 2026-11-02
|
|
44
|
+
|
|
45
|
+
一次以**可恢复、可声明的子树**为中心的发布。三个新能力:`centurion` 的状态机改为由 root 派发时用 `phases:` 声明(不声明就是普通 agent);同一个 session 重开能接回上次的树,退出等于暂停、恢复必须显式;长等待中的 `wait`/`collect` 会在收到子节点邮件时立刻返回。`/legion` 一侧:整帧分配降到 2.0MB 以内(120×40 −59%、200×60 −67%),overlay 改为主屏挂载,状态行能说清子节点正在做什么,且不再每写一格分配一个 `Cell`。另有三十余项稳定性修复:失败队不再静默消失、内联相位机器进树、headless 账本不再漏记、`ctx is stale` 与宿主 `turn_end` 的报错消失。
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **`legion_phase` gained the two arguments a centurion actually needed: `supersedes`
|
|
50
|
+
and `reason`.** `implementing` had no legal exit for the most common way a squad settles — one child
|
|
51
|
+
failed, the rest settled. `implementing → reviewing` demands `children_none_failed`, which only
|
|
52
|
+
tolerates failures listed in `handledIds`; that set had exactly one producer, and its hard
|
|
53
|
+
precondition was `reworkCount > 0`, which increments only on the two `rework: true` edges —
|
|
54
|
+
`implementing` has none. `implementing → failed` demands `children_all_failed`, and
|
|
55
|
+
`to: "failed"` was refused on the tool surface before the state machine ever ran. Meanwhile
|
|
56
|
+
`terminalReason: "parent_decision"` was already unconditionally accepted by the state machine
|
|
57
|
+
with zero producers in `src/`. Both gaps are now closed without touching the transition graph: a
|
|
58
|
+
centurion may declare `to: "failed"` **with a reason** (a missing reason is refused, and `done`/`aborted`
|
|
59
|
+
stay off-limits because they belong to the parent or supervisor), and may pass
|
|
60
|
+
`supersedes: [<failed child>]` to name which failures a settled sibling has taken over — an
|
|
61
|
+
extra source unioned into the existing `reworkCount > 0` / `updatedAt < phaseSince` derivation,
|
|
62
|
+
never a replacement for it, with a `node_handled` audit event per handled id. A declared failure
|
|
63
|
+
still has to be a declared exit: the tool only hands the state machine that terminal reason when
|
|
64
|
+
the team's effective (normalized) machine offers a `failed` edge out of the current phase that
|
|
65
|
+
this node may take, so a card that reserves `failed` to another actor — or declares the phase but
|
|
66
|
+
omits the edge — keeps its veto, and the persisted reason is capped like `note`/`evidence`. A
|
|
67
|
+
declared failure is a terminal verdict, so it now mails the parent like the other two failure
|
|
68
|
+
paths do instead of leaving it to poll the ledger. The
|
|
69
|
+
refusal text for the deadlock now names both of those calls instead of two actions that were
|
|
70
|
+
unreachable in the current code. Contract: `docs/contracts/partial-failure-exit-v1.md` (#309).
|
|
71
|
+
|
|
72
|
+
- **`status` now says what a live child is doing and for how long — `working · bash 00:07:31` —
|
|
73
|
+
and marks a single tool that has run unchanged for five minutes.** "The process is alive but has
|
|
74
|
+
stopped progressing" was invisible everywhere: the strongest `liveness()` rule calls any child
|
|
75
|
+
holding a non-terminal handle `live`, the heartbeat refreshes for exactly those children, and the
|
|
76
|
+
orphan scan exempts a node whose first root child is still alive, so a child stuck inside one
|
|
77
|
+
tool call looked identical to one doing useful work and the parent learned otherwise only when
|
|
78
|
+
its 900s `collect` deadline fired. Both backends now record the one tool call a child is inside —
|
|
79
|
+
tool name plus the host clock reading when its own `tool_execution_start` arrived — onto the child
|
|
80
|
+
record, folded from the session event stream each backend already consumes (no new IPC, no
|
|
81
|
+
polling, no new state machine). `subagent({ action: "status" })`, the `all: true` overview and
|
|
82
|
+
`/legion status <name>` lead each block with that fact and, at or past
|
|
83
|
+
`STALL_NOTICE_MS = 300_000`, append a marker naming how many whole minutes the call has been
|
|
84
|
+
stuck and which lever can move it — `steer` cannot reach a child that is inside a tool call
|
|
85
|
+
(#313), `abort` can (#314).
|
|
86
|
+
**This is display only**: `liveness()` is untouched, no heartbeat or orphan-scan criterion moved,
|
|
87
|
+
and nothing is ever auto-killed, aborted or retired — the marker names the levers, the parent
|
|
88
|
+
still pulls them. A child with no in-flight tool renders exactly the pre-#315 block, byte for
|
|
89
|
+
byte. The one behaviour change is that the read-only `scout` role now declares
|
|
90
|
+
`toolTimeoutMs: 600000`, because `toolTimeoutMs` had no default and the SDK bash tool has no
|
|
91
|
+
default timeout, so a single hung probe could pin a recon child for its whole turn budget and
|
|
92
|
+
report nothing; `worker` and `centurion` deliberately keep no default, since killing a
|
|
93
|
+
slow-but-progressing build or integration run costs more than the hang it would prevent. Contract and the frozen boundary:
|
|
94
|
+
`docs/contracts/stall-visibility-v1.md` (#315).
|
|
95
|
+
|
|
96
|
+
- **`subagent({ action: "abort" })` stops a child's current turn without killing it.** The
|
|
97
|
+
capability was already sitting in the supervisor (`LegionSupervisor.abort`, `RpcSupervisor`,
|
|
98
|
+
`InProcessSupervisor`, `Orchestrator.abort`) with zero call sites and no action on the tool
|
|
99
|
+
surface, so the only ways out of a stuck child were `stop` (tear the process down and throw the
|
|
100
|
+
context away) and `steer` (a queue, so it cannot reach a child that is inside a tool call). The
|
|
101
|
+
action now clears the child's queued steering/follow-up messages first — `clearQueued`,
|
|
102
|
+
default `true`, because an abort that lands first lets the child immediately pick up the oldest
|
|
103
|
+
queued instruction, the opposite of "stop that" — and then asks the child to stop the current
|
|
104
|
+
turn. Context, transcript and session file survive, so the child can be continued afterwards.
|
|
105
|
+
The call is bounded: one 5s limit covers the interrupt **and** the queue clear that has to land
|
|
106
|
+
before it, so the worst case is about twice that bound — and the tool surface takes no bound of
|
|
107
|
+
its own (`timeoutMs` stays a `wait`-only parameter; a clear that cannot answer counts as zero
|
|
108
|
+
while the interrupt is still sent). A call that hits the bound answers `timeout` instead of
|
|
109
|
+
hanging, and it never kills anything: `stop` (`force: true` for the hard variant) remains the
|
|
110
|
+
path that takes the process down. Three honest receipts — `interrupted` (with the count of
|
|
111
|
+
queued messages it dropped), `no-turn` (nothing was running; the queue is left alone) and
|
|
112
|
+
`timeout` (the request went out, the child had not settled). Contract and the frozen three-state
|
|
113
|
+
table: `docs/contracts/abort-soft-interrupt-v1.md` (#314).
|
|
114
|
+
|
|
115
|
+
### Changed
|
|
116
|
+
|
|
117
|
+
- **交付物是分支,MR 只是有 remote 时的便利。** 以前角色卡、注入给子节点的任务附录、验证后的提示语都写「开 MR/PR 并把 URL 写进 verdict」,
|
|
118
|
+
在**零 remote** 的仓库里等于要求一个不可能的东西(实测一次真实会话:centurion 只能交一条待推分支,如实报 `no remote`)。
|
|
119
|
+
现在统一的措辞是:**分支名 + HEAD sha 就是交付**;仓库有 remote 且 `gh`/`glab` 可用时再附 MR/PR 链接,缺失不算失败,**任何情况下不许编造 URL**。
|
|
120
|
+
逐级合并权不变(仍由父节点合并,认分支/提交而非平台对象)。见 `docs/legion-v2-contract.md` REV-29。
|
|
121
|
+
|
|
122
|
+
- **长等待中收到子 agent 的邮件会立刻返回,而不是等这一轮结束 (#384).** `legion_mail → root` 走宿主的
|
|
123
|
+
followUp,而宿主只在一轮「本来要停」时才取走它——于是根会话只要在 `subagent({action:"wait"})` 里,邮件就跟着
|
|
124
|
+
一起排队(实测一封冻结契约的邮件等了 17.3 分钟)。现在 `wait`/`collect` 在调用者是 legion 节点时每 250ms 看
|
|
125
|
+
一眼自己的收件箱:出现 `PENDING` 邮件就立即返回,正文就是那封邮件(`formatMail` 原文,多封按 id 升序、最多
|
|
126
|
+
100 封)加一行状态说明(`wait`:`Still running: <name>, …`;`collect`:提示处理完再 collect 一次),
|
|
127
|
+
`details.state = "mail"`。返回是**普通工具结果**而不是错误/中断,模型读完邮件继续干活。
|
|
128
|
+
邮件只被取走一次:**发送前先在账本上认领**(§3.1 的 `delivered_at` 租约,postman 与抢占方用同一个条件更新,
|
|
129
|
+
输的一方跳过自己的发送),以及**阻塞调用期间该节点的 postman 停手**(§1.1,邮件留在 PENDING 供抢占);
|
|
130
|
+
账本仍是 postman 同一套状态(`PENDING → ACCEPTED`,不新增事件、不改 schema)。子节点状态与进程完全不受影响(只返回,不 retire/abort);Esc/父轮结束仍是原来的
|
|
131
|
+
`interrupted (parent turn ended; children still running)`,谁先到谁赢。没有邮件时 `wait`/`collect` 的结果与今天
|
|
132
|
+
逐字节一致。契约:`docs/contracts/wait-mail-preempt-v1.md`。
|
|
133
|
+
|
|
134
|
+
- **`/legion` 两个画布面板的帧内容器收成了一个 `GalaxyScratch` 单例 (#369).** 对维护者:`panels.ts`
|
|
135
|
+
的 26 个模块级可变容器(braille 网格及其尺寸键、19 个 `galaxy*`、4 个 chart 容器)不再散在文件里、
|
|
136
|
+
也不再各自在函数中途清零——闸门之后的第一条语句就是唯一入口 `begin()`,它一次性清空全部带长度/
|
|
137
|
+
计数器的容器(`clear()`/`length = 0`/计数归零,不分配、身份跨帧保持);七个按计数器寻址的索引数组
|
|
138
|
+
只归零计数器,截断它们反而会分配(契约 §1.2),于是「这一帧此刻什么是干净的」只有一个答案。同时两个面板都加了不可重入闸门(嵌套渲染抛 `viz: <fn> is not reentrant`,以前这条
|
|
139
|
+
约束只写在注释里)。用户无感知:可见输出逐字节不变,整帧分配不回退(bench 10 全绿)。
|
|
140
|
+
契约:`docs/contracts/galaxy-scratch-v1.md`。
|
|
141
|
+
|
|
142
|
+
- **退出/恢复会话时不再打印 `Extension error (/plugin/index.ts): … ctx is stale` (#377).** 宿主在会话销毁后
|
|
143
|
+
仍会派发 `turn_end` / `agent_settled`,扩展入口这时读 ctx 会抛。现在把这种"ctx 已作废"当成「没有 UI」处理
|
|
144
|
+
(与状态栏一直以来的做法一致):缓存清空、安静返回;其它异常照旧抛出。用户感知:每次退出会话少一行红色噪音。
|
|
145
|
+
|
|
146
|
+
- **没声明状态机的队伍「全员失败」时不再静默消失 (#372).** #371 之后,没声明 `phases` 的 centurion 出生时
|
|
147
|
+
`nodes.phase` 为空,而「全队都失败」的兜底逻辑开头就写着「没有相位就没什么可判的」——于是这种队伍把 N 个
|
|
148
|
+
worker 全派失败后,账本里没有失败、没有事件、父级什么也收不到,只能等 lead 自己写 verdict。判据改成
|
|
149
|
+
「这是一支有子节点的队伍」:有相位时行为逐字不变(队伍自己的机器判 `failed` 并记 `phase_change`);
|
|
150
|
+
没有相位时直接写 `status → failed` 并记一条 `node_failed`(既有事件类型,闭集仍是 16 条),父级收到同样的
|
|
151
|
+
通知,同一节点只通知一次。**取舍**:`status = failed` 让 `isTerminalNode` 为真,全队失败的队伍因此不再能被
|
|
152
|
+
恢复——pre-#371 的 `phase → failed` 本来也是不可恢复的,两种写法现在给出同一个结论。**判决是硬的**:lead 那一轮
|
|
153
|
+
仍会正常结束,收队 drain 不再把 `failed` 改写成 `settled`(以前会:账本里留下「`node_failed` 事件 + `settled` 行」
|
|
154
|
+
的自相矛盾,队伍还会重新变成可恢复)。只有「全队失败」这类 supervisor 判决受保护,普通子节点的 drain 行为逐字不变。
|
|
155
|
+
**已经结束的队伍一律不再判定**:`retired`(会话退出时的暂停)或已经结束(`settled`)的队伍,**不管有没有声明
|
|
156
|
+
相位机器**,都不会被本路径判失败——相位原样保留、不发通知、父级也不会多收一条「Team failed」,因此仍可恢复
|
|
157
|
+
(`legion-session-continuity-v1.md` §3)。root 行同样不受影响(它不是队伍;把 root 判失败会让寻址到 root
|
|
158
|
+
的邮件全部退回)。
|
|
159
|
+
|
|
160
|
+
- **内联声明的相位机器现在也显示在 `/legion` 树里 (#372).** 面板取机器只有「按角色查角色卡」一条路,而 #371
|
|
161
|
+
允许 root 在派发时内联声明 `phases`——这种队伍根本没有卡,配置得最明确的队伍反而看不见自己的相位。
|
|
162
|
+
现在面板先按**节点**问一次(来源是 launch 写下的 `ChildRecord.phases`,跨进程、重开会话后都还在),查不到
|
|
163
|
+
再退回按角色查卡,优先级与契约一致(内联 > 卡片 > 无)。查询按「**派发这个节点的那本 run**」定位,不是
|
|
164
|
+
「谁先命中」——同一个 checkout 里每个 run 都有个 `root.team`,否则旧 run 的声明会画到今天的节点上;实在无法
|
|
165
|
+
消歧时按本仓惯例当作「没有」而不是猜。只有队伍根节点会被问(其它角色不可能带声明),且每轮渲染每个队伍只问
|
|
166
|
+
一次,所以渲染一整队 worker 不会变成每节点一次注册表读取。渲染形式与卡片声明完全相同:不新增列、不改布局;
|
|
167
|
+
没声明的队伍依旧什么都不显示。
|
|
168
|
+
|
|
169
|
+
- **同一个 session 重开时会接回上次的树,并且只“提示”,不自动动任何子 agent (#374).** 以前每次启动都新建一份 run
|
|
170
|
+
和一本 `legion.db`,上次被打断的队伍既看不见也接不回来。现在 run 记录自己这个 session 的 transcript 路径
|
|
171
|
+
(`RunRecord.sessionFile`),同 session 再启动时复用它——同一个 runId、同一本账本、节点与相位原样还在
|
|
172
|
+
(`legion_tree` 看得到),root 行是更新而不是重建。**恢复必须显式**:启动时只在 system prompt 里列出
|
|
173
|
+
「上次退出时被打断的子 agent」并给用户一行提示,`spawn` 零次;用户同意后再 `subagent({action:"resume", name})`。
|
|
174
|
+
**换一个 session 启动则什么都不做**:不扫描、不提示、不写任何旧文件。
|
|
175
|
+
|
|
176
|
+
- **退出 = 暂停,不再判负 (#374).** `session_shutdown` 收队时不再把相位推到 `aborted`(终态会被
|
|
177
|
+
`CHILD_TERMINAL` 永久拒绝恢复),只把节点标成 `retired` 并记一条带 `reason: "session-shutdown"` 的
|
|
178
|
+
`node_retired`(事件类型仍是 16 条)。相位、`phase_since`、`rework_count` 一律不动。其它收队原因
|
|
179
|
+
(`stop`/`abort`/超预算/显式 retire)行为逐字不变。
|
|
180
|
+
|
|
181
|
+
- **`subagent resume` 能把声明过的相位机器一起带起来 (#374).** 声明式机器现在快照进 `ChildRecord.phases`,
|
|
182
|
+
跨进程恢复时重新下发给子进程的 `PI_LEGION_PHASES`——否则复活的内联队伍会退回角色卡机器,
|
|
183
|
+
自定义相位名全变 `invalid_phase`。
|
|
184
|
+
|
|
185
|
+
- **空 `legion-verify` 块不再被报告成"验证通过" (#371).** 契约里没有 ```legion-verify 块时仍然直通
|
|
186
|
+
(§7.3 的只读队形状,行为不变),但工具返回、收队提示与后台收尾现在都说
|
|
187
|
+
`by contract (0 commands)`;若声明的机器把 `verification_passed` 当收尾门,还会明说
|
|
188
|
+
「门是被空契约满足的」。以前这三种输出都写着 `Verification passed`,父级无法分辨
|
|
189
|
+
"跑过了" 和 "什么都没跑"。
|
|
190
|
+
|
|
191
|
+
- **`centurion` 的状态机改成 root 派发时声明 (#371).** 以前「角色叫 centurion」就等于强制走五阶段:
|
|
192
|
+
哪怕只改一行文件也要走契约 → 派 worker → 派 reviewer → 跑 verify → squash MR。现在只有
|
|
193
|
+
`subagent({ agent: "centurion", phases: [...] })` 显式声明了才走状态机(声明了 = 以前的行为一字不改,
|
|
194
|
+
含 `done` 后注入的收队提示);**没声明就是一次普通派发**——`nodes.phase` 为空、工具里没有
|
|
195
|
+
`legion_phase`、也没有收队提示,干完自己的活交 verdict 即结束。小活请直接派 worker。`phases` 只能由
|
|
196
|
+
root 声明、只能给 centurion,且必须能从 start 走到某个终态,否则在创建 worktree 之前就被拒。
|
|
197
|
+
同时把「非 root 不得派 centurion」写进契约(`docs/legion-v2-contract.md` REV-27,子契约
|
|
198
|
+
`docs/contracts/centurion-declared-phases-v1.md`),`agents/centurion.md` 的验收条目从 `required` 改为
|
|
199
|
+
`optional`(它从来没有可跑的验证命令)。worktree/rpc 耦合、深度公式、组员不隔离均不变。
|
|
200
|
+
|
|
201
|
+
- **`/legion` 整帧分配降到 ≤2.0MB(−59% @120×40,−67% @200×60),且可见输出逐字节不变 (#364).** #362 已经修掉「每写一格分配一个 `Cell`」,这一轮清掉的是**每帧重建的
|
|
202
|
+
脚手架**:星系像素缓冲/帧内 Map、行字符缓冲、图表 band 栈、`new Canvas(W,H)`,以及 `Math.hypot`、
|
|
203
|
+
`{...cell, color}` 克隆、每帧 `text.trim()` 这类每格/每 run 的临时对象。现在这些都原地复用,
|
|
204
|
+
role 色表由 `set()` 增量维护、调色板按 **(theme, 该帧 role 集合)** 缓存,`renderLegionView` 接受一个
|
|
205
|
+
可选 canvas 让 `createLegionViewFactory` 跨帧复用(`new Canvas` 语义与成本对所有其它调用方不变;
|
|
206
|
+
`chartWindow` 的 earliest 推导也一并去掉了 `filter`/`map`/`spread`,语义等价)。
|
|
207
|
+
实测(同一脚本、200 节点、真 `Theme(dark)`、`--expose-gc`、单帧 `heapUsed` 差值 20 次中位):
|
|
208
|
+
整帧分配 **2586.9 → 1036–1053 KB**(120×40/live,−59%)、**3963.9 → 1309–1342 KB**(200×60/live,−67%);
|
|
209
|
+
时间仍在契约门禁内(median/p95 ≤4.0/≤7.0ms @120×40,≤7.0/≤10.0ms @200×60)。
|
|
210
|
+
**可见输出零变化**:4 形状(120×40 / 200×60 / 80×24 / 46×60)× `toPlain`/`toLines(真 dark)` 的 sha256、
|
|
211
|
+
以及逐格 `(ch, color, bold, inverse)` 的 sha256,都与改动前捕获的 golden 逐字节相同;新增的**内联 golden**
|
|
212
|
+
补上了仓里原先没有的快照空档(旧用例都是"同代码渲染两次再比",两侧同时漂移抓不住)。
|
|
213
|
+
基准:`bench/perf-core/10-legion-frame-alloc.ts`(+ `10-baseline.json` digest golden);
|
|
214
|
+
契约:`docs/contracts/viz-frame-alloc-v1.md`。
|
|
215
|
+
|
|
216
|
+
- **`/legion` 从全屏 overlay 改成主屏挂载,整帧合成开销消失(120×40 4.39ms → 1.69ms,200×60 9.59ms → 3.39ms)(#347).**
|
|
217
|
+
页面一直以 `overlay: true` + `width:"100%"` 打开,于是 pi-tui 对**每一行**都跑 `compositeTuiLine`
|
|
218
|
+
(`extractSegments` + `sliceWithWidth` + `visibleWidth` 三次 `Intl.Segmenter` 字素扫描,行宽缓存按整行
|
|
219
|
+
key 而实时帧永不重复)。`/legion` 是这条路径的最坏输入:非空格里 56%–68% 是 braille/box/块字形,
|
|
220
|
+
没有一行纯 ASCII,非 ASCII 行单价是 ASCII 的 3.67×,实测占整帧 57%–70%。
|
|
221
|
+
现在改成 `ctx.ui.custom(factory, { overlay: false })`(与经典 `--tree` 视图同一个挂载方式):
|
|
222
|
+
页面**替换** base 组件而不是叠在它上面,宿主的 overlay 合成层完全不参与,页面只付自己那一次
|
|
223
|
+
`render()`。实测(真 `TuiMainScreen` + `FakeTerminal`,`written[]` 每帧清空,50 帧 × 3 轮):
|
|
224
|
+
页面帧 median 2.49 → **0.96**(80×24)、4.41 → **1.77**(120×40)、8.63 → **3.35ms**(200×60),
|
|
225
|
+
p95 5.30/17.64/41.51 → **1.25/2.85/4.67ms**,每帧字节 −3.1% / −2.5% / −1.8%(视觉内容等价)。
|
|
226
|
+
**用户可见行为不变**:`Esc`/`q` 仍然回到原来的会话视图(宿主把打开时换掉的 editor 挂回来);
|
|
227
|
+
页面继续按 `terminal.rows` 自己定高(非 overlay 的自定义组件是布局叶子,宿主不分配视口高度,
|
|
228
|
+
与 `createTreeViewFactory` 同源);`/legion` 内打开的详情浮层仍是 75% 浮层
|
|
229
|
+
(`MODAL_MAX_WIDTH_RATIO` / 65% 高 / `resolveModalOverlayOptions()` 一个像素没动),resize 语义原样。
|
|
230
|
+
`paintPageFloor` **保留且仍然必需**:主屏挂载后详情浮层依旧是 overlay,合成器依旧按行取 base 行尾的
|
|
231
|
+
SGR 快照,实测去掉 floor 后两种挂载的 gutter 都退回终端默认背景(#308 黑带重现),
|
|
232
|
+
所以它不是「保险」。不改宿主 `compositeTuiLine`、不改 pi-tui 节流常量。
|
|
233
|
+
基准:`bench/perf-core/09-legion-mount.ts`;契约:`docs/contracts/legion-main-screen-mount-v1.md`。
|
|
234
|
+
|
|
235
|
+
- **`/legion` 不再每写一个格子就分配一个 `Cell`:`Canvas` 预建 W×H 个可变 cell,`set()` 原地改字段 (#362).**
|
|
236
|
+
`set()` 原本是 `row[x] = { ch, color, ...style }`——一帧要写 4,583 次(120×40)/ 9,691 次(200×60),
|
|
237
|
+
实测 52.4 B/次 与 128.0 B/次。现在格子在构造时一次性建好,写入只做 `cell.ch / color / bold / inverse`
|
|
238
|
+
的显式字段赋值(无对象展开、默认 style 用共享常量),`sanitizeChar` 也按字符记忆化
|
|
239
|
+
(`String.fromCodePoint` 对 Latin-1 以外的字形每次都新建字符串,图表/星图画 `█ ⣿ ─` 上万次)。
|
|
240
|
+
同一脚本前后实测:每次 `set` 分配 **52.4 → 0.03 B**(120×40)/ **128.0 → 0.002 B**(200×60);
|
|
241
|
+
整帧分配 **2839.9 → 2551.8 KB**(120×40/live,−10%)、**4556.8 → 4018.2 KB**(200×60/live,−12%);
|
|
242
|
+
帧 median **4.53 → 3.87 ms**(120×40/live,−15%)、**6.95 → 5.73 ms**(200×60/live,−18%),
|
|
243
|
+
p95 −12% / −13%(5 轮 × 50 帧前后交叉,各 1250 样本)。预填本身也顺带变小
|
|
244
|
+
(269.3 → 190.0 KB / 616.1 → 472.4 KB,53–57 → 39–41 B/格)。
|
|
245
|
+
**`/legion` 可见输出逐字节不变**:4 个场景(120×40 / 200×60 × live / settled)的 plain 与 ANSI
|
|
246
|
+
摘要前后一致,`legion-viz` 快照用例原样绿。对外语义只有一条要说清:`get()` / `cells` 是**活视图**
|
|
247
|
+
(下一次 `set()` 会改同一个对象),不是快照——两个既有读点都只读 `ch` 即弃。
|
|
248
|
+
另:`drawChart` 的 `(from, to)` 直接交给 `computeStats`,一帧只调一次 `chartWindow`。
|
|
249
|
+
整帧分配离 #346 的 ≤2.0MB 仍差 0.5–2.0MB:残项已逐项隔离(`drawGalaxy` ≈0.93–1.23MB、
|
|
250
|
+
`toLines` ≈0.44–1.01MB、`drawChart` ≈0.38–0.62MB、`Canvas` 预填 0.19–0.47MB),
|
|
251
|
+
见契约 §3.4 / §4 与 follow-up #364。
|
|
252
|
+
|
|
253
|
+
- **`/legion` 的 tok/s 图表一帧只分桶一次,且只算本帧真的要画的 series (#346).** `drawChart` 先给图体
|
|
254
|
+
分桶、`computeStats` 又用**完全相同的参数**再分一遍,200 节点下一帧白烧 ≈1.2ms 与 ≈0.6MB 垃圾;
|
|
255
|
+
同时 `RateSampler.buckets()` 会遍历**每一个曾经记录过**的 series(`series` 永无删除点),
|
|
256
|
+
结束的 child 每帧照算。现在:`chartBuckets(data, count, window?)` 是唯一入口,结果以第三个参数
|
|
257
|
+
传给 `computeStats(data, buckets, series?)`(不传时退回老签名,语义逐字节一致);
|
|
258
|
+
`buckets()` 接受 `BucketOptions.onlyNodeIds`,只遍历 `visibleRateNodeIds(data.nodes)`——settled 行
|
|
259
|
+
只要还在画就照旧贡献,不在帧里的 series 保留原始采样但不参与本帧计算;同时不再为每个 series
|
|
260
|
+
新建 `plotW` 长度的临时数组,并对「`changedAt` 早于首桶窗口左沿一个桶窗口」的 series 直接跳过
|
|
261
|
+
(`interpolate` 两端夹取 ⇒ 每桶 Δ 恒为 0,可证明为同一结果)。
|
|
262
|
+
`changedAt` 是 `record()` 里值真的变化时才更新的时间戳:上一版用的是**最后一个采样点**,而
|
|
263
|
+
flat-run collapse 每帧都会把 settled 行的最后一个采样点推到 `now`,所以那条跳过在生产形状下
|
|
264
|
+
一次都没触发(实测 200 series / 190 settled ⇒ walked 200, skipped 0)。图表的 `avg` 也跟同一份
|
|
265
|
+
可见集走:不可见 series 不再把它抬起来(实测 40 个已结束 ghost 把 300 tok/s 抬到 1.5k)。
|
|
266
|
+
实测(120×40 / 200 节点,真 `Theme(dark)`,50 帧 × 3 轮):帧 median 6.5ms → 4.4ms(−32%)、
|
|
267
|
+
p95 9.1ms → 4.8ms(−40%)、`drawChart` 2.65ms → 0.6ms(−77%)、`buckets()` 1.24ms → 0.47ms;
|
|
268
|
+
帧分配 4214KB → 3306KB、`drawChart` 1432KB → 530KB。尾部 60s 窗口形状下 190 个 settled series
|
|
269
|
+
现在真的被跳过(walked 200 → 10,`buckets()` 0.89ms → 0.10ms);**整轮窗口下它们仍在窗内、
|
|
270
|
+
必须照画**——「结束的 child 不再每帧收税」这句上一版契约的话是错的,已在契约 R2 如实改写。
|
|
271
|
+
图表外观与 `/legion` 其余输出不变。契约:`docs/contracts/legion-chart-frame-buckets-v1.md`。
|
|
272
|
+
|
|
273
|
+
- **`finished` 完成结果缓存有了有界淘汰:每条 ≤8KB、合计 ≤1MB 驻留 output 文本,淘汰只丢文本、留结果 (#349).**
|
|
274
|
+
这个 one-round 缓存原本为每个完成过的 child 留一份**输出全文**且永不淘汰——实测 50 child ×
|
|
275
|
+
204,800B ⇒ GC 后驻留 10.07MB,长会话按 child 数单调增长。现在按 LRU:单条驻留 ≤8KB
|
|
276
|
+
(`FINISHED_CACHE_MAX_ENTRY_BYTES`)、合计 ≤1MB(`FINISHED_CACHE_MAX_OUTPUT_BYTES`)、
|
|
277
|
+
条目数 ≤128(=1MB/8KB,`FINISHED_CACHE_MAX_ENTRIES`),超出的条目剥成 4000 字符预览,
|
|
278
|
+
但**条目本身与 `runId`/`round`/`execution`/`acceptance`/`outputFile` 全部保留**——删条目会把
|
|
279
|
+
`collect`/`wait` 变成 `missing`(结果丢失)并抹掉本轮的 claim 键。命中时从
|
|
280
|
+
`snapshot.outputFile` 重新读回全文,因此 `wait`/`collect` 在淘汰前后返回**同样的结果**
|
|
281
|
+
(文本等价到 `output.trim()`)。缓存条目是**快照的浅拷贝**:淘汰只动缓存自己的那份,
|
|
282
|
+
已经交给调用方(以及 `join.onTerminal` 读的同一个对象)的结果永远不会被事后改写;
|
|
283
|
+
上一版存的是引用,插入时就把 >4MB 的输出原地剥成了 4000 字头(首次 `wait` 拿不到全文)。
|
|
284
|
+
没有 `outputFile` 的条目(宿主自备 collect、写盘失败)是 **pinned**,按契约不淘汰,字节数由
|
|
285
|
+
`noticeQueueStats().finished.pinnedBytes` 报出(新增 `finished` 计数块:`entries` /
|
|
286
|
+
`residentBytes` / `stripped` / `pinnedBytes`);盘上文件被删时降级为「4000 字符头 + 元数据 +
|
|
287
|
+
`CollectSnapshot.outputTruncated: true`」,永远不是 `missing`。实测:50 child × 204,800B 驻留
|
|
288
|
+
10.07MB → **0.51MB**(满足 #349 的 ≤1.0MB),重新 track 全部后回落到基线。
|
|
289
|
+
契约:`docs/contracts/finished-cache-eviction-v1.md`。
|
|
290
|
+
|
|
291
|
+
- **A 500ms status refresh no longer re-parses a session file that has not been written since the
|
|
292
|
+
last tick (#348).** `refreshUi()` → `statusEntries()` parsed every child's session file — plus every
|
|
293
|
+
historical ledger row that carries one — once per tick, changed or not: one idle child cost 119
|
|
294
|
+
opens per 60s, 50 children 6000. That count grows with the *length* of a session rather than with
|
|
295
|
+
the number of live agents — the reason the issue suspected the 64-entry incremental-parse cap of
|
|
296
|
+
thrashing every tick. `parseSessionFileCached` now sits in front of the parser: one `statSync`
|
|
297
|
+
compares `(path, size, mtimeMs)` and an unchanged file is answered from the previous parse instead
|
|
298
|
+
of opening it at all. A write from another process is still seen — an append grows `size`, a
|
|
299
|
+
rewrite moves `mtimeMs` even at identical length — so no fs watcher and no bus subscription is
|
|
300
|
+
involved, the one exception being a same-millisecond rewrite that keeps the byte length (see the
|
|
301
|
+
function's doc: the parser's own head/boundary fingerprint would catch it, but only by opening the
|
|
302
|
+
file, which is the fixed cost this gate exists to remove; it resolves on the next write). The
|
|
303
|
+
display readers go through the gate (`progressFromSessionFile`, which every status row calls;
|
|
304
|
+
`/legion summary`; `/legion timing`), while every path that has to *decide* something about a file
|
|
305
|
+
keeps calling `parseSessionFile` on the file it is judging: verdict derivation (`orchestrator`,
|
|
306
|
+
`rebuild`, `legion_*` tools) and the postman's DELIVERED evidence scan — that one moves a message
|
|
307
|
+
to DELIVERED and can discard a notice as obsolete, and the frozen criterion names the real parse,
|
|
308
|
+
so it is **deliberately kept** off the gate (it also only parses a seam whose mailbox page is
|
|
309
|
+
non-empty, so a seam with nothing pending parses nothing). None of the parsing semantics moved.
|
|
310
|
+
A file that cannot be *read* (`EACCES`/`EMFILE`/`EIO`) is never cached: `parseSessionFile` answers
|
|
311
|
+
that the same way it answers an empty file, and a settled session file never moves
|
|
312
|
+
`(size, mtimeMs)` again — `chmod` only bumps `ctime` — so caching that empty value would have
|
|
313
|
+
pinned `/legion summary` and the status rows at zero for the rest of the process. Both caches are
|
|
314
|
+
bounded: the file-level cache is capped at 256 entries, LRU-ordered on every hit, so the files a
|
|
315
|
+
tick actually visits are the ones that stay; the incremental cap rises 64 → 256 as headroom for a
|
|
316
|
+
child that appends on every tick, **not** as the source of this change's win (that cache's own
|
|
317
|
+
`misses` counter read 0 throughout the windows below, so it was never the cost being paid).
|
|
318
|
+
`sessionFileCacheStats()` (`hits`/`misses`/`size`/`statCalls`) exists for tests and is never
|
|
319
|
+
rendered.
|
|
320
|
+
|
|
321
|
+
Measured with the issue's own script (`b3.ts` harness, real 60s/20s windows): 1 idle child 119 → 0
|
|
322
|
+
opens, 50 children 6000 → 0, 100 session files 4000 → 0, and the only syscall left per unchanged
|
|
323
|
+
file is the `stat`. Two caveats on those zeros. First, they are window figures: the window starts
|
|
324
|
+
after the seed parse, so the steady-state number is **one parse per session file per process**
|
|
325
|
+
(plus one per write), not zero — a window that opens on the first tick does pay for it. Second, the
|
|
326
|
+
CPU side of that table (0.34% → 0.10%, 3.64% → 0.31%, 16.20% → 0.63%, `process.cpuUsage()` over the
|
|
327
|
+
same windows) was sampled on runs whose loadavg differed, so it shows direction, not an isolated
|
|
328
|
+
cost. The microsecond figure is the gate's own micro-benchmark — open + fstat + two 256B fingerprint
|
|
329
|
+
reads + close, 36–66µs warm, timed in isolation by the issue — and must not be subtracted from the
|
|
330
|
+
logged per-parse cost: that log shows a whole parse at ≈1.19ms per parse in one run and ≈0.78ms per
|
|
331
|
+
parse in the other, i.e. the per-parse figure itself moved between the two runs.
|
|
332
|
+
|
|
333
|
+
- **A control call no longer scans the run registry to find a child it is already scoped to, and the
|
|
334
|
+
scan now keeps a snapshot when it does run (#344).** Every `collect` / `status` / `steer` / `abort` /
|
|
335
|
+
`retire` / `continue` opened with `store.listRuns()` — `readdir` plus one `readFileSync` +
|
|
336
|
+
`JSON.parse` per run — *before* it even asked which run it was scoped to, so a call that already
|
|
337
|
+
knew its `runId` (the explicit argument, the session's legion run, or the run the runtime tracks
|
|
338
|
+
the name under) paid for every run ever written: 26ms at the 352 runs on the reporting machine,
|
|
339
|
+
97% of a warm `status`, 110ms at 1000 synthetic runs, linear and unbounded apart from the 14-day
|
|
340
|
+
retention prune. The scan is now a FALLBACK: the scope run is read with one `readRun` (0.05ms) and
|
|
341
|
+
the registry is consulted only when the name might live in another run — which is exactly the
|
|
342
|
+
#312 case that must report or refuse instead of adopting. On this repo's 365-run corpus one name
|
|
343
|
+
resolution went 18.4ms → 0.04ms, and on 1000 synthetic runs 31.5ms → 0.04ms, with **zero**
|
|
344
|
+
`listRuns` calls for a scoped hit. The fallback scan itself is cached per runs directory: a
|
|
345
|
+
process-wide snapshot (shared by every `RunStore` instance on that root, since the tool surface
|
|
346
|
+
builds one per call) invalidated by the directory's `mtimeMs`, by any write this process performs
|
|
347
|
+
(`createRun` / `updateRun` / `updateChild` — all funnel through `writeRun` — plus `prune`'s explicit invalidation), and, for a
|
|
348
|
+
record another process rewrote in place with no directory mtime to show for it, by a 1s TTL that
|
|
349
|
+
tests drive through the store's injectable clock. Cold scans are unchanged (21.2ms at 365 real
|
|
350
|
+
runs, 34ms at 1000 synthetic on both sides of this change); a repeat scan is 0.018ms / 0.012ms.
|
|
351
|
+
`listRuns()` still answers exactly what it did — every readable run, oldest first, corrupt entries
|
|
352
|
+
skipped — and every #312 strict-scope case (`collect`/`continue`/`resume` refusing a same-named
|
|
353
|
+
node elsewhere, ambiguity never resolved by preference) is pinned by a parity test that compares
|
|
354
|
+
the shortcut against the full scan shape by shape.
|
|
355
|
+
|
|
356
|
+
- **A launch that cannot use a worktree no longer runs the nested-worktree `git` probe whose answer is
|
|
357
|
+
then discarded (#345).** `isInsideChildWorktree(cwd)` — one synchronous `git rev-parse
|
|
358
|
+
--git-common-dir`, ~5–7ms — was an *argument* to `resolveLaunchWorktree`, so it was evaluated before
|
|
359
|
+
the resolver could short-circuit, and for every non-centurion its value was then thrown
|
|
360
|
+
away: the worktree guard forces `worktree: false` for those roles whatever the probe said (all 18
|
|
361
|
+
combinations of `roleDefault × launch × parentInWorktree`, now a table-driven test). The probe is
|
|
362
|
+
asked on the centurion branch only — the one branch it can change — so a worker launch
|
|
363
|
+
drops from 1 `git` subprocess to 0 and from 11.6ms to 2.9ms median on this repo, while a
|
|
364
|
+
centurion launch still spawns the same two git commands (probe not reached: legion
|
|
365
|
+
forces its worktree, `worktree add` still runs). `resolveLaunchWorktree`'s result semantics are
|
|
366
|
+
untouched; the one observable difference is that a role card pinning `worktree: true` while the
|
|
367
|
+
parent sits in a linked worktree now also gets the existing one-time `worktree denied for
|
|
368
|
+
non-centurion role` warning, because the suppression that used to silence it is the
|
|
369
|
+
answer being skipped.
|
|
370
|
+
|
|
371
|
+
- **`messages.delivered` is a five-value domain now — `pending / accepted / bounced / delivered / obsolete`
|
|
372
|
+
— and `LEGION_DB_USER_VERSION` moves 1 → 2 (#322).** The old `INJECTED(1)` claimed a delivery the ledger
|
|
373
|
+
never observed: it was written the moment a send call returned, which covers `queued` (waiting for the
|
|
374
|
+
child's next turn boundary), `accepted` (a turn started) and `intercepted` (an input handler ate the text
|
|
375
|
+
and the child never saw a word of it) alike, so a parent had no queryable signal to tell them apart.
|
|
376
|
+
`ACCEPTED(1)` now says only "the send call returned", `DELIVERED(3)` is written later from evidence, and
|
|
377
|
+
`intercepted` bounces instead of pretending. The migration adds **no DDL** — the column was already
|
|
378
|
+
`INTEGER NOT NULL DEFAULT 0`, so v2 is a semantic agreement plus a version bump, and every v1 `1` reads as
|
|
379
|
+
`accepted` with no backfill. `MAIL_DELIVERY.INJECTED` survives as a `@deprecated` alias of `ACCEPTED`
|
|
380
|
+
so out-of-tree imports keep compiling; the ledger's own call sites use the new names, and exactly one
|
|
381
|
+
unit test keeps the alias pinned (`INJECTED === ACCEPTED`) instead of letting it rot silently. Events are
|
|
382
|
+
unchanged on purpose (the closed set stays at 16): `ACCEPTED` reuses the `mail_sent` row `sendMail`
|
|
383
|
+
already wrote, `DELIVERED` reuses `mail_delivered`, `OBSOLETE` reuses `mail_bounced` with
|
|
384
|
+
`reason: "obsolete_after_phase_change"`. A read-only `mailboxStats(db, nodeId)` exports the five counts;
|
|
385
|
+
nothing renders it yet — D5 asks for the export and its unit test, not for a caller. Contract: `docs/contracts/mailbox-delivery-v1.md`,
|
|
386
|
+
`docs/legion-v2-contract.md` §8.3.1/§8.5 (REV-23).
|
|
387
|
+
|
|
388
|
+
- **`SessionRuntime.consumeCollect*` now answers with a `ClaimedCollect`, not a bare snapshot
|
|
389
|
+
(#320/#327).** The claim-once seam claims a terminal result as it hands the snapshot out, and
|
|
390
|
+
the caller has to be able to hand that claim back when it bails — which requires knowing
|
|
391
|
+
whether *this* call took it (`src/api.ts` re-exports `SessionRuntime`, so this is a public
|
|
392
|
+
surface change, and it is a breaking one):
|
|
393
|
+
```ts
|
|
394
|
+
const { snapshot } = await rt.consumeCollect("worker-0")!; // was: const snapshot = await …
|
|
395
|
+
```
|
|
396
|
+
`claimedNow` is `true` only for the call that took the round's claim; a caller that delivered
|
|
397
|
+
the text to nobody rolls it back with `unclaimUndelivered(name, snapshot, claimedNow)`, and a
|
|
398
|
+
call that did not take the claim leaves the real delivery's claim alone. **This breaks
|
|
399
|
+
`zzjcool`/api consumers**: the bare-snapshot shape *did* ship — v4.0.0 and v5.0.1 both exported
|
|
400
|
+
`consumeCollect(name): Promise<CollectSnapshot> | undefined`, and `src/api.ts` calls that surface
|
|
401
|
+
"safe to depend on" — so the release that carries this change must be a **major**. The migration
|
|
402
|
+
is the one line above; no compatibility shim is provided (no overload, no `snapshotOnly`
|
|
403
|
+
switch), because a second shape would keep the claim/copy ambiguity alive for everyone. Contract
|
|
404
|
+
revision: `docs/contracts/notice-claim-v1.md` (D2).
|
|
405
|
+
|
|
406
|
+
- **`AbortOutcome` now carries the bound the abort really applied (`timeoutMs`), and the timeout
|
|
407
|
+
receipt reads it instead of a default of its own (#325).** `abortReceipt` used to format a
|
|
408
|
+
`timeoutMs = DEFAULT_ABORT_TIMEOUT_MS` parameter that nothing ever passed — the tool surface
|
|
409
|
+
deliberately takes no bound (it is a `wait`-only parameter) — so the receipt could only ever say
|
|
410
|
+
"5s", even for a caller that armed a different limit on the supervisor. Both backends now report
|
|
411
|
+
the normalized, timer-capped bound in the outcome for all three statuses, and the receipt names
|
|
412
|
+
that number. The receipt text on the tool surface is byte-identical (no bound is ever passed
|
|
413
|
+
there); the two private `race*Timeout` helpers the backends each carried are one shared
|
|
414
|
+
`raceWithTimeout` (`src/supervisor/race-timeout.ts`) with the timer cleanup, loser-rejection
|
|
415
|
+
swallow and timer-delay cap unchanged, and the in-process pre-abort queue clear now runs inside
|
|
416
|
+
the child's own identity scope (`runAtAbsoluteDepth` + `runInChildContext`), like the interrupt
|
|
417
|
+
it precedes. Contract revision: `docs/contracts/abort-soft-interrupt-v1.md` (D2/D7).
|
|
418
|
+
|
|
419
|
+
### Fixed
|
|
420
|
+
|
|
421
|
+
- **退出会话时不再出现 `Extension error (<boundary>): turn_end could not resolve the persisted
|
|
422
|
+
assistant entry ID` (#383)。** 这是 pi 宿主在 `turn_end` 边界上的既有缺陷(`dispose()` 之后仍会收尾一个
|
|
423
|
+
被 abort 的 turn,message 没落盘),扩展这边唯一能做的是**不去触发那个边界**:宿主只在
|
|
424
|
+
`hasHandlers("turn_end")` 为真时才走这条路径。我们注册的 `turn_end` 只做一次 `bindUi`,而 `bindUi` 已经挂在
|
|
425
|
+
`turn_start`/`agent_start`/`agent_end`/`agent_settled`/`input`/`tool_result` 上,于是直接不注册——宿主连报错
|
|
426
|
+
分支都进不去,UI 行为不变。
|
|
427
|
+
|
|
428
|
+
- **没有事件总线时账本不再漏记事件 (#380)。** 四处写成 `eventBus?.emit(appendEvent(...))`:可选链在没有
|
|
429
|
+
订阅者时会把 `appendEvent(...)` 的参数一起短路,于是 headless 宿主(不传 eventBus 的嵌入方)里相位迁移
|
|
430
|
+
只改了 `nodes.phase`、账本里一条 `phase_change` 都没有,重开账本时历史与真实状态不一致。改成「先写账本、
|
|
431
|
+
再通知订阅者」;交互模式行为不变。
|
|
432
|
+
|
|
433
|
+
- **PR #363 review follow-up: an internal notice can no longer be refused into silence, and a refused
|
|
434
|
+
one is retried.** #353 made `sendMail` throw for a `U+0000`, which is right where the text is the
|
|
435
|
+
model's own (`legion_mail`) and wrong at the three call sites that compose a notice from ledger
|
|
436
|
+
facts, because all three run after the move they describe. `requestLegionPhase` writes
|
|
437
|
+
`updateNode` + `phase_change` and only then calls `notifyParent`, so a `reason`/`note` carrying a
|
|
438
|
+
NUL (model text, and the only place one can exist — a ledger column would have truncated it on
|
|
439
|
+
write) turned an accepted transition — including a declaring centurion's failure notice, the #309
|
|
440
|
+
promise — into a tool error while the team had already moved; `checkOwnPhaseTimeout` wrote its
|
|
441
|
+
`phase_timeout` row (also the de-dup key) BEFORE mailing, and `LegionPostman.start()` swallows
|
|
442
|
+
whatever escapes (`.catch(() => {})`), so a refused notice was lost for the whole stint. The
|
|
443
|
+
refusal now belongs to the tool boundary alone: `legion_mail` answers `toolError(…,
|
|
444
|
+
"MAIL_INVALID_TEXT")` so the code is visible in `details.error` instead of only in a message, while
|
|
445
|
+
internal notices degrade — the NUL characters are dropped and the send retried, and a refusal the
|
|
446
|
+
ledger understands (rate limit, permission) falls back to `sendRootMessage` + a warning — and never
|
|
447
|
+
throw. `checkOwnPhaseTimeout` places the notice before writing the de-dup key, so a notice it could
|
|
448
|
+
not place anywhere leaves the key unwritten and the next tick tries again; 0 keeps its meaning and
|
|
449
|
+
the built-in subjects/clocks are untouched.
|
|
450
|
+
|
|
451
|
+
- **PR #363 review follow-up: a failed revival gives the phase clock back, and the completion gate's
|
|
452
|
+
assumption is pinned.** `Orchestrator.reserveNode` moves `phase_since` on a revive (#357) and
|
|
453
|
+
records the `revived` `phase_change` before the supervisor spawns; a spawn failure rolled the
|
|
454
|
+
reservation back but left the moved clock behind — a stint `checkOwnPhaseTimeout` de-dupes on, for a
|
|
455
|
+
launch that does not exist. `phase_since` now travels with the rest of the rollback. Also: the
|
|
456
|
+
derivation of the success phase follows the ORDER A CARD DECLARES, so "`phases:` order is pipeline
|
|
457
|
+
order" is a load-bearing assumption rather than a style note — a case now pins a shuffled
|
|
458
|
+
declaration moving the gate, and its control pins that a non-final phase cannot collect a team.
|
|
459
|
+
Contracts: `docs/legion-v2-contract.md` REV-26 and
|
|
460
|
+
`docs/contracts/declarative-phase-machine-v5.md`.
|
|
461
|
+
|
|
462
|
+
- **`legion_mail` refuses text containing `U+0000` instead of storing a truncation (#353).** SQLite's
|
|
463
|
+
TEXT binding stops at the first NUL, so a `body`/`subject` carrying one was written as the part
|
|
464
|
+
before it while the send still answered `ok: true` — and `bodyBytes` counted the ORIGINAL text, so a
|
|
465
|
+
message that arrived cut in half looked complete to its sender. There is no lossless encoding that
|
|
466
|
+
keeps "the body" meaning the same thing to every reader, so the send is refused before any byte is
|
|
467
|
+
counted: `sendMail` throws `MailInvalidTextError` (code `MAIL_INVALID_TEXT`, a member of the existing
|
|
468
|
+
`code` vocabulary) naming the field, the character offset of the first NUL, and that NUL is not
|
|
469
|
+
supported. `subject`, `body` and every `to` token are checked; nothing is stored and no `mail_sent`
|
|
470
|
+
event is written. NUL-free text is untouched, and `bodyBytes` keeps describing exactly the row that
|
|
471
|
+
was stored (the invariant the truncation broke).
|
|
472
|
+
|
|
473
|
+
- **A team woken by `continue` starts a new phase stint, so its next timeout is announced again
|
|
474
|
+
(#357).** The `Phase timeout:` notice was already judged `OBSOLETE` at settle time (#333/#354); what
|
|
475
|
+
made the team go permanently silent is that `Orchestrator.reserveNode`'s resume branch only rewrote
|
|
476
|
+
`status`, leaving `phase_since` at the value of the stint that had ALREADY timed out. Since
|
|
477
|
+
`checkOwnPhaseTimeout` dedupes on exactly that value (`hasPhaseTimeoutForSince`), one `phase_timeout`
|
|
478
|
+
per stint meant one for the life of the team: the second stint could run past its advisory clock
|
|
479
|
+
forever and nobody would hear. A revival now advances `phase_since` to the revival clock and records
|
|
480
|
+
a `phase_change` with `from === to` and `reason: "revived"` — the phase does not move (work resumes
|
|
481
|
+
where it was), the event set stays closed (no new type, no new subject prefix, no change to the
|
|
482
|
+
`kind: "notice"` accounting), and a revival that cannot move the clock still dedupes, because the key
|
|
483
|
+
is `phase_since` and not "a revival happened". Contract note:
|
|
484
|
+
`docs/contracts/mailbox-delivery-v1.md` (D4).
|
|
485
|
+
|
|
486
|
+
- **A declared machine's own phase names now reach the verifying gate and the phase clock (#358).**
|
|
487
|
+
The follow-up left by #350: `finalizeVerifiedTeamIfReady` hard-coded `node.phase !== "verifying"` and
|
|
488
|
+
evaluated `verifying → done` with no `machine`, so a workflow the card called `spec → impl → ship`
|
|
489
|
+
could never take the supervisor's verified-completion path (`invalid_phase`, dropped by the
|
|
490
|
+
`!decision.allowed` guard). The completion gate is now the phase of THIS team's machine that judges
|
|
491
|
+
success — an exit to `done`, preferring one gated on `verification_passed`, otherwise the last
|
|
492
|
+
declared candidate, which is `verifying` in the built-in machine — and the machine is forwarded to
|
|
493
|
+
`evaluatePhaseTransition`. A team with no declared machine (or a machine with no exit to `done` at all)
|
|
494
|
+
keeps the historical literal, so the built-in four-phase pipeline is unchanged. The sibling hole is
|
|
495
|
+
the same mistake in the clock: `checkOwnPhaseTimeout` resolved `DEFAULT_PHASE_TIMEOUTS[phase] ?? 0`,
|
|
496
|
+
which for an undeclared phase name is 0, so `PhaseDefinition.timeoutMs` (`machineTimeout`) was dead
|
|
497
|
+
code and a custom phase's advisory clock never armed. The order is now operator setting
|
|
498
|
+
(`legion.phaseTimeouts`) → the machine's `timeoutMs` → the built-in default → 0, and 0 keeps its
|
|
499
|
+
pre-existing meaning of "never warn". **Correction (PR #363 review, major 1):** that order was only
|
|
500
|
+
real for phase names the built-in table had never heard of. `index.ts` handed the postman a
|
|
501
|
+
default-FILLED `legion` object, so `settings.phaseTimeouts[phase] ?? machineTimeout(...)` always had
|
|
502
|
+
a value on the left and a card's `timeoutMs` on `planning`/`implementing`/… was ignored in silence
|
|
503
|
+
(probe: `implementing` 3600000 in settings, 300000 on the card → 3600000 won). The explicitness the
|
|
504
|
+
settings parser already records (`legionExplicitFields`) is now carried through the run-time fill
|
|
505
|
+
(`legionSettingsForRun`), so "operator wins" means "the operator wrote it down". Declaring
|
|
506
|
+
`timeoutMs` on a built-in phase name now really takes effect; an explicit operator setting still
|
|
507
|
+
beats the card, and with neither the four built-in numbers are unchanged. Contract: REV-26.
|
|
508
|
+
|
|
509
|
+
- **A team is now born in the phase its own machine starts in, so a card declaring `phases:` no longer
|
|
510
|
+
strands its team at spawn (#350).** `Orchestrator.reserveNode` wrote a hard-coded `phase: "planning"`
|
|
511
|
+
into every team row, ignoring the machine the card already carried. With a machine whose first phase
|
|
512
|
+
is not `planning` (e.g. `recon -> build`) the team was born in a phase its own machine had never heard
|
|
513
|
+
of, and `evaluatePhaseTransition` refuses every transition out of an unknown `from` as `invalid_phase`
|
|
514
|
+
*before* the target, the outgoing edge or the `failed` escape hatch is consulted — so advancing,
|
|
515
|
+
declaring failure (#309) and aborting were all closed, leaving a team that was alive and spending
|
|
516
|
+
budget while it could not move. The derivation now lives in one function, `phaseStart(machine)`
|
|
517
|
+
(`machine.start ?? machine.phases[0]?.id`), shared by the state machine's entry rule, the birth site
|
|
518
|
+
and the load-time validation (`validatePhaseMachine` no longer keeps a third copy), so the three
|
|
519
|
+
cannot drift; an empty machine falls back to the built-in start (it cannot be declared — the loader
|
|
520
|
+
rejects it). A card that declares no machine keeps the literal `"planning"`, so the built-in
|
|
521
|
+
four-phase pipeline is unchanged. Contract:
|
|
522
|
+
`docs/contracts/declarative-phase-machine-v5.md:107` (that file is `Status: draft`). **Review
|
|
523
|
+
follow-up:** making a declared start reachable also made every supervisor-side net that only speaks
|
|
524
|
+
the built-in vocabulary reachable-and-inert. `failTeamIfAllChildrenFailed` (`legion-tools.ts`)
|
|
525
|
+
evaluated `from: "recon" -> "failed"` for "every child failed" **without the team's own machine**, so
|
|
526
|
+
the transition was `invalid_phase` and the `!decision.allowed` guard dropped the verdict in silence —
|
|
527
|
+
a squad whose children had all died never reached `failed` and its parent never heard; it now forwards
|
|
528
|
+
the machine, and a new case pins the verdict (`phase === "failed"`, one `phase_change`, one mail).
|
|
529
|
+
The abort half of the same hole is #351, which landed upstream in `abortLegionPhase` (already on
|
|
530
|
+
master), so this branch only had to stop duplicating it. The other two custom-phase holes — a hard-coded
|
|
531
|
+
`phase !== "verifying"` completion gate and a phase clock that resolves to 0 for undeclared names —
|
|
532
|
+
are documented in place and tracked as #358 rather than fixed here; this branch closes them (see the
|
|
533
|
+
#358 entry above).
|
|
534
|
+
|
|
535
|
+
- **A `wait` whose result the abort net throws away now hands the completion claim back, so the child's
|
|
536
|
+
finished notice is no longer lost (#352).** Waiting on an already-settled child goes through
|
|
537
|
+
`waitAction`'s finished-cache probe, which claims the round (`claimedNow === true`) — and when the
|
|
538
|
+
parent turn ends inside the wait's `retire` teardown, the tool layer replaces the rendered result with
|
|
539
|
+
the frozen `interrupted (parent turn ended; children still running)` line and nothing was handed back:
|
|
540
|
+
`deliverBatch` filters a claimed round, so that completion notice never reached the parent, silently
|
|
541
|
+
and for the whole round. `waitAction` now carries the claims IT took out as `details.waitClaims` (only
|
|
542
|
+
`claimedNow === true`; `waitOne`'s own `claimReported` is a no-op `false` on this path), and the bail
|
|
543
|
+
gives each one back through `unclaimUndelivered` — never `unclaimReported`, which would also drop the
|
|
544
|
+
queued copy, the only remaining route — exactly as `collectChild` already does on its own bails. The
|
|
545
|
+
frozen `interrupted …` text, the `(runId, name, round)` claim key and `deliverBatch`'s single exit are
|
|
546
|
+
untouched. Contract: `docs/contracts/notice-claim-v1.md` (bail-gate enumeration extended). **Review
|
|
547
|
+
follow-up:** the probe answers ASYNCHRONOUSLY, and the first shape (`if (claims.length > 0)`) was a
|
|
548
|
+
no-op in exactly the window #352 was opened for — `waitOne` can bail while the probe's `ensureCollect`
|
|
549
|
+
is still in flight, and the claim then lands on an array nobody can reach. `details.waitClaims` is now
|
|
550
|
+
mounted unconditionally (one probe = at most one claim), the probe's own promise travels as
|
|
551
|
+
`details.waitClaimsPending`, and `releaseWaitClaims` awaits it before rolling back — so a claim that
|
|
552
|
+
landed after the bail is still handed back and the round's notice arrives exactly once.
|
|
553
|
+
|
|
554
|
+
- **A `Phase timeout:` notice no longer outlives a team that ended in the very phase it timed out in
|
|
555
|
+
(#354).** `isStalePhaseTimeoutNotice` asked `isTerminalNode`, which reads `status === "failed" ||
|
|
556
|
+
phase ∈ {done,failed,aborted}`. A team that only investigated never calls `legion_phase`, so it keeps
|
|
557
|
+
its birth `planning` phase and ends with `status: settled` / `retired` — neither predicate fired and
|
|
558
|
+
`phase_since` never moved either, so an `action-needed` asking root to extend-or-abort a team whose
|
|
559
|
+
work had already merged was delivered ~30 minutes late. The "has ended" arm now asks the new narrow
|
|
560
|
+
`hasEnded` predicate (terminal **or** settled/retired); `isTerminalNode` is deliberately unchanged
|
|
561
|
+
because mail bounce and the orphan scan share it. The `reason` stays `obsolete_after_phase_change`
|
|
562
|
+
(event set still 16) and the "no `phase_timeout` event → stay ACCEPTED, never guess" clause stands.
|
|
563
|
+
Contract: `docs/legion-v2-contract.md` §8.3.1, `docs/contracts/mailbox-delivery-v1.md` D4.
|
|
564
|
+
|
|
565
|
+
- **`retire()` / `retireAll()` / `/legion stop` can no longer leave a team in its custom phase "forever"
|
|
566
|
+
(#351).** `Orchestrator.abortLegionPhase` evaluated the abort without the team's own phase machine, so a
|
|
567
|
+
card-declared name (`build`) fell through to `DEFAULT_PHASE_MACHINE`, was refused `invalid_phase`, and
|
|
568
|
+
that refusal was silently dropped (`if (!decision.allowed) return;`) — no `aborted`, no event, no log,
|
|
569
|
+
even though the child process had already been killed. The abort now forwards the machine resolved from
|
|
570
|
+
the node's role card, and when no machine can resolve the phase the retire is **forced** through:
|
|
571
|
+
`phase` becomes `aborted` (a parent retire must always take effect — the safety bottom line in
|
|
572
|
+
`phase-machine.ts`), one `phase_change` event carries `{ forced: true, reason:
|
|
573
|
+
"parent_retire_unresolved_phase", denied }` (no new event type; the set stays at 16), and one
|
|
574
|
+
`console.warn` names the node, the phase and the refusal.
|
|
575
|
+
Contract: `docs/contracts/declarative-phase-machine-v5.md`.
|
|
576
|
+
|
|
577
|
+
- **`/legion doctor` stopped recommending the dead extension switch (#329).** `inheritExtensions: true`
|
|
578
|
+
and `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1` outlived #328's clean-up in `legion-doctor.ts`: `true` has been
|
|
579
|
+
the default since #280 and both yield 0 extensions when host discovery fails, so every doctor fix and
|
|
580
|
+
next step now names only `models.json` / `PI_LEGION_CHILD_EXTENSIONS=<path>`, with a case that pins it.
|
|
581
|
+
The `?fresh=1` half of the #317 case re-evaluated the wrong module (ESM drops the query when resolving a
|
|
582
|
+
relative import), so it now re-evaluates `extension-inheritance.ts` and asserts that a second instance
|
|
583
|
+
reads the shared discovery holder.
|
|
584
|
+
|
|
585
|
+
- **The `/legion` track view now paints its own page background, so the detail modal no longer sits
|
|
586
|
+
on a black vertical stripe (#308).** Opening a node's context viewer put a pure-black band between
|
|
587
|
+
the frame's right edge and the terminal's right edge (the left side looked fine). The frame was not
|
|
588
|
+
overflowing — `paintModalFrame` emits exactly the rect width every row. The base row was: the canvas
|
|
589
|
+
pre-fills `W×H` spaces and `renderRow` only styles a run when it has a color *and* non-blank text, so
|
|
590
|
+
a row ends in bare spaces with no SGR at all. pi-tui's compositor inherits the styling of the area
|
|
591
|
+
*beside* an overlay from a one-shot snapshot of the active SGR at the first grapheme after the rect
|
|
592
|
+
(`extractSegments`), and an empty snapshot means the terminal's default background. Every `/legion`
|
|
593
|
+
row is now wrapped in `theme.bg?.(LEGION_PAGE_BG, line)` at the view's single render exit
|
|
594
|
+
(`paintPageFloor` in `src/tui/viz/view.ts`), so the inherited state is the page floor. `theme.bg` is
|
|
595
|
+
optional in `VizTheme` and the call is optional, so a theme without background tokens (every unit-test
|
|
596
|
+
fake) still renders the exact bytes it did before, and the row width is unchanged (the wrap is
|
|
597
|
+
zero-width escapes only). The token is `toolPendingBg` — the only one of the seven `ThemeBg` keys that
|
|
598
|
+
is a neutral surface rather than a hued panel (blue / violet / yellow / green / red for the rest).
|
|
599
|
+
**Not fixed in the `system` theme:** with no terminal color report that theme assigns `""` to all
|
|
600
|
+
seven `ThemeBg` keys, so each renders as `\x1b[49m` (terminal default) and the band stays black —
|
|
601
|
+
upstream exposes no renderable page-background token. The new regression test keeps ANSI (the #193
|
|
602
|
+
`wipedRows` criterion strips escapes, which is why it never caught this) and compares the background
|
|
603
|
+
SGR of both gutters with the base's own: no fix ⇒ every gutter snapshot is `""`. Contract:
|
|
604
|
+
`docs/contracts/legion-page-floor-v1.md`.
|
|
605
|
+
|
|
606
|
+
- **The five-value mailbox delivery landed against an adversarial review: four majors and six minors
|
|
607
|
+
(#338, follow-up to #322/#333).** The important ones were silent: mail addressed to **root** could never
|
|
608
|
+
be confirmed — `nodes.session_file` was written only on the child path (`reserveNode`), so every
|
|
609
|
+
`to: "parent"` letter, including the whole #333 `Phase timeout:` path, sat at `accepted` forever.
|
|
610
|
+
`createRootLegion` now records this process's `ctx.sessionManager.getSessionFile()` on the root row at
|
|
611
|
+
creation (and the contract says where that stops: a ledger rebuilt from `run.json` still has no root
|
|
612
|
+
session file, so those letters honestly stay `accepted`). The `DELIVERED` criterion also dropped its
|
|
613
|
+
"or the user turn equals the mail **body**" branch — a body carries no message id, so a task card, a
|
|
614
|
+
different letter or a quoted paragraph matched it, and "that sentence appeared somewhere" is a mention,
|
|
615
|
+
not a read. And the per-tick 20-message confirmation window now **rotates per seam** (`id > cursor`,
|
|
616
|
+
wrapping at the tail) instead of re-reading the oldest 20: the old shape let 20 undecidable messages hold
|
|
617
|
+
the window forever, which stops the confirm *and* obsolete paths permanently — a deadlock, not the delay
|
|
618
|
+
the docs claimed. The `obsolete` rule no longer names a `kind: "instruction"` that never existed (only
|
|
619
|
+
`kind: "notice"` is eligible), `mailboxStats` is documented as an export with no consumer instead of a
|
|
620
|
+
wired status surface, each seam's session is parsed once per tick instead of once per message, and a
|
|
621
|
+
delivery event is re-emitted by `messageId` rather than by scanning the newest 100 rows of that node+type
|
|
622
|
+
(which went silent on a long-lived ledger). An intercepted send is not a steer, so it emits no
|
|
623
|
+
`node_steered`; the observable signal is the `mail_bounced` with `reason:
|
|
624
|
+
intercepted_by_input_handler`. No ledger event type was added — the closed set stays at 16.
|
|
625
|
+
|
|
626
|
+
- **A `Phase timeout:` notice that arrives after its phase ended is no longer handed to the parent as a
|
|
627
|
+
live to-do item (#333).** A team that runs two or three review rounds reliably exceeds the 30-minute
|
|
628
|
+
`reviewing` limit, so an `action-needed` phase-timeout notice is not an edge case: the notice was queued,
|
|
629
|
+
the team went on to converge (next phase, failure, or retire), and *then* the notice reached root, asking
|
|
630
|
+
for a decision about a node that no longer existed. Nothing ever revoked it — there was no state to revoke
|
|
631
|
+
it into. Mail whose `kind` is `notice` and whose subject starts with `Phase timeout:` is now checked
|
|
632
|
+
against the ledger fact that produced it (the `phase_timeout` event's `phase`/`phaseSince`, never the
|
|
633
|
+
notice's prose) and marked `obsolete` when the sender has left that phase or gone terminal. The check runs
|
|
634
|
+
in two places: **before delivery** (a queued notice is never injected at all — the path the issue actually
|
|
635
|
+
took) and in the postman's confirmation pass for mail already `accepted` (that one only relabels; it
|
|
636
|
+
cannot unqueue text a recipient already holds). Mail of any other kind — `question`/`report`/`handoff`, the domain has
|
|
637
|
+
no `instruction` — is never auto-obsoleted: whether a question or a handed-over task went stale is a
|
|
638
|
+
human call, and a `delivered` message is never relabelled, because a
|
|
639
|
+
consumption that happened is a fact about the past. No new event type: the verdict is `mail_bounced` with
|
|
640
|
+
`reason: "obsolete_after_phase_change"`.
|
|
641
|
+
|
|
642
|
+
- **The five `steer`-receipt residuals (#323).** The three honest dispositions from #313 are now
|
|
643
|
+
wired through the edges that still flattened them. The in-process backend can never answer
|
|
644
|
+
`queued` vs `accepted` — the SDK's `AgentSession.prompt()` returns `void` — so the supervisor seam
|
|
645
|
+
gained an explicit capability (`capabilities.promptDisposition`: in-process `false`, rpc `true`,
|
|
646
|
+
and the routing facade reports the intersection, `false`) instead of inventing a disposition that
|
|
647
|
+
backend cannot observe; the v2 contract now states that `accepted` on in-process means only "the
|
|
648
|
+
call did not throw". An intercepted mailbox `action-needed` steer no longer flips the node from
|
|
649
|
+
`blocked` to `running`: #338 already makes that send `bounced` and returns before the unblock, and
|
|
650
|
+
the orchestrator's own `clearBlocked` path now refuses it for the same reason — nothing landed, so
|
|
651
|
+
the child is still waiting. `abandonTurn` keeps the same `retiring` guard `updateTurnState` has, so
|
|
652
|
+
a concurrent rollback can no longer rewrite a retiring child as `idle`. And an intercepted *launch*
|
|
653
|
+
prompt now fails with the new deterministic code `PROMPT_INTERCEPTED`, which `canFallbackStart`
|
|
654
|
+
refuses — a swallowed prompt used to re-spawn one process per fallback model and roll each one back
|
|
655
|
+
right away. The mailbox `prompt` branch keeps no disposition by construction; its comment and the
|
|
656
|
+
contract now say why. Contract: `docs/legion-v2-contract.md` §3.1.
|
|
657
|
+
|
|
658
|
+
- **The in-flight tool fact behind `status` is a stack, so nested and parallel calls stop disappearing
|
|
659
|
+
(#331).** `working · bash 00:07:31` used to read a single slot: `tool_execution_start` overwrote it
|
|
660
|
+
and the matching `tool_execution_end` cleared it. Two shapes therefore made the line vanish while a
|
|
661
|
+
tool was demonstrably still running. A nested call (`codemode` → `bash`) pushed the inner frame into
|
|
662
|
+
the slot, so the inner end wiped the outer call — `status` showed nothing while `codemode` ran on. A
|
|
663
|
+
parallel batch kept only the newest call, so when that one finished first the still-running sibling
|
|
664
|
+
became invisible. Each `ChildRecord` now keeps a LIFO stack (`ChildActivity[]`): a start pushes, an
|
|
665
|
+
end removes **only** the frame it names (by `toolCallId`), `agent_settled` empties it, and the line
|
|
666
|
+
reads the top (innermost) frame for the tool name and the clock. When the stack is deeper than one,
|
|
667
|
+
the line names the context instead of hiding it — ` (inside codemode ×2)` for nesting (from the
|
|
668
|
+
event stream's own `parentToolCallId`) and ` (+N more)` for parallel siblings, with the weaker
|
|
669
|
+
`(+N more)` chosen when parent ids cannot tell the two apart. An empty stack renders no line, exactly
|
|
670
|
+
as before. Display only: `liveness()`, heartbeats, the orphan scan and every kill/abort/retire path
|
|
671
|
+
are untouched. The seam gained an optional `inFlightToolStack` next to `inFlightTool`, so a doubled
|
|
672
|
+
supervisor that only answers the single frame keeps working. Contract revision:
|
|
673
|
+
`docs/contracts/stall-visibility-v1.md` (D1/D2/D4/D7).
|
|
674
|
+
|
|
675
|
+
- **One name, one node: `status` / `collect` / `stop` / `continue` now resolve a child name the same way.**
|
|
676
|
+
In a single root session, `status(name)` and `collect(name)` called ten seconds apart answered with two
|
|
677
|
+
different nodes for the same `worker-0`: `status` went through `pickChildByName`, which walks **every**
|
|
678
|
+
run in the store and prefers a live record and then the newest `createdAt`, while `collect` acted on one
|
|
679
|
+
run's direct children. Neither reported the disagreement, so a parent chasing its own **failed** child
|
|
680
|
+
collected another run's **successful** one and believed the work was done; `continue` would then use the
|
|
681
|
+
wrong node's model and outcome, and `stop` picked the same wrong record and answered
|
|
682
|
+
`No non-retired children to stop.` — masking that the name was ambiguous at all. The four actions now go
|
|
683
|
+
through one resolver (`src/runs/child-scope.ts`): the current run's **direct** children win and add no
|
|
684
|
+
new output; a name that only exists elsewhere is reported with `runId` / `nodeId` / `state` for every
|
|
685
|
+
candidate (`collect`/`stop`/`continue` refuse it and name both levers — `runId`, or the parent node's
|
|
686
|
+
scope — instead of acting on it), `status` still shows it but leads with
|
|
687
|
+
`resolved: <name> is not a direct child of this session — showing the record from run <runId>`, and two
|
|
688
|
+
or more same-named candidates are an ambiguity every action refuses rather than guesses. `subagent`
|
|
689
|
+
gained an optional `runId` to name the run a call is scoped to. The scope now travels all the way to the
|
|
690
|
+
**execution point**, which is where the old resolution leaked back in: `stop` hands the run it resolved the
|
|
691
|
+
name in to the retire step, which reads THAT record instead of re-resolving the name across runs — so the
|
|
692
|
+
child it reports and the child it retires are the same one, and a live job carrying the name in another
|
|
693
|
+
run is refused (with the same candidate list) instead of retired. `collect` only consumes the runtime
|
|
694
|
+
snapshot whose `runId` matches the resolved node, the `status` block's `owner:` line is read from the
|
|
695
|
+
resolved node's own run dir (another run's dead owner marker used to render `owner: dead (pid gone)` — and
|
|
696
|
+
even `state=failed` — under this session's record), and the `resolved:` annotation is emitted only for a
|
|
697
|
+
genuinely foreign (`elsewhere`) record, not for a #270 cross-store hit that is this session's own child. A
|
|
698
|
+
session with no run of its own keeps reaching a foreign/restarted run's persisted record (#209-3), which is
|
|
699
|
+
where `pickChildByName` was still the right answer (#312).
|
|
700
|
+
|
|
701
|
+
- **A bare JSON verdict on the last line is no longer reported as `no machine-readable verdict`.**
|
|
702
|
+
`child-guard` injects “End with machine-readable JSON on its own” and `docs/design.md` says
|
|
703
|
+
`attested` is “解析末尾 verdict JSON”, but `extractVerdict` only knew three JSON candidates:
|
|
704
|
+
fenced blocks, `text.trim()`, and the span from the **first** `{` to the **last** `}`. One brace
|
|
705
|
+
anywhere in the report — an API shape, a code fragment, `{ version, entries }` — turned that span
|
|
706
|
+
into prose-plus-JSON, `JSON.parse` failed, and a perfectly valid verdict on the final line was
|
|
707
|
+
thrown away, so the child was recorded `acceptance: unknown` while a sibling whose verdict was
|
|
708
|
+
byte-identical but wrapped in a fence came back `accepted`. Two candidates are added after the
|
|
709
|
+
existing ones and before giving up: the **last line** when it is itself a JSON object (what the
|
|
710
|
+
injection asks for, and it survives trailing blank lines), and a **string/escape-aware brace
|
|
711
|
+
balance scan backwards from the text's last `}`**, which reaches a verdict that spans lines or is
|
|
712
|
+
followed by a short trailing note. The existing `\n\s+` collapsed-span compat candidate is kept,
|
|
713
|
+
moved last because it rewrites the text. Parsing never throws and a report with no JSON still
|
|
714
|
+
returns `null` (#311).
|
|
715
|
+
|
|
716
|
+
- **A provider that ends a turn with no content no longer costs a child its whole turn.** Some
|
|
717
|
+
upstreams answer HTTP 200 and then terminate the stream in-band; pi maps that to
|
|
718
|
+
`stopReason: "error"` with an empty assistant message (`finish_reason: error`, measured with
|
|
719
|
+
`usage.input === 0` and 2/42 output tokens after 90–175s). Nothing retried it: the SDK's retryable
|
|
720
|
+
classifier misses the message, the provider-level retry wrapper only covers thrown HTTP errors,
|
|
721
|
+
and this package's only transient predicate matches `/connection error/i` — whose hold loop polls
|
|
722
|
+
the session file and never re-prompts, so waiting could not help. `collect()` now recognises the
|
|
723
|
+
*shape* (`isEmptyGeneration`: last turn has an assistant record, `stopReason: "error"`, blank
|
|
724
|
+
text, zero tool calls — never `usage`, never the provider's message text) and, while the child is
|
|
725
|
+
still alive and the parent has not aborted, sends it one `CONTINUE_PROMPT` on the same session and
|
|
726
|
+
re-derives the outcome; the failure is tagged `Execution.errorKind =
|
|
727
|
+
"provider-empty-generation"` either way, the parent-visible reason gains
|
|
728
|
+
`(provider returned an empty generation; retried once)` when the retry also came back empty, and
|
|
729
|
+
the ledger's `node_failed` event carries that `errorKind`. The retry budget is its own counter
|
|
730
|
+
(`ChildRecord.providerRetriesUsed`, limit **1**), deliberately NOT the renewal budget — renewals
|
|
731
|
+
are a user-granted allowance for "no measurable progress" that a plain failure never charges, and
|
|
732
|
+
renewal re-runs a child while this continues the same live one. A continue-prompt that an input
|
|
733
|
+
handler swallowed is nobody's retry: it is not charged to the budget, the original failure stands,
|
|
734
|
+
and a warning names what happened — the retry also never classifies its own turn by looser rules
|
|
735
|
+
than a first turn (a `Connection error` on the retry keeps the child `running` instead of retiring
|
|
736
|
+
it). Assistant records also pass the
|
|
737
|
+
upstream `responseId` through for forensics. Contract:
|
|
738
|
+
`docs/contracts/provider-empty-generation-retry-v1.md` (#316).
|
|
739
|
+
|
|
740
|
+
- **A team can no longer deadlock in `implementing` after a partial failure, and two error codes
|
|
741
|
+
stopped lying.** Covered by the `supersedes` / `reason` entry above; the code-hygiene half is
|
|
742
|
+
separate: resuming a terminal legion child reported `BUDGET_EXCEEDED` although nothing about a
|
|
743
|
+
budget was involved, so a parent could not tell "this child is finished" from "this child ran
|
|
744
|
+
out of budget" — the new `ErrorCodes.CHILD_TERMINAL` says what happened, and `reviveChild`'s
|
|
745
|
+
catch no longer rewrites every failure to `START_FAILED` (a `SubagentError` keeps its own code,
|
|
746
|
+
anything else still gets `START_FAILED`). Codes travel only in `details.error`, never in the
|
|
747
|
+
user-visible text, so no tool output gained a `BUDGET_EXCEEDED` string (#309).
|
|
748
|
+
|
|
749
|
+
- **Host extension discovery is no longer a silent failure, and `/legion doctor` can say whether
|
|
750
|
+
inheritance is wired at all.** `ensureHostExtensions` kept its never-reject / R2-b backoff
|
|
751
|
+
semantics but recorded a failure only as a `failedAt` timestamp: a host whose `pi list` breaks
|
|
752
|
+
looked exactly like a host that ships no package extensions, because `discoveredHostExtensions()
|
|
753
|
+
?? []` collapses "could not ask" and "nothing installed" into one empty array. A failure now logs
|
|
754
|
+
one `[pi-legion]`-prefixed warning carrying a bounded `pi list` stderr summary (the `failedAt` stamp doubles as
|
|
755
|
+
the already-told bit, so a permanently broken probe warns at most once per process instead of once
|
|
756
|
+
per warm call; with no stderr the reason falls back to the exit code, then to pi's own message,
|
|
757
|
+
and the warning itself can never throw). `/legion doctor`'s
|
|
758
|
+
Extensions section answers the question with
|
|
759
|
+
`discovered host extensions: N (last probe: ok | failed <n>s ago | never)`, backed by the new
|
|
760
|
+
read-only `hostExtensionDiscoveryState()` — doctor never probes on its own, so `never` is honest
|
|
761
|
+
and the line costs nothing. The in-process spawn hint also stopped recommending levers that no
|
|
762
|
+
longer do anything (`inheritExtensions: true` is the default policy since #280,
|
|
763
|
+
`PI_LEGION_CHILD_INHERIT_EXTENSIONS=1` is a no-op, and neither brings back a host extension when
|
|
764
|
+
discovery failed): it names `PI_LEGION_CHILD_EXTENSIONS=<path>` and a static `models.json`
|
|
765
|
+
provider, and appends "inheritance is enabled but host discovery is failed/never probed — the
|
|
766
|
+
child got 0 host extensions" when that is the actual situation — on every spawn-hint branch, so
|
|
767
|
+
the rpc `--no-extensions` hint lost its own `inheritExtensions` clause too (it would have
|
|
768
|
+
contradicted the note right below it). Contract revision:
|
|
769
|
+
`docs/contracts/extension-inheritance-wiring-v1.md` §9 (#317).
|
|
770
|
+
|
|
771
|
+
- **A `collect` that bails after claiming no longer swallows that round's completion notice.**
|
|
772
|
+
The claim-once seam claims a terminal result as it hands the snapshot to the tool call, and
|
|
773
|
+
`collectChild` can still bail afterwards — the parent turn aborts, or persisting the child
|
|
774
|
+
throws. Two things were wrong with the rollback. It deleted the claim unconditionally, so a
|
|
775
|
+
bail on a `(runId, name, round)` a real delivery had already claimed erased the evidence and
|
|
776
|
+
resurrected an already-read notice into the next flush; the seam now hands out *whether this
|
|
777
|
+
call took the claim* with the snapshot (`consumeCollect*` returns `{ snapshot, claimedNow }`),
|
|
778
|
+
and only that one comes back — per call, not per snapshot, because the watch and the tool call
|
|
779
|
+
await the same `job.collectPromise` and a shared record lets the second call overwrite the
|
|
780
|
+
first's answer. And it did not hand the notification route back: the seam sets
|
|
781
|
+
`consumedByTool` before the claim, the default async path watches every child, so the watch
|
|
782
|
+
passed its gate first (`!consumedByTool && !notified` → skip `onTerminal`) and was already
|
|
783
|
+
parked in `retire` when the bail arrived — meaning the repair had to re-arm unconditionally,
|
|
784
|
+
which it now does (clear the flag, `watch` again; the generation bump keeps the old watch from
|
|
785
|
+
releasing the job, `notified` keeps the round single, `deliverBatch`'s claim filter is the
|
|
786
|
+
last net). The old `!job.watching` guard is therefore dead code wherever it would have
|
|
787
|
+
mattered: in the default shape somebody is always watching, and an `async: false` inline
|
|
788
|
+
collect registers no watch at all, so the flag is never unset on the path that needs the
|
|
789
|
+
re-arm. Measured in that production shape: 0 notices before, 1 after. The one case still
|
|
790
|
+
not recovered is a bail that lands only after the watch has already released the child — there
|
|
791
|
+
the result stays readable through a later `collect` and a copy already held in the run outbox
|
|
792
|
+
still arrives, but a copy sitting in the join window goes with the release. Contract revision:
|
|
793
|
+
`docs/contracts/notice-claim-v1.md` (D2, #320).
|
|
794
|
+
|
|
795
|
+
- **A `steer` receipt now reports what the child's session actually answered.** `steer` is a
|
|
796
|
+
queue, not an interrupt: the text is enqueued and only consumed at the child's next turn
|
|
797
|
+
boundary — after the current batch of tool calls finishes, before the next LLM call — so a
|
|
798
|
+
child stuck inside one long-running command never receives it, and no amount of steering can
|
|
799
|
+
break into that tool call. The receipt read "Steered <name>." whatever happened, because the
|
|
800
|
+
disposition the child's session reports back (`handled` vs `queued`) was dropped on the way up
|
|
801
|
+
through the supervisor seam — and `handled` never meant delivered: it is the SDK's
|
|
802
|
+
interception signal, returned *before* the message is queued, so the child never sees it. The
|
|
803
|
+
receipt now has three honest shapes: `queued` (it takes effect after the current tool batch
|
|
804
|
+
finishes), `accepted` (the child was idle, so this call started a turn with it) and
|
|
805
|
+
`intercepted` (an input handler took it — the child never saw it). Resuming or continuing a
|
|
806
|
+
live child carries the same distinction, and the parent playbook plus the v2 contract now
|
|
807
|
+
state the negative semantics — a tool call in flight is unreachable by `steer`; `stop`
|
|
808
|
+
(`force: true` for the hard variant) is what actually interrupts. Because `intercepted` means
|
|
809
|
+
"no run started", the turn it had claimed is rolled back: `waitSettled` answers at once with
|
|
810
|
+
`prompt was intercepted before any run started` instead of timing out, the child does not stay
|
|
811
|
+
`running` forever, and a launch prompt swallowed that way fails the spawn rather than handing
|
|
812
|
+
back a child that was born busy. Legion mail sent to a busy child with `action-needed` now logs
|
|
813
|
+
a warning when its steer was intercepted — `delivered=1` still records the send (D3), so the
|
|
814
|
+
warning is the only signal that the child never saw the message.
|
|
815
|
+
|
|
816
|
+
- **A child's completion notice is delivered once, whichever route won.** A terminal
|
|
817
|
+
result reaches the parent two ways — the tool result (`wait` / `collect`) and the
|
|
818
|
+
pushed notice (`watch` → `join` → outbox) — and each kept its own memory
|
|
819
|
+
(`consumedByTool` / `notified`), judged once when the watch hit terminal. The notice
|
|
820
|
+
still had to cross the 10s join window and the run-scoped outbox, neither of which
|
|
821
|
+
knew those flags existed, so four duplicate windows followed: a buffered entry
|
|
822
|
+
flushed after a `wait` (W1), an outbox entry flushed on `agent_settled` after a
|
|
823
|
+
`wait` (W2), a buffered entry orphaned by `release()` (W3), and the flags
|
|
824
|
+
evaporating with the deleted job (W4).
|
|
825
|
+
- Dedupe collapses onto **one claim per `(runId, name, round)`**. `round` is a
|
|
826
|
+
per-name counter because `runId` is the *parent* run id — every round of a
|
|
827
|
+
same-named child shares it, so a two-part key lets an unread old round win the
|
|
828
|
+
flush and filter out the new round's real result. Three claim sites — `wait`
|
|
829
|
+
(before its retire await), the `collect` seam, and a notice that actually went
|
|
830
|
+
out — and one rollback: when a `collect` claims and then bails (the parent turn
|
|
831
|
+
aborts, or persisting the child fails), the claim is handed back, because a
|
|
832
|
+
notice for a result nobody saw must not stay suppressed. Delivery itself never
|
|
833
|
+
unclaims.
|
|
834
|
+
- `deliverBatch` is the single exit: claimed entries are filtered, survivors
|
|
835
|
+
formatted (grouped headers and the `N of M` count recomputed at delivery time),
|
|
836
|
+
then sent or held. The outbox now holds structure rather than formatted text, so a
|
|
837
|
+
member claimed while a notice waits drops out of the body and the count alike.
|
|
838
|
+
- Each round is one deliverable result, so releasing a child only ever drops the
|
|
839
|
+
copy of the round the parent just read: an earlier round's unread notice still
|
|
840
|
+
arrives.
|
|
841
|
+
- Tool results are **never** suppressed: `wait` / `collect` always return the full
|
|
842
|
+
text, only the passive notice is deduplicated. Claim state stays in memory by
|
|
843
|
+
design — after a process restart the result is still recoverable via `collect`.
|
|
844
|
+
- Contract and the four implementation-time revisions:
|
|
845
|
+
`docs/contracts/notice-claim-v1.md`.
|
|
846
|
+
|
|
847
|
+
- **A timed-out or aborted verify command no longer leaves its grandchildren running.** Both
|
|
848
|
+
command runners (`runVerifyCommand` for acceptance, `runLegionCommandNow` for `legion-verify`)
|
|
849
|
+
killed only the direct child — `bash -lc` / `timeout` — so everything the command had forked
|
|
850
|
+
was reparented to init and kept running: a `node --test` was still alive 44 minutes after its
|
|
851
|
+
run with `ppid=1`. Both now spawn `detached: true` (own process group) and, on the timeout,
|
|
852
|
+
output-cap and abort paths, run the new shared `sweepProcessTree()`
|
|
853
|
+
(`src/shared/proc-sweep.ts`): group signal first, then every descendant enumerated from
|
|
854
|
+
procfs (which covers a `setsid` escapee), then — for as long as the process group still holds a
|
|
855
|
+
live member, the spawned root included — `SIGKILL` to the group and to every pid the grace left
|
|
856
|
+
behind, waited for before the runner resolves. That "still a live member" clause is the point:
|
|
857
|
+
a root that installs a `SIGTERM` handler and never forks used to survive the whole sweep while
|
|
858
|
+
the timeout result reported no survivors. The runner's promise resolves only **after** that
|
|
859
|
+
sweep, so a caller that sees a timeout knows the tree is gone; the price is up to
|
|
860
|
+
`graceMs + killAfterMs` (2.5s) of extra latency on those paths, documented in
|
|
861
|
+
`docs/contracts/proc-sweep-v1.md`. Two exits are therefore *not* timeouts: a **normal** exit is
|
|
862
|
+
never swept (`cmd &` in a verification command is a legal way to leave a background process
|
|
863
|
+
behind — leftovers are reported with one `[legion]` warning naming the command and the pids),
|
|
864
|
+
and neither is a command that already exited while an unredirected `cmd &` kept its pipes open.
|
|
865
|
+
`legion-verify` also stops reading its inner `timeout(1)` as a clean exit: `124` (or death by
|
|
866
|
+
signal) is a timeout, and it is swept too. A sweep that still ends with survivors warns instead
|
|
867
|
+
of staying silent. `/proc` absent (non-Linux) degrades to the process-group signal and never
|
|
868
|
+
throws; `ESRCH`/`EPERM` are normal outcomes — but there the clean-exit leftover warning is dead
|
|
869
|
+
code, because both of its sources are procfs. The `signal` parameter has no production caller
|
|
870
|
+
yet (abort wiring is a todo). (#310)
|
|
871
|
+
|
|
14
872
|
## [5.0.1] — 2026-10-27
|
|
15
873
|
|
|
16
874
|
Patch release. **This is the release where 5.0.0's extension-inheritance policy
|