pi-legion 3.0.3 → 4.0.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 +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
# 冻结契约:launch admission queue(超限排队而非拒绝,issue #266)
|
|
2
|
+
|
|
3
|
+
状态:**FROZEN** — 实施中禁止偏离。发现矛盾立刻停下报告,不要自圆其说。
|
|
4
|
+
范围:planning only。本文件冻结接口、闸门分类、状态机、文件归属与验收;不含实现。
|
|
5
|
+
|
|
6
|
+
## 0. 目标与不做的事
|
|
7
|
+
|
|
8
|
+
**目标**:把「超过并发/扇出上限 → 抛 `BUDGET_EXCEEDED` 让调用方自己重试」改成
|
|
9
|
+
「超过**可排队**上限 → 入队等待,槽位释放后自动准入」,对 inprocess 与 rpc/legion
|
|
10
|
+
**同一套调度器**;用户不再看到 `inprocess maxChildrenPerNode reached for '<root>' (8/8)`
|
|
11
|
+
把一次 12 步 fanout 打成 8 成功 + 4 报错。
|
|
12
|
+
|
|
13
|
+
**non-goals(v1 明确不做)**:跨 parent 公平性 / 优先级 / 队列持久化 / 排队期间改
|
|
14
|
+
agent-model / `queued` 进账本状态闭集 / 队列指标进 `/legion doctor` 深度报表。
|
|
15
|
+
详见 §10。
|
|
16
|
+
|
|
17
|
+
## 1. 已确认事实(父会话已核对,**禁止重新侦察**)
|
|
18
|
+
|
|
19
|
+
| # | 事实 | 证据 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| F1 | 默认后端 inprocess | `src/supervisor/spawn-mode.ts:17` |
|
|
22
|
+
| F2 | 用户可见的 `maxChildrenPerNode reached` 唯一生成点 | `src/supervisor/inprocess-supervisor.ts:1163-1168`(throw `:1168`,`onSpawnRefused` `:1167`) |
|
|
23
|
+
| F3 | in-process 子节点在**账本门**被故意放宽到无限 | `src/runs/orchestrator.ts:385-396` |
|
|
24
|
+
| F4 | 账本门文案另一套,抛错点 | `src/legion/budget.ts:89-92`(`max_children_per_node`)、`src/runs/orchestrator.ts:420-423` |
|
|
25
|
+
| F5 | 两套计数不同源:inprocess 数内存 `this.children`(`assertWithinLimits` `:1132-1170`,active 过滤 `:1135-1137` **只排除 exited/retired**);legion 数 SQLite(`LIVE_CHILD_STATUSES` = starting/running/blocked,`src/legion/nodes.ts:24-29`) |
|
|
26
|
+
| F6 | **一个错误码覆盖全部闸门**:`BUDGET_EXCEEDED` | `inprocess-supervisor.ts:1138-1170`(4 闸门)、`orchestrator.ts:355/423/868+`、`budget.ts:24-28` |
|
|
27
|
+
| F7 | 两条派发入口都是裸 `Promise.all` 全量并发;单步失败降级成一行文本 | `index.ts:1864`、`index.ts:2394`、`index.ts:2090-2096` |
|
|
28
|
+
| F8 | `agent_settled → state="idle"`;retire → `"retired"`;spawn 失败 → `"exited"` | `inprocess-supervisor.ts:1312-1319`、`:1408-1432`、`:1231-1252` |
|
|
29
|
+
| F9 | **idle 仍占槽**(见 F5 的过滤):真正释放槽位只有 `retired` / `exited` 两个边沿 | 同上 |
|
|
30
|
+
| F10 | `reserveNode` 先插 `status:"starting"` 账本行 → 排队项会自锁 | `orchestrator.ts:397-419` |
|
|
31
|
+
| F11 | `maxChildrenPerNode: 0` 合法且语义=禁止 | `src/agents/settings.ts:76-88`;`siblings(0) >= 0` 恒真 |
|
|
32
|
+
| F12 | 契约哨兵锁 §11.1 默认值字面 | `test/unit/legion-schema-sentinel.test.ts:226` |
|
|
33
|
+
| F13 | 两个事件闭集:持久化 `src/legion/events.ts:3-26`、纯进程 `src/extension/event-bus.ts:10-21`;漏加会冻结 DB 游标(#233) | 同上 |
|
|
34
|
+
| F14 | 既有可复用形态:verify 槽位排队(**轮询式**,本任务要事件驱动) | `src/runs/verify-queue.ts:55/95/141`、`index.ts:578` |
|
|
35
|
+
| F15 | `Handle` / `ChildRecord` / `TaskState` | `src/shared/types.ts:473-500` / `:298-360` / `:211-217` |
|
|
36
|
+
| F16 | `NODE_STATUSES` 6 值 + `padStatuses`/`validateStatuses` 硬编码 6 占位 | `src/legion/nodes.ts:7-14/403-427`;另一份副本 `src/extension/legion-status.ts:43` |
|
|
37
|
+
| F17 | 5 处 `new Orchestrator(` | `index.ts:556, 1445, 1517, 1801, 2396` |
|
|
38
|
+
| F18 | `rebuild.ts` 按 `child.state` 反推节点状态(`launching→starting` …),闭集里没有 `queued` 的落点 | `src/legion/rebuild.ts:342-347` |
|
|
39
|
+
| F19 | `recycle.ts` 列举「不可回收」状态 | `src/extension/recycle.ts:18-19` |
|
|
40
|
+
| F20 | 脚本:`npm run typecheck` / `npm test` / `npm run test:integration` | `package.json:52-54` |
|
|
41
|
+
|
|
42
|
+
## 2. 决策(D 编号,reviewer 必读)
|
|
43
|
+
|
|
44
|
+
### D1 — 可排队闸门白名单(四闸门)
|
|
45
|
+
**判定**:只有 `maxChildrenPerNode`、`maxActive`、账本 `max_children_per_node`、账本
|
|
46
|
+
`max_active_nodes` 入队。其余全部**同步硬拒**,**错误码与文案逐字不变**。
|
|
47
|
+
**不变量**:入队/拒绝的判定必须来自**结构化 verdict**(§4.1 `AdmissionVerdict`),
|
|
48
|
+
不得来自捕获 `BUDGET_EXCEEDED`(理由见 D1-b)。
|
|
49
|
+
**失败模式**:把 `maxDepth` 当可排队 → 无限队列,每个排队项必然超时。
|
|
50
|
+
**验收**:A6、A7、A9。
|
|
51
|
+
|
|
52
|
+
#### D1-a — 闸门来自「一个纯函数」,不是两份逻辑
|
|
53
|
+
`InProcessSupervisor.assertWithinLimits` 的四个 gate 判定与四条文案必须**抽成一个纯函数**
|
|
54
|
+
`evaluateInProcessAdmission(...)`(§4.1),supervisor 与 Orchestrator 的探测共用它。
|
|
55
|
+
理由:文案是用户可见契约(`test/unit/inprocess-supervisor.test.ts` 与 #197 用户反馈都锁它们),
|
|
56
|
+
两份实现必然漂移。
|
|
57
|
+
**验收**:A6(逐字比对四条文案)、A8。
|
|
58
|
+
|
|
59
|
+
#### D1-b — 为什么**不能**用 try/catch 包 `BUDGET_EXCEEDED`
|
|
60
|
+
一个码覆盖至少 8 种条件(F6):`maxSpawnsPerSession` / `maxActive` / `maxDepth` /
|
|
61
|
+
`maxChildrenPerNode`(`:1143/:1150/:1159/:1166`)、`assertWithinBudgets` 的 depth 与
|
|
62
|
+
spawns、`childFromNode` 的「legion child is terminal and cannot be resumed」
|
|
63
|
+
(`orchestrator.ts:355`)、`reserveNode` 的 `parent_not_found` 与 `legion launch refused(...)`。
|
|
64
|
+
catch 它会同时造成三重破坏:
|
|
65
|
+
1. **把永久失败排进队列**(终态子节点、parent 不存在、深度超限)→ 每个都退化成 15 分钟静默等待;
|
|
66
|
+
2. **丢失文案同一性**(D1 要求硬拒文案逐字不变),因为 catch 到的是 `Error` 而非闸门身份;
|
|
67
|
+
3. **改变 model fallback 语义**:`canFallbackStart`(`orchestrator.ts:235-240`)**依赖**该 code 不做降级,
|
|
68
|
+
catch-then-requeue 会让 fallback 链在排队语义下失效或误触发。
|
|
69
|
+
补充:账本侧 `budget_refused` 是**存储域事件**,由 `reserveNode` 的拒绝分支发出
|
|
70
|
+
(`orchestrator.ts:419-423`);探测路径**不得**替它发事件(否则硬拒会出现两条事件)。
|
|
71
|
+
|
|
72
|
+
### D2 — 实现落点:Orchestrator 级准入(single choke point)
|
|
73
|
+
**判定**:排队逻辑只放在 `Orchestrator.launch`;`InProcessSupervisor` **只加**
|
|
74
|
+
「槽位释放回调 + 只读计数/探测」,**不**在 supervisor 内排队。
|
|
75
|
+
**为什么不是 supervisor 内排队**:in-process 子节点在账本门被放宽到无限(F3),账本看不到它们,
|
|
76
|
+
排队若放 supervisor 就必然与 rpc 路径分裂成两套调度器(不同队列、不同超时、不同文案),
|
|
77
|
+
而 issue #266 的诉求正是「一套」。Orchestrator 是唯一同时看见两后端、且唯一能保证
|
|
78
|
+
「入队时不做任何 reservation」的层(F10)。
|
|
79
|
+
**不变量(D2-a,需父会话追认)**:supervisor 新增的两个成员必须是 **read-only**
|
|
80
|
+
(`admissionCounts()` / `preflightAdmission()`)与**一个回调**(`onSlotReleased`);
|
|
81
|
+
它们不改任何状态、不持有队列。契约原文「只加槽位释放回调」在字面上不含只读探测,
|
|
82
|
+
但缺少只读计数就必须在 Orchestrator 里复制 `assertWithinLimits` 的过滤与文案 —— 那与 D1-a 冲突。
|
|
83
|
+
本契约按「只读探测 + 回调」实施(仍无 supervisor 内排队),列为 §12 待追认项 O1。
|
|
84
|
+
**验收**:A7、A19。
|
|
85
|
+
|
|
86
|
+
### D3 — 排队项不占账本
|
|
87
|
+
**判定**:入队时**不** `reserveNode`、不建 worktree、不建 child run、不 `preCreateSessionFile`;
|
|
88
|
+
只产出 `ChildRecord{ state:"queued", spawnMode: <预期后端>, nodeId?: <确定性 id>, sessionFile: <占位路径> }`
|
|
89
|
+
并写入父 run.json(见 D3-a)。准入时才执行既有 reservation 路径。
|
|
90
|
+
`queued` **不**并入 `NODE_STATUSES` 闭集(F16)。
|
|
91
|
+
**为什么不是另一种方案**:把 `queued` 加进闭集会连带改 `padStatuses`/`validateStatuses`(硬编码 6 占位)、
|
|
92
|
+
`LIVE_CHILD_STATUSES`、`legion-status.ts` 的副本与 §11.1 哨兵,收益仅是 TUI 可见性 —— 而 TUI 通过
|
|
93
|
+
LaunchQueue 状态展示即可(D9)。账本里出现 `queued` 行还会污染 `listStaleNodes`/hygiene/orphan 扫描。
|
|
94
|
+
**失败模式**:先 reservation 再排队 → F10 自锁(`starting` 属 LIVE,自己把自己堵死)。
|
|
95
|
+
**验收**:A3、A16。
|
|
96
|
+
|
|
97
|
+
#### D3-a — 澄清:`queued` 记录**会**进 run.json(D3 原文「只留内存记录」措辞不准)
|
|
98
|
+
`index.ts` 的 `launchStep` 在 `launch()` 返回后必然 `store.addChild(handle.child)`
|
|
99
|
+
(`index.ts:2036-2040`),跨进程 `status` 也依赖 run.json(D9)。所以:
|
|
100
|
+
- **允许**写入:父 run.json 的一个 child 记录(state=`queued`)、`launch_queued` 进程事件。
|
|
101
|
+
- **禁止**创建:账本 `nodes` 行、child run 目录/run.json、worktree、`<name>.jsonl` session 文件。
|
|
102
|
+
- `sessionFile` 是**占位路径** `path.join(parentRunDir, "<name>.jsonl")`(纯字符串拼接,不建文件),
|
|
103
|
+
准入后由真实路径覆盖;消费者不得把它当 resume 凭据(§11 R9)。
|
|
104
|
+
**验收**:A3、A15。
|
|
105
|
+
|
|
106
|
+
### D4 — 定名与命名空间
|
|
107
|
+
**判定**:名字在**入队时**即分配,唯一分配者是 Orchestrator(沿用 `allocateName`/`allocateLegionName`;
|
|
108
|
+
名字已占用检查 `this.children.has(name)` 覆盖排队项)。返回的 `Handle` 带
|
|
109
|
+
`queued: true` + `queuePosition: <1-based>`,`spawnMode` 报告**预期**后端(与准入后一致)。
|
|
110
|
+
**不变量**:同一个 `launch` 调用在被准入时**不得**重新分配名字或改 `spawnMode`;
|
|
111
|
+
`Handle.name`、`Handle.child.name` 在队列驻留期间不变。
|
|
112
|
+
**失败模式**:准入时重新分配名字 → 父 agent 手里的 queued handle 变成别名,`stop`/`status` 对不上。
|
|
113
|
+
**验收**:A12、A1(准入顺序 == 入队顺序)。
|
|
114
|
+
|
|
115
|
+
### D5 — 队列策略
|
|
116
|
+
**判定**:
|
|
117
|
+
1. **顺序**:单一全局 `seq`(入队序号);准入时取「**seq 最小且探测通过**的项」。
|
|
118
|
+
2. **跳过(head-of-line)**:seq 更小的项若探测不通过则被跳过,不阻塞后续项。
|
|
119
|
+
3. **禁止跨 parent 抢占**:`seq` 全序即唯一顺序,任何实现都不得按 `parentKey` 重排或提升。
|
|
120
|
+
4. v1 不做公平性/优先级/权重。
|
|
121
|
+
**为什么不是「严格 per-parent FIFO + 全局 FIFO 双队列」**:一个 Orchestrator 通常只有一个
|
|
122
|
+
`parentKey`(父节点是同一个),双队列退化成单队列;而真正的公平性诉求(跨 parent 均分)
|
|
123
|
+
被 v1 明确排除(§10)。全序 + 跳过是这两条要求的最小实现。
|
|
124
|
+
**失败模式**:按 parentKey 分桶 → 跨 launch 调用的顺序不可预测且不可测。
|
|
125
|
+
**验收**:A1、A7。
|
|
126
|
+
|
|
127
|
+
### D6 — 有界与超时
|
|
128
|
+
**判定**(设置键与默认值见 D10):
|
|
129
|
+
- 队列上限 `maxPending`(默认 **32**)。溢出 → **立即硬拒**,文案
|
|
130
|
+
`launch queue overflow (<size>/<max>); raise subagents.inprocess.maxQueuedLaunches to allow more`。
|
|
131
|
+
- 等待上限 `launchQueueTimeoutMs`(默认 **900000**)。在**出队时刻**判定超时,文案
|
|
132
|
+
`launch queue timed out after <N>s ('<name>')`。
|
|
133
|
+
- 两者都**绝不**创建账本行/目录/session 文件;都必须留下可断言痕迹:
|
|
134
|
+
溢出抛 `SubagentError(msg, BUDGET_EXCEEDED)`;超时把 queued 记录标 `retired` +
|
|
135
|
+
`onLaunchQueueNotice` 一行 + `LaunchQueueEvent{phase:"expired"}`(D9)。
|
|
136
|
+
**不变量**:只有「本来会被**可排队**闸门拒绝」的 launch 才可能入队;槽位空闲时永远走快路径(D2-b)。
|
|
137
|
+
**失败模式**:溢出判定做在准入路径上 → 32 个排队项的父被自己的第 33 个 launch 打断;
|
|
138
|
+
或超时用 `setTimeout` 直接 reject 一个正在 admit 的项(见 R7)。
|
|
139
|
+
**验收**:A10、A3。
|
|
140
|
+
|
|
141
|
+
#### D6-a — 澄清(D6 与 D10 的命名冲突)
|
|
142
|
+
D6 倾向中性名 `launchQueue.maxPending`,D10 冻结 `inprocess.maxQueuedLaunches`。
|
|
143
|
+
**裁定:设置键以 D10 为准**(`subagents.inprocess.maxQueuedLaunches` /
|
|
144
|
+
`subagents.inprocess.launchQueueTimeoutMs`),因为 D10 是设置键决策且要与 `docs/inprocess-supervisor.md`
|
|
145
|
+
的 Limit keys 表、§11.1 哨兵同步登记;`maxPending` 只作为**内部字段名**(`AdmissionQueueOptions.maxPending`)。
|
|
146
|
+
列为 §12 O2 备案。
|
|
147
|
+
|
|
148
|
+
### D7 — abort flush
|
|
149
|
+
**判定**:以下三者必须把队列清空且**零 spawn**:
|
|
150
|
+
1. 父 turn 的 `signal` abort(`interruptedResult()` 路径,`index.ts:1853-1863`);
|
|
151
|
+
2. `subagent({action:"stop", name})` 指向一个尚未准入的排队项;
|
|
152
|
+
3. session 退出(`shutdownRetireAll`)。
|
|
153
|
+
`stop` 一个排队项 = 直接出队(记录标 `retired`、`retiredAt`、无 spawn、无账本行)。
|
|
154
|
+
**Esc/wait 中断不清空队列**(沿用 `esc-no-retire-v3`:Esc 只结束父 turn 的等待,child 继续)。
|
|
155
|
+
**失败模式**:abort 后队列仍在 pump → 父 turn 已结束却冒出 `▶ x started after queued 3s`。
|
|
156
|
+
**验收**:A4、A5。
|
|
157
|
+
|
|
158
|
+
### D8 — 顺序与 `async:false`
|
|
159
|
+
**判定**:
|
|
160
|
+
- `async: true`(默认)语义不变:`launch()` 立刻返回 queued handle,队列自己推进。
|
|
161
|
+
- `wait`/`collect` 对排队名**可见**:`collect(name)` 阻塞直到「准入 + 本轮 settle」,
|
|
162
|
+
受 D6 超时约束;超时/被清空/被 stop 时 `collect` 抛
|
|
163
|
+
`SubagentError(<queue message>, BUDGET_EXCEEDED)`。
|
|
164
|
+
- `async: false`:逐个「准入→再收集」(现有 `collectInline` 顺序不变),并在该子项收集结束后
|
|
165
|
+
追加一行真实等待时长 `↳ <name> waited <N>s in the launch queue`(读 `ChildRecord.queueWaitedMs`)。
|
|
166
|
+
**失败模式**:`collect` 在未准入时去 `parseSessionFile`/`waitSettled` → 对不存在的 session 空转、
|
|
167
|
+
对未注册的 child 抛 NOT_FOUND。
|
|
168
|
+
**验收**:A12、A20。
|
|
169
|
+
|
|
170
|
+
#### D8-a — `wait` 的边界
|
|
171
|
+
`wait` 的目标解析走 `runtime.activeJobs()`;排队项在 `async` 下已被 `followLaunched` 追踪,
|
|
172
|
+
所以 `wait` 天然可用,无需新代码 —— 但**必须**保证 `followJob` 的 `collect` 闭包走 D8 的
|
|
173
|
+
「等准入」路径,否则 wait 会在第一轮就拿到 `collect failed`。
|
|
174
|
+
**验收**:A12。
|
|
175
|
+
|
|
176
|
+
### D9 — 观测
|
|
177
|
+
**判定(四处,缺一不可)**:
|
|
178
|
+
1. 启动行:`▶ scout-7 (scout) supervisor=inprocess queued(3)`(在原行尾追加 ` queued(<pos>)`)。
|
|
179
|
+
2. 准入行:`▶ scout-7 started after queued 42s`(`<N>` = `Math.round(waitedMs/1000)`)。
|
|
180
|
+
送达通道:`OrchestratorDeps.onLaunchQueueNotice?: (line: string) => void`,index.ts 接到
|
|
181
|
+
`pi.sendMessage`(root UI 另接 status pill);`async:false` 同时进 tool result。
|
|
182
|
+
3. UI pill:新函数 `setLaunchQueueListener`(**不要改** verify 的 `setVerifyQueueListener`),
|
|
183
|
+
文案 `legion-queue: 3 queued · 8/8 slots busy`(`slots` = 当前被卡的闸门上限,取不到的维度省略)。
|
|
184
|
+
4. `subagent({action:"status"})` 必须把排队项渲染成 `state=queued`(**不是** `unknown`/NOT_FOUND),
|
|
185
|
+
`supervisor=<预期后端>`,且不打印 quiet/memory 行(`alive=false` 时既有实现已跳过)。
|
|
186
|
+
**为什么不是另一种方案**:复用 verify 的轮询 listener 会把「事件驱动」退回「每 `POLL_MS` 唤醒一次」,
|
|
187
|
+
与 F14 已记录的轮询缺陷一致,且 verify 的 phase 语义(waiting/acquired/released/overflow)与准入队列不同构。
|
|
188
|
+
**验收**:A10(文案)、A11、A13。
|
|
189
|
+
|
|
190
|
+
### D10 — 设置键
|
|
191
|
+
**判定**:新增两个 `subagents.inprocess.*` 键,`positiveInt` 解析(**0 非法**):
|
|
192
|
+
| 键 | 默认 | 语义 |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `inprocess.maxQueuedLaunches` | **32** | 同时驻留的排队项上限;RPC/账本路径复用同一个值(D2 单一调度器) |
|
|
195
|
+
| `inprocess.launchQueueTimeoutMs` | **900000** | 单项最长等待 |
|
|
196
|
+
必须同步登记:`docs/inprocess-supervisor.md` 的 **Limit keys (independent buckets)** 表(`:56` 起的表)
|
|
197
|
+
与 `docs/legion-v2-contract.md` §11.1(哨兵 `test/unit/legion-schema-sentinel.test.ts` 加一行断言)。
|
|
198
|
+
**不变量**:既有三行默认值(4/8/30)**逐字不变**(F12)。
|
|
199
|
+
**失败模式**:用 `nonNegativeInt` 解析 → 0 变成「队列禁用」而静默吞掉所有排队(0 的语义留给后续 issue)。
|
|
200
|
+
**验收**:A14、A8(哨兵仍绿)。
|
|
201
|
+
|
|
202
|
+
### D11 — `maxChildrenPerNode: 0` 特殊处理
|
|
203
|
+
**判定**:`inprocess.maxChildrenPerNode === 0` 或账本 `maxChildrenPerNode === 0` 时,
|
|
204
|
+
命中扇出门**直接硬拒**,**不入队**;文案沿用原 cap 文案。
|
|
205
|
+
**为什么**:0 = 「禁止派任何子节点」(F11),语义上永不可满足;入队只会让每个子项等 15 分钟后超时,
|
|
206
|
+
把用户的配置错误伪装成延迟。
|
|
207
|
+
**不变量**:0 判定必须在**入队决策之前**,并且在 `AdmissionVerdict` 里用独立硬拒 gate 名
|
|
208
|
+
(`inprocess.maxChildrenPerNode.disabled` / `ledger.maxChildrenPerNode.disabled`),便于断言与诊断。
|
|
209
|
+
**验收**:A9。
|
|
210
|
+
|
|
211
|
+
### D12 — 事件(只加两个进程级)
|
|
212
|
+
**判定**:`LEGION_PROCESS_EVENT_TYPES`(`src/extension/event-bus.ts:10-21`)新增
|
|
213
|
+
`launch_queued`、`launch_admitted`(**仅此两个**);`src/legion/events.ts` 的持久化闭集**不动**。
|
|
214
|
+
- `launch_queued` data:`{ name, parentKey, position, queueSize, maxPending, gate, dimension, message }`
|
|
215
|
+
- `launch_admitted` data:`{ name, parentKey, waitedMs, position }`
|
|
216
|
+
**为什么**:持久化闭集漏加会永久冻结 DB 事件游标(#233,F13);而队列状态是**本进程瞬时**事实,
|
|
217
|
+
跨进程无意义(重启即丢,§10)。
|
|
218
|
+
**失败模式**:顺手把 `launch_queue_timeout` 也加进总线 → 违反「只有两个」;超时/停止走 D9 的
|
|
219
|
+
notice + listener(无事件)。
|
|
220
|
+
**验收**:A13、A8(`test/unit/legion-events.test.ts` / `legion-event-bus.test.ts` 不红)。
|
|
221
|
+
|
|
222
|
+
## 3. 闸门 → 可排队性对照表(D1 完整枚举)
|
|
223
|
+
|
|
224
|
+
| 闸门 | 后端 | 默认 | 判定文案(逐字) | 可排队 | 维度 |
|
|
225
|
+
|---|---|---|---|---|---|
|
|
226
|
+
| `inprocess.maxChildrenPerNode` | inprocess | 8 | `inprocess maxChildrenPerNode reached for '<parent>' (n/8)` | ✅ | fanout(per parentKey) |
|
|
227
|
+
| `inprocess.maxActive` | inprocess | 30(有块缺省 8) | `inprocess maxActive reached (n/30); raise subagents.inprocess.maxActive to allow more` | ✅ | active(全局) |
|
|
228
|
+
| `legion.maxChildrenPerNode`(`max_children_per_node`) | rpc | 8 | `legion launch refused (max_children_per_node): parent already has n live children; maxChildrenPerNode is 8` | ✅ | fanout |
|
|
229
|
+
| `legion.maxActiveNodes`(`max_active_nodes`) | rpc | 30 | `legion launch refused (max_active_nodes): tree already has n active nodes; maxActiveNodes is 30` | ✅ | active |
|
|
230
|
+
| `inprocess.maxSpawnsPerSession` | inprocess | 无 | `inprocess spawn budget exhausted (n/N); raise subagents.inprocess.maxSpawnsPerSession to allow more` | ❌ 硬拒 | — |
|
|
231
|
+
| `inprocess.maxDepth` | inprocess | 4 | `inprocess nesting limit reached (depth d, max 4); raise subagents.inprocess.maxDepth to allow deeper nesting` | ❌ 硬拒 | — |
|
|
232
|
+
| `legion.maxDepth`(`max_depth`) | rpc | 4 | `legion launch refused (max_depth): …` | ❌ 硬拒 | — |
|
|
233
|
+
| `parent_not_found` | rpc | — | `legion launch refused (parent_not_found): …` | ❌ 硬拒(永久) | — |
|
|
234
|
+
| `maxSpawns`(`maxSubagentSpawnsPerSession`) | 两后端 | 无 | `subagent spawn budget exhausted (n/N); …` | ❌ 硬拒 | — |
|
|
235
|
+
| Orchestrator `maxDepth` | 两后端 | ≤4 硬顶 | `subagent nesting limit reached (depth d, max C); …` | ❌ 硬拒 | — |
|
|
236
|
+
| memory gate | rpc | — | `INSUFFICIENT_MEMORY` 原文 | ❌ 硬拒 | — |
|
|
237
|
+
| name collision | 两后端 | — | `NAME_TAKEN` 原文 / `child is already alive` | ❌ 硬拒 | — |
|
|
238
|
+
| unknown agent / disabled role / model scope | index | — | 现有 `✗ …` 行 | ❌ 硬拒(在 `launchStep`,不入 Orchestrator) | — |
|
|
239
|
+
| `inprocess` + `worktree:true` / `kind!==pi` | 两后端 | — | `INVALID_PARAMS` 原文 | ❌ 硬拒 | — |
|
|
240
|
+
| terminal child resume | legion | — | `legion child is terminal and cannot be resumed` | ❌ 硬拒(永久) | — |
|
|
241
|
+
| **`maxChildrenPerNode === 0`** | 两后端 | — | 同原 cap 文案 | ❌ 硬拒(D11) | — |
|
|
242
|
+
|
|
243
|
+
> 判据的唯一来源是 `checkLaunchBudget` 的 `reason` 与 `evaluateInProcessAdmission` 的
|
|
244
|
+
> `gate` 字段,**不是** `Error.code`(D1-b)。
|
|
245
|
+
|
|
246
|
+
## 4. 冻结接口
|
|
247
|
+
|
|
248
|
+
### 4.1 新模块 `src/supervisor/admission-queue.ts`(纯:无 fs / 无 db / 无 timer / 无 process)
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import type { ChildLaunchContext } from "./child-context.ts"; // 仅类型,payload 用
|
|
252
|
+
|
|
253
|
+
export const DEFAULT_LAUNCH_QUEUE_MAX_PENDING = 32;
|
|
254
|
+
export const DEFAULT_LAUNCH_QUEUE_TIMEOUT_MS = 900_000;
|
|
255
|
+
|
|
256
|
+
export type AdmissionDimension = "fanout" | "active";
|
|
257
|
+
|
|
258
|
+
export type QueueableGate =
|
|
259
|
+
| "inprocess.maxChildrenPerNode"
|
|
260
|
+
| "inprocess.maxActive"
|
|
261
|
+
| "ledger.maxChildrenPerNode"
|
|
262
|
+
| "ledger.maxActiveNodes";
|
|
263
|
+
|
|
264
|
+
export type HardGate =
|
|
265
|
+
| "inprocess.maxSpawnsPerSession"
|
|
266
|
+
| "inprocess.maxDepth"
|
|
267
|
+
| "inprocess.maxChildrenPerNode.disabled"
|
|
268
|
+
| "ledger.maxChildrenPerNode.disabled"
|
|
269
|
+
| "legion.maxDepth"
|
|
270
|
+
| "legion.parentNotFound"
|
|
271
|
+
| "orchestrator.maxDepth"
|
|
272
|
+
| "orchestrator.maxSpawns"
|
|
273
|
+
| "other";
|
|
274
|
+
|
|
275
|
+
export type AdmissionVerdict =
|
|
276
|
+
| { readonly admitted: true }
|
|
277
|
+
| {
|
|
278
|
+
readonly admitted: false;
|
|
279
|
+
readonly queueable: true;
|
|
280
|
+
readonly gate: QueueableGate;
|
|
281
|
+
readonly dimension: AdmissionDimension;
|
|
282
|
+
readonly message: string;
|
|
283
|
+
}
|
|
284
|
+
| { readonly admitted: false; readonly queueable: false; readonly gate: HardGate; readonly message: string };
|
|
285
|
+
|
|
286
|
+
/** in-process 计数快照(由 InProcessSupervisor 提供,只读)。 */
|
|
287
|
+
export interface InProcessAdmissionCounts {
|
|
288
|
+
readonly parentKey: string;
|
|
289
|
+
/** 该 parentKey 下的 live 子节点数(过滤与 assertWithinLimits 完全一致)。 */
|
|
290
|
+
readonly siblings: number;
|
|
291
|
+
/** 全部 live 子节点数。 */
|
|
292
|
+
readonly active: number;
|
|
293
|
+
readonly spawnCount: number;
|
|
294
|
+
/** inProcessDepth()(调用时刻)。 */
|
|
295
|
+
readonly depth: number;
|
|
296
|
+
readonly limits: {
|
|
297
|
+
readonly maxDepth: number;
|
|
298
|
+
readonly maxChildrenPerNode: number;
|
|
299
|
+
readonly maxActive: number;
|
|
300
|
+
readonly maxSpawnsPerSession?: number;
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 唯一闸门判定实现(纯)。`inflight` = Orchestrator 已探测通过但尚未出现在
|
|
306
|
+
* counts 里的准入(见 D2-b);只影响 fanout/active 两个可排队维度。
|
|
307
|
+
* 判定顺序必须与 assertWithinLimits 现状一致:
|
|
308
|
+
* maxSpawnsPerSession → maxActive → maxDepth → maxChildrenPerNode。
|
|
309
|
+
*/
|
|
310
|
+
export function evaluateInProcessAdmission(
|
|
311
|
+
counts: InProcessAdmissionCounts,
|
|
312
|
+
inflight?: { readonly fanout: number; readonly active: number },
|
|
313
|
+
): AdmissionVerdict;
|
|
314
|
+
|
|
315
|
+
/** 冻结文案构造器(A6 逐字断言)。 */
|
|
316
|
+
export function inProcessSpawnsMessage(spawnCount: number, limit: number): string;
|
|
317
|
+
export function inProcessActiveMessage(active: number, limit: number): string;
|
|
318
|
+
export function inProcessDepthMessage(depth: number, limit: number): string;
|
|
319
|
+
export function inProcessFanoutMessage(parentKey: string, siblings: number, limit: number): string;
|
|
320
|
+
export function queueOverflowMessage(size: number, maxPending: number): string;
|
|
321
|
+
export function queueTimeoutMessage(name: string, waitedMs: number): string;
|
|
322
|
+
|
|
323
|
+
// ── 队列本体 ──────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export interface QueuedLaunch<T = unknown> {
|
|
326
|
+
readonly name: string;
|
|
327
|
+
readonly parentKey: string;
|
|
328
|
+
readonly seq: number;
|
|
329
|
+
readonly enqueuedAt: number;
|
|
330
|
+
readonly payload: T;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface AdmissionQueueOptions {
|
|
334
|
+
readonly maxPending?: number;
|
|
335
|
+
readonly timeoutMs?: number;
|
|
336
|
+
/** 注入时钟;默认 Date.now。纯模块唯一的时间来源。 */
|
|
337
|
+
readonly now?: () => number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export type EnqueueOutcome<T> =
|
|
341
|
+
| { readonly ok: true; readonly item: QueuedLaunch<T>; readonly position: number }
|
|
342
|
+
| { readonly ok: false; readonly kind: "overflow"; readonly message: string };
|
|
343
|
+
|
|
344
|
+
export type NextAdmission<T> =
|
|
345
|
+
| { readonly kind: "none" }
|
|
346
|
+
| { readonly kind: "admit"; readonly item: QueuedLaunch<T>; readonly waitedMs: number }
|
|
347
|
+
| { readonly kind: "expired"; readonly item: QueuedLaunch<T>; readonly waitedMs: number; readonly message: string };
|
|
348
|
+
|
|
349
|
+
export interface AdmissionQueueEntry {
|
|
350
|
+
readonly name: string;
|
|
351
|
+
readonly parentKey: string;
|
|
352
|
+
readonly seq: number;
|
|
353
|
+
readonly position: number; // 1-based
|
|
354
|
+
readonly waitedMs: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface AdmissionQueue<T = unknown> {
|
|
358
|
+
readonly size: number;
|
|
359
|
+
readonly maxPending: number;
|
|
360
|
+
readonly timeoutMs: number;
|
|
361
|
+
enqueue(payload: T, meta: { name: string; parentKey: string }): EnqueueOutcome<T>;
|
|
362
|
+
positionOf(name: string): number | undefined;
|
|
363
|
+
waitedMs(name: string): number | undefined;
|
|
364
|
+
/** 出队并返回(stop / abort flush)。不存在返回 undefined。 */
|
|
365
|
+
remove(name: string): QueuedLaunch<T> | undefined;
|
|
366
|
+
/** 清空并返回(按 seq 升序)。 */
|
|
367
|
+
flush(): QueuedLaunch<T>[];
|
|
368
|
+
/** 纯决策:seq 最小且 probe 通过者;若 seq 最小项已超时则先报 expired。 */
|
|
369
|
+
next(probe: (item: QueuedLaunch<T>) => AdmissionVerdict): NextAdmission<T>;
|
|
370
|
+
entries(): readonly AdmissionQueueEntry[];
|
|
371
|
+
/** 是否还有未到期的等待项 → 决定 Orchestrator 是否保留一个 unref 定时器。 */
|
|
372
|
+
nextDeadlineAt(): number | undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function createAdmissionQueue<T = unknown>(options?: AdmissionQueueOptions): AdmissionQueue<T>;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 进程级注册表:同一父 run 的所有 Orchestrator 实例共享一个队列(D5 全序 + 跨实例 stop)。
|
|
379
|
+
* 存 globalThis + Symbol.for —— 与 spawn-default-inprocess-v3 D5/REV2-4 同因(jiti moduleCache:false)。
|
|
380
|
+
*/
|
|
381
|
+
export function acquireAdmissionQueue<T = unknown>(
|
|
382
|
+
scope: string,
|
|
383
|
+
options?: AdmissionQueueOptions,
|
|
384
|
+
): AdmissionQueue<T>;
|
|
385
|
+
export function resetAdmissionQueuesForTests(): void;
|
|
386
|
+
|
|
387
|
+
/** D9:进程内 UI 钩子(与 verify-queue 的 listener 同形态、不同函数)。 */
|
|
388
|
+
export interface LaunchQueueEvent {
|
|
389
|
+
readonly phase: "queued" | "admitted" | "expired" | "overflow" | "stopped" | "flushed";
|
|
390
|
+
readonly name?: string;
|
|
391
|
+
readonly position?: number;
|
|
392
|
+
readonly waitedMs?: number;
|
|
393
|
+
readonly queued: number;
|
|
394
|
+
readonly slotsInUse?: number;
|
|
395
|
+
readonly slots?: number;
|
|
396
|
+
readonly message?: string;
|
|
397
|
+
}
|
|
398
|
+
export function setLaunchQueueListener(fn: ((event: LaunchQueueEvent) => void) | undefined): void;
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### 4.2 `InProcessSupervisor` 增量(`src/supervisor/inprocess-supervisor.ts`)
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
export interface InProcessSlotEvent {
|
|
405
|
+
readonly name: string;
|
|
406
|
+
/** 与 assertWithinLimits 的 maxChildrenPerNode 分组键完全一致。 */
|
|
407
|
+
readonly parentKey: string;
|
|
408
|
+
/** 只有 retired / exited 释放槽位;idle 仍占槽(F9)。 */
|
|
409
|
+
readonly state: "retired" | "exited";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export interface InProcessSupervisorOptions {
|
|
413
|
+
// …既有字段不变…
|
|
414
|
+
/** D2:child 离开 live 集合时触发(retire 收尾、rollbackSpawn 收尾、disposeRecord)。 */
|
|
415
|
+
onSlotReleased?: (event: InProcessSlotEvent) => void;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export class InProcessSupervisor {
|
|
419
|
+
/** 只读探测:给定名字现在会不会被闸门拒绝。绝不抛、绝不改状态。 */
|
|
420
|
+
preflightAdmission(name: string): AdmissionVerdict;
|
|
421
|
+
/** 只读计数(含 limits 与当前 depth),供 Orchestrator 叠加 inflight。 */
|
|
422
|
+
admissionCounts(name: string): InProcessAdmissionCounts;
|
|
423
|
+
// …其余 API 不变…
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**实现约束**:`assertWithinLimits` 改为
|
|
428
|
+
`const v = evaluateInProcessAdmission(this.admissionCounts(name)); if (!v.admitted) { this.onSpawnRefused?.(v.message); throw new SubagentError(v.message, ErrorCodes.BUDGET_EXCEEDED); }`。
|
|
429
|
+
`onSlotReleased` 的发射点(三处,全部幂等):
|
|
430
|
+
`retireChild` 的 `finally`(`:1428-1431`)、`rollbackSpawn` 的 `finally`(`:1249-1254`)、
|
|
431
|
+
`disposeRecord`(`:1434-1442`)。`updateTurnState` 的 `idle` 分支(`:1318`)**不**发射。
|
|
432
|
+
|
|
433
|
+
### 4.3 Orchestrator 接入点(`src/runs/orchestrator.ts`)
|
|
434
|
+
|
|
435
|
+
**插入位置(冻结)**:`launch()` 体内,在
|
|
436
|
+
`if (inProcess && worktree) { throw … }`(`:664-669`)**之后**、
|
|
437
|
+
`let worktreePath = previous?.worktreePath;`(`:670`)与 `try {`(`:671`)**之前**。
|
|
438
|
+
即:名字已分配(`:585-587`)、后端已仲裁(`:630-655`)、`assertWithinBudgets` 与 `isAlive`
|
|
439
|
+
已通过(`:656-657`)、inprocess+worktree 已硬拒(`:664-669`)、**任何副作用之前**(worktree / run / session / reserveNode)。
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
export interface OrchestratorDeps {
|
|
443
|
+
// …既有字段不变…
|
|
444
|
+
/** D6/D10:队列边界。缺省取 admission-queue.ts 的默认值。 */
|
|
445
|
+
launchQueue?: { maxPending?: number; timeoutMs?: number };
|
|
446
|
+
/** D9:用户可见的一行排队/准入/超时/停止通知。 */
|
|
447
|
+
onLaunchQueueNotice?: (line: string) => void;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 排队项的 payload(Orchestrator 私有类型,冻结形状)。 */
|
|
451
|
+
export interface LaunchQueuePayload {
|
|
452
|
+
readonly name: string;
|
|
453
|
+
readonly parentKey: string;
|
|
454
|
+
readonly inProcess: boolean;
|
|
455
|
+
/** 入队时的 ALS 深度;准入必须用 runAtAbsoluteDepth 压回。 */
|
|
456
|
+
readonly parentDepth: number;
|
|
457
|
+
/** 入队时的 ALS 身份;准入必须用 runInChildContext 压回。 */
|
|
458
|
+
readonly parentContext?: ChildLaunchContext;
|
|
459
|
+
/** 执行真正的 launch(admissionAlreadyGranted=true)。 */
|
|
460
|
+
run(): Promise<Handle>;
|
|
461
|
+
/** 项离开队列(准入/超时/停止/清空)时结算。 */
|
|
462
|
+
readonly settled: Promise<AdmitOutcome>;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export type AdmitOutcome =
|
|
466
|
+
| { readonly kind: "admitted"; readonly waitedMs: number }
|
|
467
|
+
| { readonly kind: "rejected"; readonly reason: "timeout" | "stopped" | "flushed"; readonly message: string };
|
|
468
|
+
|
|
469
|
+
export class Orchestrator {
|
|
470
|
+
/** D7:清空队列且不 spawn;返回被清掉的名字(按 seq 升序)。 */
|
|
471
|
+
flushLaunchQueue(reason: string): string[];
|
|
472
|
+
/** D9:只读快照(跨实例,读注册表)。 */
|
|
473
|
+
launchQueueEntries(): readonly { name: string; parentKey: string; position: number; waitedMs: number }[];
|
|
474
|
+
// 私有(形状冻结,不对外)
|
|
475
|
+
// private probeAdmission(name, ctx): AdmissionVerdict
|
|
476
|
+
// private enqueueLaunch(input, name, verdict): Promise<Handle>
|
|
477
|
+
// private async pumpAdmissions(): Promise<void>
|
|
478
|
+
// private readonly admitting = new Map<string, { parentKey: string; dimension: AdmissionDimension }>()
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
**`ChildRecord.state` 写法(冻结)**:
|
|
483
|
+
- 入队:`state = "queued"`;`spawnMode = mode`(预期后端);`queuePosition = <1-based>`;
|
|
484
|
+
`spawnedAt = new Date(now)`;`sessionFile = path.join(this.runDir, name + ".jsonl")`(占位);
|
|
485
|
+
legion 路径下 `nodeId = nodeIdForChild(parentNodeId, name)`(确定性 id,**不建行**);
|
|
486
|
+
**不**调用 `recordChild`(不增加 `this.spawned`)、**不**碰 `onChildUpdate` 之外的持久化。
|
|
487
|
+
- 准入:`pumpAdmissions` 先 `delete queued 记录`,再以
|
|
488
|
+
`launchInternal(input, { admissionAlreadyGranted: true })` 重跑;新纪录
|
|
489
|
+
`state = "launching"/"working"`(既有路径)、`queueWaitedMs = waitedMs`。
|
|
490
|
+
- 超时/停止/清空:`state = "retired"`、`retiredAt = now`、无 `execution`。
|
|
491
|
+
|
|
492
|
+
**`previous` 计算必须排除 queued 记录(冻结)**:
|
|
493
|
+
```ts
|
|
494
|
+
const priorRecord = this.children.get(name);
|
|
495
|
+
const previous = priorRecord && priorRecord.state !== "queued"
|
|
496
|
+
? priorRecord
|
|
497
|
+
: (ledgerPrevious ? this.childFromNode(name, ledgerPrevious) : undefined);
|
|
498
|
+
```
|
|
499
|
+
(否则 queued 的**占位 sessionFile** 会被当成 resume 凭据;D3-a 的风险点。)
|
|
500
|
+
|
|
501
|
+
#### D2-b — 快路径不许多一个 await(既有测试不红的根)
|
|
502
|
+
**判定**:槽位可用时,`launch()` 的行为与 master **逐字节一致**:探测是同步的
|
|
503
|
+
(`admissionCounts`/`checkLaunchBudget` 都是同步读),入队路径完全不被触碰,不新增微任务。
|
|
504
|
+
`admitting` 计数器在**探测通过的同一次同步执行内**插入(`this.admitting.set(name, …)`),
|
|
505
|
+
在 `spawnChild` resolve 后、`recordChild` 之后删除。
|
|
506
|
+
**为什么必须**:同 tick 的 12 路 fanout,前 8 个 `launch()` 的第一次 await 之前并**没有**
|
|
507
|
+
任何 child 注册进 `this.children`(`spawnChild` 的 `children.set` 在 `persistParentReservation`
|
|
508
|
+
之后才跑)。若只靠计数快照,12 路探测全部看到 0 → 全部放行 → 回到今天的 4 个报错。
|
|
509
|
+
`admitting`(in-flight 保留位)是同 tick 只允许 8 个进入的唯一机制。
|
|
510
|
+
**精确性不承担正确性**:探测只是**咨询性**的;真正权威仍是 `assertWithinLimits`/`withLaunchBudget`。
|
|
511
|
+
探测通过后仍被真闸门拒 → **不计报错**,把项重新入队并**保留原 `enqueuedAt`**(排到队尾,但等待计时与超时不重置),等下一次触发(D2-c)。
|
|
512
|
+
**验收**:A1、A2、A18。
|
|
513
|
+
|
|
514
|
+
#### D2-c — 探测后仍被拒的处理
|
|
515
|
+
**判定**:`launchInternal(…, {admissionAlreadyGranted:true})` 抛出的错误若 `code === BUDGET_EXCEEDED`
|
|
516
|
+
且**能归因到可排队闸门**(由同一 `probeAdmission` 复判,此时其它 launch 已落子,归因可靠)→
|
|
517
|
+
`pumpAdmissions` 捕获、重新入队、`this.pumping = false`、**不**立即再 pump(等下一个触发)。
|
|
518
|
+
其余错误(含硬拒闸门、NAME_TAKEN、START_FAILED)照原样冒泡。
|
|
519
|
+
|
|
520
|
+
**重入队保什么、不保什么(2026-10-23 父会话裁决,修订本条的"原 `seq`"措辞)**
|
|
521
|
+
- **保 `enqueuedAt`**(等待计时与 D6 超时截止线)。这是 R7 的实质内容:若重入队盖上新的时间戳,
|
|
522
|
+
`waitedMs` 会归零(`async:false` 的 `↳ <名称> waited Ns` 会**低报**真实等待),且该项会**重新获得**
|
|
523
|
+
一个完整 `timeoutMs`,于是「一直输掉准入竞态」的项实际等待可达配置上限的近两倍。
|
|
524
|
+
实现:`AdmissionQueue.enqueue(payload, meta)` 的 `meta.enqueuedAt` 可选覆盖。
|
|
525
|
+
- **不保 `seq`**:重入队排到队尾。D5 的 FIFO 不赋予任何人优先权,一个刚在准入竞态里输掉的项
|
|
526
|
+
本就应该让位给等得更久的人;「不被饿死」由 `launchQueueTimeoutMs` 保证,不需要靠保位实现。
|
|
527
|
+
(原措辞「按原 `seq` 重新入队」要求队列提供 `enqueueWithSeq`,为仅有的优先权收益引入一套
|
|
528
|
+
有序插入 + `seq` 跨准入/失败存活的机制,代价与收益不成比例,故不采纳。)
|
|
529
|
+
**失败模式**:重入队后立刻再 pump → 忙等自旋(探测与真闸门短暂不自洽时 CPU 打满)。
|
|
530
|
+
**验收**:A7(rpc 路径覆盖这条)。
|
|
531
|
+
|
|
532
|
+
#### D2-d — pump 的触发源(事件优先 + 单个兜底定时器)
|
|
533
|
+
1. `enqueue` 成功之后(本次入队可能刚好有槽位:虽然入队意味着探测没过,但同批前面的项可能已释放);
|
|
534
|
+
2. `InProcessSupervisor.onSlotReleased` → `schedulePump()`;
|
|
535
|
+
3. Orchestrator 自己观察到子项 `retired`/`exited`(`retire()` 与 `recordSettled` 之后);
|
|
536
|
+
4. **兜底**:队列非空时挂**一个** `unref()` 定时器,到期时刻 = `min(队列最早到期时间, now + launchQueueRecheckMs)`
|
|
537
|
+
(`launchQueueRecheckMs` 固定 1000,写死在 `admission-queue.ts` 并导出,不新增设置键)。
|
|
538
|
+
理由:账本槽位可被**另一个进程**(sibling session / hygiene)释放,本进程收不到事件(§11 R2)。
|
|
539
|
+
**不变量**:队列空 → 立即清定时器;任何时刻最多一个定时器;`pumpAdmissions` 用 `pumping` 互斥。
|
|
540
|
+
|
|
541
|
+
### 4.4 `Handle` / `ChildRecord` / `TaskState` 增量(`src/shared/types.ts`)
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
export type TaskState =
|
|
545
|
+
| "queued" // 新增:已分配名字、无账本行、无 session 文件(D3)
|
|
546
|
+
| "launching"
|
|
547
|
+
| "working"
|
|
548
|
+
| "awaiting"
|
|
549
|
+
| "blocked"
|
|
550
|
+
| "retired"
|
|
551
|
+
| "exited";
|
|
552
|
+
|
|
553
|
+
export interface ChildRecord {
|
|
554
|
+
// …既有字段不变…
|
|
555
|
+
/** D4:入队瞬间的 1-based 队列位次;准入后清除。 */
|
|
556
|
+
queuePosition?: number;
|
|
557
|
+
/** D6/D8:实际排队等待毫秒(准入时写入;从未排队则缺省)。 */
|
|
558
|
+
queueWaitedMs?: number;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export interface Handle {
|
|
562
|
+
// …既有字段不变…
|
|
563
|
+
/**
|
|
564
|
+
* D4:true 表示本次 launch 仍在队列里、尚未 spawn。
|
|
565
|
+
* `child.state === "queued"`、`sessionFile` 是占位路径、`pid` 必然缺省。
|
|
566
|
+
*/
|
|
567
|
+
queued?: boolean;
|
|
568
|
+
/** D4:入队瞬间的 1-based 位次(`queued === true` 时必有)。 */
|
|
569
|
+
queuePosition?: number;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export interface InProcessLimits {
|
|
573
|
+
// …既有字段不变…
|
|
574
|
+
/** D10:队列上限(positiveInt,默认 32)。 */
|
|
575
|
+
maxQueuedLaunches?: number;
|
|
576
|
+
/** D10:单项最长等待毫秒(positiveInt,默认 900000)。 */
|
|
577
|
+
launchQueueTimeoutMs?: number;
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
配套改动(必须同批,否则 `queued` 是幽灵状态):
|
|
582
|
+
| 文件 | 行 | 改动 |
|
|
583
|
+
|---|---|---|
|
|
584
|
+
| `src/legion/rebuild.ts` | `:343` 前 | `if (child.state === "queued") continue;`(排队项不重建进 tree,F18/D3) |
|
|
585
|
+
| `src/extension/recycle.ts` | `:18-19` | 把 `"queued"` 加进「不可回收」判定(排队项无 session 可回收) |
|
|
586
|
+
| `index.ts` | `:2963` `listLiveChildrenForStatus` | 保持不变(queued 天然被视为 live → 出现在 status 概览,D9) |
|
|
587
|
+
| `src/extension/runtime.ts` | `:1541-1543` | 只读确认:tree 状态映射不会把 `queued` 误映射;若需要则显式跳过 |
|
|
588
|
+
| `src/legion/nodes.ts` | — | **不动**(D3/F16) |
|
|
589
|
+
|
|
590
|
+
### 4.5 Settings(`src/agents/settings.ts` + 文档)
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
// parseInProcessLimits 内新增两段(positiveInt,0 非法)
|
|
594
|
+
const maxQueuedLaunches = positiveInt(value.maxQueuedLaunches, "inprocess.maxQueuedLaunches", filePath);
|
|
595
|
+
if (maxQueuedLaunches !== undefined) out.maxQueuedLaunches = maxQueuedLaunches;
|
|
596
|
+
const launchQueueTimeoutMs = positiveInt(value.launchQueueTimeoutMs, "inprocess.launchQueueTimeoutMs", filePath);
|
|
597
|
+
if (launchQueueTimeoutMs !== undefined) out.launchQueueTimeoutMs = launchQueueTimeoutMs;
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
默认值来源单一:`admission-queue.ts` 的 `DEFAULT_LAUNCH_QUEUE_MAX_PENDING` / `DEFAULT_LAUNCH_QUEUE_TIMEOUT_MS`,
|
|
601
|
+
settings 侧不另建常量。
|
|
602
|
+
|
|
603
|
+
## 5. 状态机
|
|
604
|
+
|
|
605
|
+
```
|
|
606
|
+
┌──────────────── launch() 探测:可排队闸门命中 ───────────────┐
|
|
607
|
+
│ │
|
|
608
|
+
(调用方) ──launch()──►│ queued(ChildRecord.state="queued", 无账本行/无 session) │
|
|
609
|
+
│ │ │
|
|
610
|
+
│ ├── admit(pump: 探测通过 + runAtAbsoluteDepth/runInChildContext)
|
|
611
|
+
│ │ └──► launching ──► running ──► idle ──► retired
|
|
612
|
+
│ │ (既有 launch 路径;此步起才 reservation)
|
|
613
|
+
│ ├── launchQueueTimeoutMs 到期 ──► retired + notice(§D9) + listener(expired)
|
|
614
|
+
│ ├── stop(name) ───────────────► retired + listener(stopped)
|
|
615
|
+
│ ├── flush(signal abort / session exit) ──► retired + listener(flushed)
|
|
616
|
+
│ └── 探测后仍被真闸门拒 ───────► 重新入队(保持 seq),不报错
|
|
617
|
+
└────────────────────────────────────────────────────────────
|
|
618
|
+
槽位可用 ──launch()──► launching ──► running ──► idle ──► retired (master 路径,零改动)
|
|
619
|
+
溢出(size == maxPending)──► 立即抛 launch queue overflow,无记录、无 listener(overflow 广播)
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
| 迁移 | 驱动者(事件来源) |
|
|
623
|
+
|---|---|
|
|
624
|
+
| `(none) → queued` | `Orchestrator.launch` 探测命中可排队闸门(同步) |
|
|
625
|
+
| `queued → launching` | `pumpAdmissions`(触发:enqueue / `onSlotReleased` / 自身 retire / 兜底定时器) |
|
|
626
|
+
| `queued → 重入队` | 同一 pump 的 catch(D2-c) |
|
|
627
|
+
| `queued → retired(timeout)` | `queue.next()` 判 `enqueuedAt + timeoutMs <= now()` |
|
|
628
|
+
| `queued → retired(stopped)` | `Orchestrator.retire()` 的 queued 分支(`subagent stop`) |
|
|
629
|
+
| `queued → retired(flushed)` | `Orchestrator.flushLaunchQueue()`(signal abort / session shutdown) |
|
|
630
|
+
| `launching → running` | 既有 `recordLegionLaunch` / `recordChild`(未改) |
|
|
631
|
+
| `running → idle` | `agent_settled`(F8)——**不释放槽位**(F9) |
|
|
632
|
+
| `idle/running → retired` | 父 collect 后的 recycle / `retire()` / `retireAll()`(F8)→ **释放槽位** |
|
|
633
|
+
| 任意 → `exited` | spawn 失败 `rollbackSpawn`(F8)→ **释放槽位** |
|
|
634
|
+
|
|
635
|
+
## 6. 文件所有权(并行 worker)
|
|
636
|
+
|
|
637
|
+
> 规则:一个文件只有一个 owner;共享文件按 §7 顺序串行合并;跨 worktree 靠本契约的签名。
|
|
638
|
+
|
|
639
|
+
| Worker | 独占文件 | 依赖 |
|
|
640
|
+
|---|---|---|
|
|
641
|
+
| **W1 队列内核** | `src/supervisor/admission-queue.ts`(新)、`src/shared/types.ts`(`TaskState`/`Handle`/`ChildRecord`/`InProcessLimits` 增量)、`src/agents/settings.ts`(仅 `parseInProcessLimits` 两键)、`test/unit/admission-queue.test.ts`(新)、`test/unit/spawn-mode-settings.test.ts`(新增解析用例) | 无(本契约) |
|
|
642
|
+
| **W2 监督者钩子** | `src/supervisor/inprocess-supervisor.ts`、`test/unit/inprocess-supervisor.test.ts` | W1(`AdmissionVerdict`/`evaluateInProcessAdmission`/文案构造器) |
|
|
643
|
+
| **W3 编排器准入** | `src/runs/orchestrator.ts`、`src/legion/rebuild.ts`(queued 跳过)、`src/extension/recycle.ts`、`test/unit/orchestrator-admission.test.ts`(新)、`test/integration/launch-queue.test.ts`(新) | W1 + W2 |
|
|
644
|
+
| **W4 接线与文案** | `index.ts`(5 处 Orchestrator 接线、启动行、flush、listener、`onLaunchQueueNotice`)、`test/unit/teams.test.ts`(launch 行断言扩展)、`test/unit/legion-status.test.ts`(`state=queued`)、`test/unit/runtime.test.ts`(若需要) | W3 |
|
|
645
|
+
| **W5 文档与哨兵** | `docs/inprocess-supervisor.md`、`docs/legion-v2-contract.md`(§11.1 加一行)、`CHANGELOG.md`、`test/unit/legion-schema-sentinel.test.ts` | 可与 W3 并行(只读 W1 的类型名) |
|
|
646
|
+
|
|
647
|
+
**禁止任何 worker 改**:`test/helpers/*`、`src/legion/budget.ts`、`src/legion/nodes.ts`、
|
|
648
|
+
`src/legion/events.ts`、`src/supervisor/rpc-supervisor.ts`、`src/runs/verify-queue.ts`、
|
|
649
|
+
`src/supervisor/spawn-mode.ts`、`docs/contracts/*`。
|
|
650
|
+
|
|
651
|
+
### 6.1 五处 `new Orchestrator` 的接线责任(F17)
|
|
652
|
+
|
|
653
|
+
| 行 | 场景 | 责任 |
|
|
654
|
+
|---|---|---|
|
|
655
|
+
| `index.ts:1801` | 命令链 launch family(bug 主路径) | 传 `launchQueue`(settings 两键)+ `onLaunchQueueNotice`;abort 时 `flushLaunchQueue("parent turn ended")` |
|
|
656
|
+
| `index.ts:2396` | vexillatio utility family | 传 `launchQueue`(同一套边界)+ notice;中断时 `flushLaunchQueue`;排队项进 `stillRunning` |
|
|
657
|
+
| `index.ts:556` | `resumeLegionChild` | 传 `launchQueue`(resume 也可能撞 fanout 上限) |
|
|
658
|
+
| `index.ts:1517` | 控制动作(`collect`/`stop`/`steer`…) | 不传配置;靠进程级注册表(同 runDir 共享队列)把 `stop` 落到队列上 |
|
|
659
|
+
| `index.ts:1445` | `stop` 的 retire 适配器 | 同上;rebuild 出的 Orchestrator 通过注册表 dequeu |
|
|
660
|
+
|
|
661
|
+
## 7. 集成顺序
|
|
662
|
+
|
|
663
|
+
`W1 → W2 → W3 → W4`,`W5` 可与 `W3`/`W4` 并行(只依赖 W1 的类型名与 §4.5 的键名)。
|
|
664
|
+
W2/W3 不可并行:W3 import W2 的 `admissionCounts`/`preflightAdmission`。
|
|
665
|
+
W3/W4 不可并行:W4 的 `index.ts` import W3 的 `flushLaunchQueue`/`launchQueueEntries`。
|
|
666
|
+
冲突风险点(合并时优先看):
|
|
667
|
+
1. `src/shared/types.ts` —— 只有 W1 改;其他人若需要字段,向父会话要修订,不自改。
|
|
668
|
+
2. `src/runs/orchestrator.ts` —— 只有 W3 改;W4 只改 `index.ts`。
|
|
669
|
+
3. `test/unit/inprocess-supervisor.test.ts` —— 只有 W2 改(既有 33 例必须保持全绿)。
|
|
670
|
+
4. `test/unit/teams.test.ts` —— 只有 W4 改(launch 行断言);W3 的新断言写进自己的新文件。
|
|
671
|
+
|
|
672
|
+
## 8. 验收清单(A1..A22,全部机器可检查;A23 见 §13.3.1)
|
|
673
|
+
|
|
674
|
+
| # | 断言 | 位置/命令 |
|
|
675
|
+
|---|---|---|
|
|
676
|
+
| **A1** | 12 步 fanout、`maxChildrenPerNode: 8` → 12 个全部跑完;4 个先 `queued` 后 `admitted`;准入顺序 == 入队顺序;结果里 **0 条** `BUDGET_EXCEEDED`/`maxChildrenPerNode reached` | `test/integration/launch-queue.test.ts`;`npm run test:integration` |
|
|
677
|
+
| **A2** | 9 步、`maxActive: 4` → 全部跑完;任一时刻 in-process live 子节点 ≤ 4(在 fake session factory 里采样 `admissionCounts().active`) | 同上 |
|
|
678
|
+
| **A3** | 超时项:推进注入时钟越过 `launchQueueTimeoutMs` → 记录 `state=retired`;`nodes` 表无该行;无 `<runId>/<name>.jsonl`;无 worktree 目录;`getChildren(db,parent)` 不含它 | `test/integration/launch-queue.test.ts` |
|
|
679
|
+
| **A4** | `stop` 排队项 → `queue.size` 减 1;`FakeSupervisor.spawns` 不含该名;记录 `retired`;result 含 `launch queue` 停止文案 | `test/unit/orchestrator-admission.test.ts` |
|
|
680
|
+
| **A5** | 5 个排队项 + `signal.abort()` → `flushLaunchQueue` 返回 5 个名字、队列空、0 spawn、`supervisor.children` 计数不变、`interrupted (parent turn ended; children still running)` 仍在结果里 | `test/unit/teams.test.ts` |
|
|
681
|
+
| **A6** | 非并发闸门行为**逐一不变**:`maxDepth`/`maxSpawns`/`parent_not_found`/terminal child/`NAME_TAKEN`/`INSUFFICIENT_MEMORY`/unknown agent/disabled/model scope/`inprocess+worktree` → 错误码与文案与 master 逐字相同,且 `queue.size === 0` | `test/unit/orchestrator-admission.test.ts`(文案用 `assert.equal`) |
|
|
682
|
+
| **A7** | inprocess 与 rpc 同一套调度:把 `probe` 参数化跑同一组队列用例;另加 rpc 集成(`spawnMode:"rpc"` + `legion.maxChildrenPerNode:1`)→ 第 2 个先 queued,第 1 个 settle 后 admitted | `test/unit/admission-queue.test.ts` + `test/integration/launch-queue.test.ts` |
|
|
683
|
+
| **A8** | 既有套件全绿:`npm run typecheck && npm test && npm run test:integration`;重点 `test/unit/legion-budget.test.ts`、`test/unit/inprocess-supervisor.test.ts`、`test/unit/legion-schema-sentinel.test.ts`、`test/unit/legion-events.test.ts`、`test/unit/legion-event-bus.test.ts` | 三条命令,首个非零即 rejected |
|
|
684
|
+
| **A9** | `inprocess.maxChildrenPerNode: 0` 与 `legion.maxChildrenPerNode: 0` → 立即硬拒(gate 名为 `*.disabled`),`queue.size === 0`,文案含原 cap 数 | `test/unit/orchestrator-admission.test.ts` |
|
|
685
|
+
| **A10** | 溢出:`maxQueuedLaunches: 2`、需要 3 个排队 → 第 3 个立即 `launch queue overflow (2/2)`;文档断言用 `/launch queue overflow \(\d+\/\d+\)/`;无记录、无账本行 | 同上 |
|
|
686
|
+
| **A11** | `subagent({action:"status", name})` 对排队名 → 文本含 `state=queued`、`supervisor=inprocess`,**不含** `unknown child` | `test/unit/legion-status.test.ts` |
|
|
687
|
+
| **A12** | `collect` 对排队名阻塞:注入时钟下,准入前 `collect` 未 resolve;准入+settle 后返回真实结果;`wait` 同理不报 `collect failed` | `test/unit/orchestrator-admission.test.ts` |
|
|
688
|
+
| **A13** | 事件闭集:`LEGION_EVENT_TYPES`(`src/legion/events.ts`)数组长度与内容**逐字不变**;`LEGION_PROCESS_EVENT_TYPES` 恰好多 `launch_queued`/`launch_admitted`;DB 事件游标不冻结(append 后 `readEventsAfterTolerant` 不抛) | `test/unit/legion-events.test.ts`(新增两向断言) |
|
|
689
|
+
| **A14** | 设置:`inprocess.maxQueuedLaunches: 0` → 抛「expected a positive integer」;缺省 → 32 / 900000 | `test/unit/spawn-mode-settings.test.ts` |
|
|
690
|
+
| **A15** | 占位 sessionFile:queued 记录的 `sessionFile === path.join(parentRunDir, name + ".jsonl")` 且该文件**不存在**;准入后记录的 `sessionFile` 等于真实 spawn 路径且文件存在 | `test/unit/orchestrator-admission.test.ts` |
|
|
691
|
+
| **A16** | `queued` 不进账本闭集:`NODE_STATUSES.length === 6`;`validateStatuses` 上限仍 6;tree 里无该名节点(`rebuild` 跳过);`padStatuses` 未改 | `test/unit/legion-nodes.test.ts`、`test/unit/legion-rebuild.test.ts`(新增) |
|
|
692
|
+
| **A17** | 进程级注册表:同 `runDir` 的两个 Orchestrator 实例 `positionOf(name)` 一致;`resetAdmissionQueuesForTests()` 使测试互不污染 | `test/unit/admission-queue.test.ts` |
|
|
693
|
+
| **A18** | 深度/身份复原:在 ALS 深度 2 处入队,准入后子节点 `spawnDepth === 3`,且 spawn 期间 `currentChildContext()` 等于父上下文(用 `?reload=` 保持 D5 的判别性) | `test/unit/orchestrator-admission.test.ts` + `test/unit/inprocess-supervisor.test.ts` |
|
|
694
|
+
| **A19** | `onSlotReleased` 只在 `retired`/`exited` 触发,`agent_settled`(idle) 不触发;retire 与 spawn 失败各触发一次 | `test/unit/inprocess-supervisor.test.ts` |
|
|
695
|
+
| **A20** | `async:false`:3 步、cap 1 → 3 个结果齐全,且每个排队项输出 `↳ <name> waited <N>s in the launch queue` | `test/integration/launch-queue.test.ts` |
|
|
696
|
+
| **A21** | 快路径零微任务(D2-b):单步 launch 在 `FakeSupervisor.spawns` 上的调用时序与 master 一致;`queue.size === 0`;没有新增 `await` 导致的顺序变化(用「同一 tick 内 8 步全部进入 spawn」断言) | `test/unit/orchestrator-admission.test.ts` |
|
|
697
|
+
| **A22** | 探测后仍被真闸门拒 → 重新入队而非报错:预置 `admitting` 满 8 后放行的第 9 个在真闸门被拒 → 结果里无 `✗`,该名仍在队列(rpc 路径用 `legion.maxChildrenPerNode:1` 构造) | `test/integration/launch-queue.test.ts` |
|
|
698
|
+
|
|
699
|
+
## 9. 文档更新(独立步骤,W5)
|
|
700
|
+
|
|
701
|
+
| 文件 | 条目 |
|
|
702
|
+
|---|---|
|
|
703
|
+
| `docs/inprocess-supervisor.md` | ① `Limit keys (independent buckets)` 表(`:56` 起)加两行:`Queue depth | —(RPC 复用同值) | inprocess.maxQueuedLaunches (default 32)`、`Queue wait | — | inprocess.launchQueueTimeoutMs (default 900000)`;② 新小节「Launch admission queue」:可排队闸门白名单(§3 表)、事件驱动 + 单定时器兜底、`queued` 不进账本/不建 session、`maxChildrenPerNode: 0` 硬拒;③ `Refusals` 小节补 `launch queue overflow` / `launch queue timed out after Ns` 两条。 |
|
|
704
|
+
| `docs/legion-v2-contract.md` | §11.1 表格新增一行 `| \`maxQueuedLaunches\`(inprocess 准入队列上限) | 32 | 队列溢出立即硬拒,文案 `launch queue overflow`;默认 32 与 `inprocess.maxQueuedLaunches` 同源 |`。**不得**改 4/8/30 三行。 |
|
|
705
|
+
| `test/unit/legion-schema-sentinel.test.ts` | 三元组循环加 `["maxQueuedLaunches","32"]`;其余不动。 |
|
|
706
|
+
| `CHANGELOG.md` | `## [Unreleased]` → `### Added`:launch admission queue(超限排队而非拒绝,issue #266)+ 两个进程级事件 + 两个设置键。 |
|
|
707
|
+
| `README.md` | 可选:若 README 已有 inprocess 限额段落则补两键;当前无该段落则跳过(记为已知缺口)。 |
|
|
708
|
+
|
|
709
|
+
## 10. 明确不做(v1 边界,reviewer 判「缺失」前先读本节)
|
|
710
|
+
|
|
711
|
+
| 不做 | 理由 |
|
|
712
|
+
|---|---|
|
|
713
|
+
| 跨 parent 公平性 / 优先级 / 权重 / 队列抢占 | D5:v1 只保证「seq 全序 + 允许跳过」,公平性是单独的调度问题 |
|
|
714
|
+
| **队列持久化** | 进程崩溃即丢。**已知限制(必须写进文档)**:崩溃后 run.json 里会留下 `state:"queued"` 的**陈旧**记录;它们没有账本行、没有 session 文件,`status` 仍显示 `state=queued`(`owner: live` 指父 run 的 owner)。**恢复方式**:a) `subagent({action:"stop", name})` 把它标 `retired`;b) 重新 `launch`(新名字);c) 重启后 `queued` 记录永远不会自己变活,也不会被 hygiene 当成 live 节点(账本里没有它)。 |
|
|
715
|
+
| 排队期间改 agent / model / worktree / team | D4:payload 在入队时冻结;改参数 = 出队后重新 launch |
|
|
716
|
+
| `queued` 进 `NODE_STATUSES` / 账本 / TUI tree | D3 + §4.4:会连带改 `padStatuses`/`validateStatuses`/哨兵/hygiene |
|
|
717
|
+
| 队列深度/等待时长进 `/legion doctor` 深度报表 | v1 只要 `LaunchQueueEntry[]` + listener;doctor 报表另开 issue |
|
|
718
|
+
| 让 `maxDepth`/`maxSpawns`/memory 排队 | D1:不可满足或资源性闸门,排队只会伪装成延迟 |
|
|
719
|
+
| 改 `verify-queue.ts` 或复用它的 listener | F14/D9:那里是轮询式,形态不同 |
|
|
720
|
+
| 改 `rpc-supervisor.ts` / `budget.ts` / `nodes.ts` / `args.ts` | 只读消费;账本语义零变化 |
|
|
721
|
+
| 修 `idle` 占槽的既有语义(F9) | 那是 #197 的既定计数(`active` 过滤只排除 exited/retired);改它会让所有既有上限测试与语义变更,属另一 issue |
|
|
722
|
+
|
|
723
|
+
## 11. 风险与坑(最容易错的 1-3 处加粗)
|
|
724
|
+
|
|
725
|
+
- **R1(最高)同 tick fanout 的探测失效**:不引入 in-flight 保留位(D2-b),12 路同时探测都会
|
|
726
|
+
看到 0 live → 全部放行 → 回到今天 4 个报错。A1/A21 就是守这条。
|
|
727
|
+
- **R2 槽位释放边沿理解错**:`agent_settled → idle` **不释放**槽位(F9),只有 `retired`/`exited` 释放。
|
|
728
|
+
若把 hook 挂在 idle 上,队列会在一轮内放行超过上限(探测说空闲,真闸门说满 → 全变成退回避让,
|
|
729
|
+
表现为准入抖动)。另:账本槽位可被**另一个进程**释放 → 必须有单个 unref 兜底定时器(D2-d/4)。
|
|
730
|
+
- **R3 `reserveNode` 自锁(F10)**:任何「先 reservation 再入队」的实现都会让排队项自己占着
|
|
731
|
+
`starting` 行把 fanout 堵死,15 分钟后才超时。入队必须在 `try {` 之前、且探测阶段不 insert。
|
|
732
|
+
- **R4 `maxChildrenPerNode: 0`**(F11):`siblings(0) >= 0` 恒真 → 若当成可排队,所有 launch 排队到超时。
|
|
733
|
+
D11 的硬拒 gate 名必须能断言。
|
|
734
|
+
- **R5 两个事件闭集(F13)**:只加进程域两个类型;若误加进 `src/legion/events.ts`,DB 事件游标会因
|
|
735
|
+
`mapEvent` 抛错永久冻结(#233 事故复现)。A13 双向断言。
|
|
736
|
+
- **R6 `NODE_STATUSES` 硬编码 6 占位**:`listNodesByStatus`/`listStaleNodes` 的 SQL 写死
|
|
737
|
+
`IN (?, ?, ?, ?, ?, ?)`,`padStatuses` 用 `NODE_STATUSES.length - statuses.length` 补 null,
|
|
738
|
+
`validateStatuses` 文案写死 "one to six"。**任何人后续想把 `queued` 塞进闭集**必须同批改:
|
|
739
|
+
两条 SQL、`padStatuses`、`validateStatuses` 文案、`legion-status.ts:43` 的副本、
|
|
740
|
+
`test/unit/legion-nodes.test.ts`、§11.1 哨兵。本契约禁止这么做(D3)。
|
|
741
|
+
- **R7 超时与准入的竞态**:`pumpAdmissions` 正在 `await payload.run()` 时到时 → 不许 reject 正在
|
|
742
|
+
admit 的项(会把已 spawn 的子节点变成无主)。**规则**:`next()` 只对「等待中」的项报 expired;
|
|
743
|
+
admit 中的项在 `run()` 结算后再判(已准入则正常,失败则重入队并受原 `enqueuedAt` 约束)。
|
|
744
|
+
A3 用固定时钟覆盖「恰好到期 + 同时准入」。
|
|
745
|
+
- **R8 占位 sessionFile 的消费方**:`collect`/`status`/`resume`(`reviveChild`)/`persistChild`
|
|
746
|
+
都会读它。`collect`/`wait` 走 D8 的等准入;`revive` 必须拒绝 `state==="queued"` 的记录(报
|
|
747
|
+
NET_NOT_FOUND 之外的明确文案);`persistChild` 在准入后会覆盖。A12/A15 覆盖。
|
|
748
|
+
- **R9 进程级注册表**:`globalThis[Symbol.for("pi-legion.launch-admission-queue")]`(jiti
|
|
749
|
+
`moduleCache:false` 会重新求值模块,模块级 Map 会归零 —— 与 D5/REV2-4 同因);
|
|
750
|
+
测试必须 `resetAdmissionQueuesForTests()`。A17。
|
|
751
|
+
- **R10 队列项持有闭包**:闭包捕获 Orchestrator 与 `input`;`flush` 必须在 session shutdown 与
|
|
752
|
+
abort 时执行,否则闭包泄漏到进程结束(内存泄漏 + 幽灵准入)。A5、§7。
|
|
753
|
+
|
|
754
|
+
## 12. 待父会话确认(契约层面,不是实现细节)
|
|
755
|
+
|
|
756
|
+
- **O1(D2 字面 vs 需要)**:D2 只允许 supervisor 加 `onSlotReleased`;本契约还需要
|
|
757
|
+
`admissionCounts()` / `preflightAdmission()` 两个**只读**成员,否则必须在 Orchestrator 复制
|
|
758
|
+
`assertWithinLimits` 的过滤与四条文案(与 D1-a 冲突)。**建议追认**(仍无 supervisor 内排队)。
|
|
759
|
+
- **O2(命名冲突)**:D6 倾向中性名 `launchQueue.maxPending`,D10 冻结 `inprocess.maxQueuedLaunches`。
|
|
760
|
+
本契约按 **D10 落设置键**、`maxPending` 仅作内部字段(D6-a)。请确认。
|
|
761
|
+
- **O3(D3 措辞)**:D3 说「只留内存记录」,但 `index.ts` 的 `launchStep` 必然把 handle.child 写进
|
|
762
|
+
run.json,且跨进程 `status` 也要求它落盘。本契约按「**落 run.json + 不落账本/不建 session**」
|
|
763
|
+
执行(D3-a),请确认这不会被视为放宽 D3。
|
|
764
|
+
- **O4(新增 dep)**:D9 的「准入后再补一行」需要一个通知通道;本契约新增
|
|
765
|
+
`OrchestratorDeps.onLaunchQueueNotice` + `pi.sendMessage` 接线(不是新事件类型)。
|
|
766
|
+
|
|
767
|
+
> O1–O4 的裁决见 §13(FROZEN 2026-10-23):O1/O3/O4 全部**追认**,O2 按 D10 落键。§13 与正文冲突时以 §13 为准。
|
|
768
|
+
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
## 13. 父会话裁决(FROZEN 2026-10-23,closed O1–O4 + 2 处精确化)
|
|
772
|
+
|
|
773
|
+
本节与正文同等效力;冲突时**以本节为准**(本节是正文的部分修订,不是补充建议)。
|
|
774
|
+
|
|
775
|
+
### 13.1 O1 — **追认**:supervisor 允许两个只读成员 + 一个回调
|
|
776
|
+
|
|
777
|
+
`InProcessSupervisor` 可以新增 `admissionCounts(name)`、`preflightAdmission(name)`(均为只读、绝不抛、绝不改状态)与 `onSlotReleased` 回调。**仍然禁止** supervisor 内排队/持有队列/持有定时器 —— D2 的 single choke point 不变。
|
|
778
|
+
|
|
779
|
+
理由:不追认则必须在 Orchestrator 里复制 `assertWithinLimits` 的 `parentKey` 过滤 + 四条用户可见文案(F5/D1-a),两份实现必然漂移,而文案已被 `test/unit/inprocess-supervisor.test.ts` 与 #197 用户反馈锁死。D2 原文的「只加槽位释放回调」是**意图描述**(不要在 supervisor 里做调度),不是对只读探测的禁令。
|
|
780
|
+
|
|
781
|
+
**新增不变量(I-O1)**:`preflightAdmission` 是**咨询性**的。它的「通过」不授权 spawn,最终权威仍是 `assertWithinLimits` / `withLaunchBudget`。任何实现若让探测取代真闸门(例如探测通过就跳过 `assertWithinLimits`)即违约 —— D2-b/D2-c 的 in-flight 保留位 + 重入队正是为此存在。
|
|
782
|
+
|
|
783
|
+
### 13.2 O2 — **裁定**:设置键用 D10 的名字,`maxPending` 只作内部字段
|
|
784
|
+
|
|
785
|
+
- 用户可见:`subagents.inprocess.maxQueuedLaunches`(默认 32)、`subagents.inprocess.launchQueueTimeoutMs`(默认 900000),必须进 `docs/inprocess-supervisor.md` 的 Limit keys 表与 §11.1 哨兵。
|
|
786
|
+
- 内部:`AdmissionQueueOptions.maxPending` / `AdmissionQueue.maxPending`。
|
|
787
|
+
- **禁止**新增 `launchQueue.*` 顶层设置命名空间(v1 只有一个桶,避免以后 inprocess/rpc 分裂配置)。
|
|
788
|
+
|
|
789
|
+
### 13.3 O3 — **追认 + 收紧**:`queued` 落 run.json,但必须「三不建」
|
|
790
|
+
|
|
791
|
+
**允许**:父 run.json 一条 `ChildRecord{state:"queued"}`、`launch_queued` 进程事件、`sessionFile` 占位字符串(不建文件)。
|
|
792
|
+
**禁止(A3/A15 逐条断言)**:账本 `nodes` 行、child run 目录/`run.json`、worktree 目录、`<name>.jsonl` 实体文件。
|
|
793
|
+
|
|
794
|
+
D3 原文「只留内存记录」作废,以本裁定为准。跨进程 `status` 必须能显示排队项(D9),所以必须落 run.json。
|
|
795
|
+
|
|
796
|
+
#### 13.3.1 **新增强制项(I-O3):`restore()` 对 queued 记录的处理**
|
|
797
|
+
|
|
798
|
+
进程重启后 run.json 里的 `queued` 记录是**孤儿**(队列在内存里,已随进程消失)。`Orchestrator.restore()` 必须照旧把它们放进 `this.children`(否则 `subagent({action:"stop",name})` 的重建路径 `orchestrator.restore(found.run)` → `retire(name)` 会撞 `requireChild` 抛 `NOT_FOUND`,用户看到的是 unknown child 而不是真实原因),但 `collect` / `steer` / `continue` / `resume` 命中 `state === "queued"` 且**本进程队列里没有该名字**时必须给出专属文案:
|
|
799
|
+
|
|
800
|
+
```
|
|
801
|
+
child '<name>' never started: the launch queue was lost when the previous process exited; re-dispatch it (or relaunch) instead
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
`retire()` 例外:允许把这种孤儿直接标 `retired` + `retiredAt`(清理语义),不需要先 spawn。
|
|
805
|
+
**新增验收 A23**:构造 run.json 含 `state:"queued"` 的孤立记录 → `restore()` 后 `collect`/`steer` 返回上述文案而非 `NOT_FOUND`;`retire()` 成功且记录变 `retired`;`nodes` 表全程无该行。
|
|
806
|
+
|
|
807
|
+
### 13.4 O4 — **追认**:`OrchestratorDeps.onLaunchQueueNotice?: (line: string) => void`
|
|
808
|
+
|
|
809
|
+
不是新事件类型(D12 仍然只有两个进程级事件)。`index.ts` 接线到 `pi.sendMessage`(root UI 另接 status pill)。`async:false` 时同一行也进 tool result(D8/D20)。
|
|
810
|
+
|
|
811
|
+
### 13.5 精确化 1 — F9 确认:`idle` **占槽**,只有 `retired`/`exited` 释放
|
|
812
|
+
|
|
813
|
+
契约正文已按此写(F9 / R2 / A19)。**明确禁止**把 `onSlotReleased` 挂在 `updateTurnState` 的 `idle` 分支(`inprocess-supervisor.ts:1318`)。若挂错,表现是准入抖动(探测说空、真闸门说满、项被 D2-c 重入队),A19 就是守这条。
|
|
814
|
+
|
|
815
|
+
**已知后果(写进文档,不在本 issue 修)**:`idle` 子节点仍占 fanout 槽位,直到父 agent collect 后 recycle(`index.ts:1641` / `runtime.ts:1405-1416`)。这属 #197 的既定计数语义(F5 的 `active` 过滤只排除 `exited`/`retired`),改它会连带改所有既有上限测试 —— 单开 issue。
|
|
816
|
+
|
|
817
|
+
### 13.6 精确化 2 — **追认**:进程级注册表(同父 run 共享一个队列)
|
|
818
|
+
|
|
819
|
+
`globalThis[Symbol.for("pi-legion.launch-admission-queue")]`,键为父 run 的 `runDir`(不是 cwd,也不是 process.cwd())。理由:`jiti` 的 `moduleCache:false` 会让模块级 Map 归零(与 `spawn-default-inprocess-v3` D5/REV2-4 同因),而 `index.ts` 有 5 处 `new Orchestrator`(F17),`stop` 的重建路径必须能定位到原来那个队列。
|
|
820
|
+
|
|
821
|
+
**新增限制(I-O6)**:注册表键必须包含 `process.pid`,避免同一 `runDir` 被子进程/多 session 误共享(in-process 子节点共享 process,但 rpc 子节点不)。测试必须 `resetAdmissionQueuesForTests()`(A17)。
|
|
822
|
+
|
|
823
|
+
### 13.7 补充禁令(正文未覆盖,合并前必查)
|
|
824
|
+
|
|
825
|
+
1. **禁止**在 `admission-queue.ts` 里 `import fs` / `import path` / 起 timer / 读 `process.env`(纯模块,A17 用 lint-by-reading 或测试断言)。
|
|
826
|
+
2. **禁止**为了让 `status` 好看而在 `index.ts` 里把 `queued` 映射成 `working`/`launching`(D9 要求 `state=queued` 字面出现)。
|
|
827
|
+
3. **禁止**改 `src/legion/budget.ts` / `src/legion/nodes.ts` / `src/legion/events.ts` / `src/supervisor/rpc-supervisor.ts` / `src/runs/verify-queue.ts`。
|
|
828
|
+
4. **禁止**用 `setTimeout` 直接 reject 正在 admit 的项(R7);超时只在 `queue.next()` 这一处判定。
|
|
829
|
+
5. **禁止**让 `launch()` 的快路径(槽位可用)多出任何 `await`(D2-b/R1/A21)。
|
|
830
|
+
|
|
831
|
+
### 13.8 裁决后的 D/A 增量一览
|
|
832
|
+
|
|
833
|
+
| 增量 | 内容 |
|
|
834
|
+
|---|---|
|
|
835
|
+
| I-O1 | 探测是咨询性的,不得取代真闸门 |
|
|
836
|
+
| I-O3 | `restore()` 保留 queued 记录;`collect`/`steer`/`continue` 给"queue lost"专属文案;`retire` 允许直接标 retired |
|
|
837
|
+
| I-O6 | 注册表键含 `process.pid` |
|
|
838
|
+
| **A23** | 进程重启后孤儿 queued 记录的三种行为(文案 / retire / 无账本行) |
|
|
839
|
+
| §13.7 | 5 条合并前必查禁令 |
|