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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# `finished` 完成结果缓存淘汰契约 v1
|
|
2
|
+
|
|
3
|
+
> 状态:**生效**,随 #349 落地。这条契约把「`finished` 能留多少 output 文本、淘汰时丢什么、
|
|
4
|
+
> 哪些条目**不许**淘汰、淘汰后 `collect`/`wait` 还欠调用方什么」写死。
|
|
5
|
+
> 冲突时**停下报告**,不得自行重新解释本契约。
|
|
6
|
+
> 关联:#349(`finished` 驻留全文且永不淘汰)、#320/#322/#327/#333(claim 语义,**不动**)、
|
|
7
|
+
> `notice-claim-v1.md`(D2/D7/D9/D10 的 claim 与 one-round 语义,**不动**)。
|
|
8
|
+
> **修订 r2(review 返工)**:上限压到 issue #349 的验收(**8KB/条、1MB/总**);
|
|
9
|
+
> 明写「调用方拿到的是缓存条目的**副本**」(改造 1);`CollectSnapshot.outputTruncated` 成为
|
|
10
|
+
> 「已降级读取」的显式标记(改造 3);R3 的文本等价性指向 §3.3(改造 minor 1)。
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## §1 问题陈述
|
|
15
|
+
|
|
16
|
+
`src/extension/runtime.ts` 的 `finished` 是 **one-round 缓存**(D7b,按 name 键),存
|
|
17
|
+
`{ runId, round, snapshot: CollectSnapshot }`,其中 `snapshot.output` 是 child 的**输出全文**。
|
|
18
|
+
它只在 5 处被删(重新 `track` / `rewatch` / `unclaim` / `unclaimUndelivered` / `dispose`),
|
|
19
|
+
**没有任何上限**:实测 50 个 child × 204,800 B 输出 ⇒ 堆上驻留 **10.07 MB**(≈206 KB/child),
|
|
20
|
+
把 50 个名字重新 `track` 后立刻回落(反向证明持有者就是它)。
|
|
21
|
+
|
|
22
|
+
关键事实:**调用方真正需要的是「结果」而不是「全文」**——
|
|
23
|
+
`runId`/`round`(claim 键,D9/D10)、`execution`、`acceptance`、`outputFile`;
|
|
24
|
+
而全文早已由 orchestrator 落盘(`writeOutputFile` → `<runDir>/<name>.output.md`,
|
|
25
|
+
`CollectSnapshot.outputFile` 就是它的路径)。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## §2 约定
|
|
30
|
+
|
|
31
|
+
### R1 — 上限只作用于「驻留的 output 文本」,且满足 #349 的验收(≤ 1.0 MB)
|
|
32
|
+
|
|
33
|
+
| 常量(`runtime.ts` 具名导出) | 值 | 语义 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `FINISHED_CACHE_MAX_ENTRY_BYTES` | **8 KB** | **单条**驻留文本上限。超过它的输出**一个字节都不留在内存**(存预览头,命中时从 `outputFile` 重建) |
|
|
36
|
+
| `FINISHED_CACHE_MAX_OUTPUT_BYTES` | **1 MB** | 整个缓存的驻留文本合计上限 |
|
|
37
|
+
| `FINISHED_CACHE_MAX_ENTRIES` | **128** | 条目数上限,按 `1MB / 8KB` 推导(因此字节上限才是实际约束) |
|
|
38
|
+
| `FINISHED_OUTPUT_HEAD_CHARS` | 4000 字符 | 被淘汰条目保留的预览前缀(≤ 8 KB UTF-8,与 `notify.ts` 的 `PREVIEW_CHARS` 一致) |
|
|
39
|
+
|
|
40
|
+
上限**任何时刻**都成立(`residentBytes` 不超 1 MB),包括「刚刚命中、刚从磁盘读回全文」的那一刻
|
|
41
|
+
(见 R3 的第 3 条)。LRU 顺序 = `Map` 插入顺序:新写入的、以及**命中过的**(`wait`/`collect` 走
|
|
42
|
+
`cachedFinished()`)都是「最新」,超出上限的最旧条目的文本被淘汰。
|
|
43
|
+
|
|
44
|
+
> 为什么单条也要封顶:只封总量时,一条 200 KB 的输出仍可以长期驻留(占掉 1/5 预算),
|
|
45
|
+
> 缓存占用就跟着「最大的那个 child」走;封单条后,驻留量与单个输出大小**无关**。
|
|
46
|
+
|
|
47
|
+
### R2 — 淘汰**只丢 output 文本**,永远不丢条目、不丢元数据
|
|
48
|
+
|
|
49
|
+
淘汰后条目仍在 `finished` 里,`runId` / `round` / `snapshot.execution` /
|
|
50
|
+
`snapshot.acceptance` / `snapshot.outputFile` / `snapshot.blocked` 全部原样。
|
|
51
|
+
`snapshot.output` 变成**前 4000 字符的预览**(`stripped = true`)。
|
|
52
|
+
|
|
53
|
+
**为什么不能删条目**:`finished` 的命中同时承担 claim(`(runId, name, round)`,D9/D10)。
|
|
54
|
+
删掉条目会让之后的 `collect`/`wait` 变成 `missing` —— 那是「结果丢失」,
|
|
55
|
+
而且会让本轮的 claim 键消失(重复投递 / 该投递的失败被吞掉)。
|
|
56
|
+
|
|
57
|
+
### R3 — 淘汰必须可重建:先落盘,后淘汰;命中一律从盘上取全文
|
|
58
|
+
|
|
59
|
+
淘汰的前提是 `snapshot.outputFile` 存在。命中(`cachedFinished()`)的顺序是:
|
|
60
|
+
|
|
61
|
+
1. 条目移到「最新」;
|
|
62
|
+
2. `trimFinished()` —— 先把本次命中的条目按 R1 重新收口;
|
|
63
|
+
3. `rehydrateFinished(entry)` —— 从 `fs.readFileSync(outputFile)` 读回全文(去掉 orchestrator 写盘时
|
|
64
|
+
补的那一个 `\n`);
|
|
65
|
+
4. **把 `snapshot` 的副本交给调用方**,随后再 `trimFinished()` 把条目重新收回配额内。
|
|
66
|
+
|
|
67
|
+
于是:
|
|
68
|
+
|
|
69
|
+
- `wait(name)` / `collect` 在淘汰之后**仍能拿到和淘汰前一模一样的结果**(全文 + 元数据);
|
|
70
|
+
文本等价性见 §3.3(最多首尾空白差异),**不是**逐字节相同;
|
|
71
|
+
- 「刚命中又把条目收回配额」不会影响调用方:它拿的是副本,字符串不可变;
|
|
72
|
+
- 条目重新变回「最新」,重新占用配额(配额再挤掉别的旧条目,循环有界)。
|
|
73
|
+
|
|
74
|
+
### R4 — 不可淘汰路径(pinned):没有 `outputFile` 的条目一律不动
|
|
75
|
+
|
|
76
|
+
以下条目**不得淘汰**,其文本保留到被 `track`/`rewatch`/`dispose` 删掉为止:
|
|
77
|
+
|
|
78
|
+
| 不可重建的路径 | 为什么 |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `collect` 实现从不落盘的快照(宿主自备的 collect、测试 fake) | 没有 `outputFile`,丢了就真丢了 |
|
|
81
|
+
| `writeOutputFile` 失败(`fs` 报错)或输出为空 | `outputFile` 为 `undefined`;空输出本来也占 0 字节 |
|
|
82
|
+
| orchestrator 之外的 `TrackedJobInput.collect` 来源 | 同上,契约不假设它们有磁盘副本 |
|
|
83
|
+
|
|
84
|
+
生产路径不受影响:`index.ts` 只把 `orchestrator.collect(...)` 交给 `runtime.track()`,而它对
|
|
85
|
+
每个**终态、非空**输出都写 `<runDir>/<name>.output.md`(inprocess 与 rpc 共用同一 orchestrator)。
|
|
86
|
+
pinned 的字节数**不是隐形的**:`noticeQueueStats().finished.pinnedBytes` 会如实报出来(R5)。
|
|
87
|
+
|
|
88
|
+
### R5 — 可观测性(含「已降级读取」的显式标记)
|
|
89
|
+
|
|
90
|
+
`noticeQueueStats()` 的 `finished` 块(纯读,不加淘汰语义):
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
finished: {
|
|
94
|
+
entries: number; // 缓存里的结果条数(淘汰不改变它)
|
|
95
|
+
residentBytes: number; // 驻留 output 文本的 UTF-8 字节数(含淘汰后的预览头)
|
|
96
|
+
stripped: number; // 已淘汰文本、待命中时从磁盘重建的条数
|
|
97
|
+
pinnedBytes: number; // 因「不可重建」而无法回收的字节数(R4)
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`CollectSnapshot` 新增 **`outputTruncated?: true`**(issue #349 建议 A 的「明确的已落盘标记」):
|
|
102
|
+
|
|
103
|
+
- 只有当这一份 `output` **不是** child 的全文、并且**重建失败**时才出现
|
|
104
|
+
(`outputFile` 被删 / 从未记录 / 读盘抛错);
|
|
105
|
+
- 第一次 `collect`/`wait`、pinned 条目、落盘正常时的任何命中,**都不会**带这个字段;
|
|
106
|
+
- 它是纯快照字段,不进 wire、不进 ledger(R6)。
|
|
107
|
+
|
|
108
|
+
### R6 — 不动的边界
|
|
109
|
+
|
|
110
|
+
- **不改 claim(`reported`)的淘汰语义**(#320 那一族的正确性依赖它),只加计数;
|
|
111
|
+
- 不改 one-round 失效点(`track` / `rewatch` / `unclaim` / `unclaimUndelivered` / `dispose`);
|
|
112
|
+
- **不改 claim 键**:淘汰后的条目仍然带 `runId`/`round`,缓存命中的 claim 与淘汰前完全相同;
|
|
113
|
+
命中时交出的副本会重新登记 `(runId, round)` origin,`unclaimUndelivered`(#320 的回滚)
|
|
114
|
+
因此对缓存命中也照旧生效;
|
|
115
|
+
- 不改 `CollectSnapshot` 之外的形状(不进 wire、不改 ledger schema);`outputTruncated` 是本轮
|
|
116
|
+
唯一新增字段(R5),且是可选字段;
|
|
117
|
+
- 不改 `RateSampler` 的样本保留(那是 #346 的地盘,见 `legion-chart-frame-buckets-v1.md`)。
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## §3 已知取舍(写在这里,免得被当成 bug)
|
|
122
|
+
|
|
123
|
+
1. **单条输出大于 8 KB**:不进驻留(存 4000 字符预览头)。第一次 `collect`/`wait` 拿到的仍是
|
|
124
|
+
**全文**——缓存条目是**副本**,淘汰只动缓存自己的那份;之后的命中从 `outputFile` 重建全文。
|
|
125
|
+
上限就是上限——缓存不能为了一个巨无霸把整帧预算吃掉。
|
|
126
|
+
2. **`outputFile` 指向的文件被删了**(run dir 被清理):`collect` 拿到 4000 字符预览头 +
|
|
127
|
+
完整元数据 + `outputTruncated: true`,**不会**变成 `missing`。降级读,不是丢结果。
|
|
128
|
+
3. **重新读回的是落盘文本**:orchestrator 写的是 `output.trim() + "\n"`,所以重建后的
|
|
129
|
+
`snapshot.output` 与原始 `output` 的差异最多是首尾空白,**等于 `output.trim()`**;
|
|
130
|
+
正文逐字节相同。用例:`output = "abc\n\n"` ⇒ 命中后 `=== output.trim()`。
|
|
131
|
+
4. **淘汰发生在写入时、以及命中时**(不是定时器):任何时刻 `finished` 的驻留文本都不超过 R1 的上限。
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## §4 实测
|
|
136
|
+
|
|
137
|
+
环境:Node v24.13.1,`createSessionRuntime` + 真 orchestrator 形状的 `outputFile`
|
|
138
|
+
(`output.trim() + "\n"`),`--expose-gc`,GC 后取 `heapUsed`。同一脚本跑三侧:
|
|
139
|
+
`master`(改前)、`b80a9ab`(本 PR 上一轮提交,review 打回的版本)、本轮 HEAD。
|
|
140
|
+
|
|
141
|
+
### 4.1 50 个 child × 204,800 B
|
|
142
|
+
|
|
143
|
+
| 场景 | master | b80a9ab(上一轮) | 本轮 HEAD |
|
|
144
|
+
|---|---|---|---|
|
|
145
|
+
| 完成后堆驻留 | **10.07 MB**(206 KB/child) | 3.58 MB(73 KB/child) | **0.51 MB**(10 KB/child) |
|
|
146
|
+
| `finished.residentBytes` | —(无此字段) | 3.41 MB(16 条全文 + 34 个预览头) | **0.20 MB**(50 个预览头) |
|
|
147
|
+
| `stripped` / `pinned` | — | 34 / 0 | **50 / 0** |
|
|
148
|
+
| 是否满足 #349 的「≤ 1.0 MB」 | ❌ 10.07 MB | ❌ 3.58 MB | ✅ **0.51 MB** |
|
|
149
|
+
|
|
150
|
+
### 4.2 单条大输出(本轮 major 1 的核心)
|
|
151
|
+
|
|
152
|
+
| 场景 | master | b80a9ab(上一轮) | 本轮 HEAD |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| 单条 204,800 B:首次 `wait` 字节数 | 204,818(全文) | 204,818(全文) | **204,818(全文)** |
|
|
155
|
+
| 单条 5,242,900 B:首次 `wait` 字节数 | 5,242,900(全文) | **4,000(头!)** | **5,242,900(全文)** |
|
|
156
|
+
| 单条 5,242,900 B:之后缓存命中字节数 | 5,242,900 | **4,000(头!)** | **5,242,900(全文)** |
|
|
157
|
+
| 单条 204,800 B:命中(从盘重建) | 全文 | 全文 | **全文** |
|
|
158
|
+
|
|
159
|
+
上一轮那两行 4,000 就是 review 的 major 1:`rememberFinished` 存的是**引用**,
|
|
160
|
+
插入时 `trimFinished()` 把**要交出去的那个对象**原地剥成了 4000 字头。
|
|
161
|
+
本轮缓存存浅拷贝,交付对象与缓存条目彻底分开。
|
|
162
|
+
|
|
163
|
+
### 4.3 `outputFile` 被删(降级读取)
|
|
164
|
+
|
|
165
|
+
| 场景 | 本轮 HEAD |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `consumeCollect()` 返回值 | `output.length === 4000`(预览头,且是原文的头部) |
|
|
168
|
+
| 标记 | `snapshot.outputTruncated === true` |
|
|
169
|
+
| 元数据 | `execution` / `acceptance` / `outputFile` 全在,**不是** `missing` |
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# GalaxyScratch 契约 v1(帧内复用容器的局部可推理化)
|
|
2
|
+
|
|
3
|
+
- 状态:生效,随实现 PR 落地。
|
|
4
|
+
- 上游:`docs/contracts/viz-frame-alloc-v1.md`(#364,**冻结数字与不变量以它为准**)。
|
|
5
|
+
- 本契约只约束**结构**,不改变任何可见输出与分配目标。
|
|
6
|
+
|
|
7
|
+
## §0 目标
|
|
8
|
+
|
|
9
|
+
`src/tui/viz/panels.ts` 现有 26 个模块级可变容器、14 处分散清零。正确性有 golden 守着,但
|
|
10
|
+
**读者无法局部推理**:要判断「这一帧的脏状态从哪开始是干净的」,必须通读整个函数。
|
|
11
|
+
本契约把它收成**一个** `GalaxyScratch` 实例 + **一次** `begin()`,并给不可重入加闸门。
|
|
12
|
+
|
|
13
|
+
零行为变化:golden 逐字节不变、bench 10 的 23 条门禁不变、整帧分配不回退。
|
|
14
|
+
|
|
15
|
+
## §1 单一容器
|
|
16
|
+
|
|
17
|
+
- `panels.ts` 内新增 `class GalaxyScratch`(可以放在 `panels.ts` 或 `src/tui/viz/scratch.ts`,
|
|
18
|
+
但**必须只有一处**定义、**模块级只有一个实例**)。
|
|
19
|
+
- 26 个可变容器全部成为它的字段:`pxWidth/pxHeight/pxGrid`、19 个 `galaxy*`、4 个 `CHART_*`。
|
|
20
|
+
- `begin(rect)` 是**唯一**的清零入口。它必须:
|
|
21
|
+
1. 无条件完成全部重置(`Map.clear()`、数组 `length = 0`、计数 `= 0`);
|
|
22
|
+
**尺寸键不来自 panel rect**(实现期修订):galaxy 与 chart 的 rect 每帧互不相等,拿 rect 当键会让两次
|
|
23
|
+
`begin` 各重建一次网格(实测一次重建 81.8 / 266.5 KB,即每帧多分配一次)。网格重建按「已挂载网格自身的
|
|
24
|
+
`w`/`h`」判定,只在 resize 时发生;`rect` 参数保留是为了调用点一致,不参与尺寸判定。
|
|
25
|
+
2. **不分配新对象**(不许 `new Map()` / `[]` 代替 clear;容器身份跨帧保持——这是 #364 分配目标的前提);
|
|
26
|
+
3. 幂等(对同一 rect 连续调用两次结果相同)。
|
|
27
|
+
- 清零顺序不影响结果;但 `begin` 之后、任何读取之前,容器状态必须与「首次构造」等价。
|
|
28
|
+
|
|
29
|
+
## §2 顺序
|
|
30
|
+
|
|
31
|
+
`drawGalaxy` 的第一条**副作用**是 `scratch.begin(r)`,**早于**任何提前返回(今天 `if (!center) return`
|
|
32
|
+
出现在首次清零之前,读起来像漏清)。`drawChart` 同理。
|
|
33
|
+
|
|
34
|
+
实现期修订:**闸门检查先于 `begin`**。若先 `begin` 再判重入,一次被拒绝的嵌套调用会把外层正在画的帧清空——
|
|
35
|
+
拒绝必须是无副作用的。顺序因此是:查闸门 → 置位 → `begin` → body → `finally` 复位。
|
|
36
|
+
|
|
37
|
+
## §3 不可重入闸门
|
|
38
|
+
|
|
39
|
+
- `drawGalaxy` / `drawChart` 入口置 `painting` 标志,`finally` 复位;重入即抛
|
|
40
|
+
`viz: drawGalaxy is not reentrant`(或对应函数名)。
|
|
41
|
+
- 闸门**始终启用**(一个布尔读写,不进热路径分配)。它保护的是:这些容器是模块级单例,
|
|
42
|
+
嵌套渲染会互相踩脏数据,而今天这个事实只写在注释里。
|
|
43
|
+
|
|
44
|
+
## §4 Canvas 约束
|
|
45
|
+
|
|
46
|
+
- `Canvas` 的 `cells` / `get()` 注释必须写明:**改 `color` 只能走 `set()`**,否则 `roleCounts`
|
|
47
|
+
会留下幽灵 role(就地改 `color` 的现有调用点是安全的,但约束要被写下来)。
|
|
48
|
+
- `Canvas.reset()` 仍覆盖 `ch/color/bold/inverse/m/z` 六个字段(#364 用例已钉)。
|
|
49
|
+
|
|
50
|
+
## §5 不变量
|
|
51
|
+
|
|
52
|
+
| 编号 | 不变量 | 守卫 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| **I1** | `viz-frame-alloc-v1.md` 的 I1–I7、T1–T7、§5 S1–S4 全部继续成立 | 该契约原文 |
|
|
55
|
+
| **I2** | 可见输出逐字节不变:4 形状 plain/ANSI digest + 逐格 `(ch,color,bold,inverse)` sha256 | `test/unit/legion-viz.test.ts` 的 #364 用例群,**不改** |
|
|
56
|
+
| **I3** | 整帧分配不回退:120×40 ≤ 1042.5 KB、200×60 ≤ 1319.0 KB(+ 噪声) | `bench/perf-core/10-legion-frame-alloc.ts` 23/23 |
|
|
57
|
+
| **I4** | 不新增模块级可变状态(除 `GalaxyScratch` 单例与 `painting` 布尔) | 代码审查 |
|
|
58
|
+
| **I5** | `paintPageFloor`(`view.ts:96`)、layout / `plotW` / `plotH` / `chartWindow` 窗口长度 / `RateSampler` 保留策略一律不动 | #364 契约 §5 |
|
|
59
|
+
|
|
60
|
+
## §6 验收
|
|
61
|
+
|
|
62
|
+
1. `npm run typecheck` / `npm test` / `npm run test:integration` 全绿。
|
|
63
|
+
2. `node --expose-gc --experimental-strip-types bench/perf-core/10-legion-frame-alloc.ts` → `ok: true`,
|
|
64
|
+
23/23 门禁,两个尺寸的 `frameAllocKB` 不高于基线。
|
|
65
|
+
3. 两条新用例且能红(变异自证):
|
|
66
|
+
- 删掉 `begin()` 里**输出承重**的重置(实测会红的有:逐格 `z`、`leafMemo.clear()`、`depthGuard`/`layGuard`、
|
|
67
|
+
`occCount`、`roles`、`statusItems`、`all`、`running`、`candidates`、`chartRoles`)→ 既有 px 残留 / golden
|
|
68
|
+
用例变红。⚠️ `posCount = 0` 不在此列:`g.pos` 每帧被 `lay()` 重写、读侧只按本帧索引取值,删掉不会红;
|
|
69
|
+
但它是**内存卫生**的守卫——`posCount` 同时是 `posX/posY` 的长度,删掉会让它们跨帧无界增长,不属可删项;
|
|
70
|
+
- 去掉重入闸门 → 新的重入用例变红(构造一次嵌套调用)。
|
|
71
|
+
4. 机械改动清单:`galaxyXxx` → `g.xxx` 的每一处引用都在 `panels.ts` 内;对外导出签名不变
|
|
72
|
+
(`drawGalaxy` / `drawChart` / `chartWindow` / `computeStats` / `childrenMap` / `dfsOrder` / `hypot2`)。
|
|
73
|
+
|
|
74
|
+
## §7 非目标
|
|
75
|
+
|
|
76
|
+
- 不改渲染逻辑、不调整布局、不优化算法。
|
|
77
|
+
- 不动 `view.ts` / `canvas.ts` 的行为(只加注释)。
|
|
78
|
+
- 不引入新依赖、不做模块拆分(除允许把 `GalaxyScratch` 放进独立文件)。
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# /legion 图表分桶契约 v1(一帧一次分桶)
|
|
2
|
+
|
|
3
|
+
> 状态:**生效**,随 #346 落地。这是一条**记录性**契约:把「一帧只分桶一次」「分桶只算本帧
|
|
4
|
+
> 需要画的 series」这两条写死,防止以后有人在 `computeStats`/`drawChart` 里各算一遍,或者把
|
|
5
|
+
> 已经停止产出的 series 的账重新摊到每帧上。
|
|
6
|
+
> 冲突时**停下报告**,不得自行重新解释本契约。
|
|
7
|
+
> 关联:#346(每帧两遍分桶 + 历史税)、#308(页面底色,不受本契约影响)。
|
|
8
|
+
> **修订 r2(review 返工)**:R2 的复杂度声明按实现重写(r1 说「结束的 child 不再每帧收税」,
|
|
9
|
+
> 那是**错的**,见 R2 的「残余成本」);R4 的「逐字节相同」改成「每桶数值相同 + 整列 role 可能
|
|
10
|
+
> 从 `byRole` 消失,画面不变」;§3 补「未达标」两行与本轮复测;§4 补 `Canvas.set` 的隔离数字。
|
|
11
|
+
> **修订 r3(#362 落地)**:§4 第 1 项(`Canvas.set` 每次写分配一个 `Cell`)已修掉,§3.4 记录前后数字;§4 第 3 项(滚动 `chartWindow`)本轮**评估后不采纳**,理由与数字见 §4。本契约的 R1–R6 语义**没有变化**(cell 复用不涉及分桶、采样、可见输出)。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## §1 问题陈述
|
|
16
|
+
|
|
17
|
+
`/legion` 的 tok/s 图表每帧把**同一份** buckets 算两遍,且成本随「记录过的 series 数」线性增长:
|
|
18
|
+
|
|
19
|
+
1. `drawChart` 先 `sampler.buckets(from, to, plotW)`(`src/tui/viz/panels.ts`)给图体上色,
|
|
20
|
+
紧接着 `computeStats(data, plotW)` 内部又 `chartWindow(data)` + `sampler.buckets(from, to, plotW)`——
|
|
21
|
+
参数与结果**完全相同**,没有任何记忆化。200 节点下这是 ≈1.2ms + ≈0.6MB 垃圾 / 帧。
|
|
22
|
+
2. `RateSampler.buckets()` 对 `this.series` 里**每一个曾经记录过**的 series 分桶
|
|
23
|
+
(`src/tui/viz/rate.ts`),而 `series` 全文件没有任何删除点:结束的 child、离开子树的节点
|
|
24
|
+
每帧照样被白算。
|
|
25
|
+
3. 每个 series 还各自新建一条长度 `plotW` 的临时数组再累加进 role 行——纯垃圾。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## §2 约定
|
|
30
|
+
|
|
31
|
+
**R1 — 一帧只能有一次分桶。**
|
|
32
|
+
唯一入口是 `panels.ts` 的 `chartBuckets(data, count, window?)`;`drawChart` 调一次,
|
|
33
|
+
把结果作为第三个参数传给 `computeStats(data, buckets, series?)`。
|
|
34
|
+
`computeStats` **不得**在收到 `series` 时重新分桶;不传时(老签名,测试/一次性消费者)
|
|
35
|
+
退回 `chartBuckets` 自己算一次,语义与三参数版逐字节一致。
|
|
36
|
+
防回归用例:`test/unit/legion-viz.test.ts` 的「a rendered frame buckets the sampler exactly once」
|
|
37
|
+
(包一层计数 `sampler.buckets`,断言整帧 == 1)与「computeStats reuses the frame's buckets」。
|
|
38
|
+
|
|
39
|
+
**R2 — 分桶只算「本帧看得到且窗口内真的变过」的 series,复杂度如实声明。**
|
|
40
|
+
|
|
41
|
+
`buckets(from, to, count, { onlyNodeIds })` 的两层过滤:
|
|
42
|
+
|
|
43
|
+
1. **可见性**:`onlyNodeIds` 来自 `visibleRateNodeIds(data.nodes)`——**保留** settled/retired 行
|
|
44
|
+
(图表仍要画它们窗口内的历史),**排除** root 与不在这帧里的节点。不可见 series 的
|
|
45
|
+
**原始采样一个字节都不删**(`prune` 语义、`DEFAULT_RETENTION_MS` / `MAX_SAMPLES_PER_NODE` 都不变)。
|
|
46
|
+
2. **可证明为零**(见 R4):`series.changedAt <= times[0] − w` 的 series 直接跳过。
|
|
47
|
+
`changedAt` 是 `record()` 里**采样值真的变化**的时刻更新、**flat-run collapse 不更新**的时间戳。
|
|
48
|
+
|
|
49
|
+
**复杂度(如实)**:一次 pass 是 **O(k × plotW)**,其中 `k` = 可见 series 中
|
|
50
|
+
**`changedAt` 落在窗口内(含一个桶窗口的余量)的那些**。不是 O(历史 series 数),也**不是** 0。
|
|
51
|
+
|
|
52
|
+
**残余成本(r1 的错话,这里改正)**:`chartWindow()` 的 `from = min(最早 agent 的 startedAt, to−60s)`,
|
|
53
|
+
长跑时 `from` 就是**整轮 run 的起点**,因此「窗口内真的变过」对**每个曾经产出过的 series** 都成立——
|
|
54
|
+
它在窗口内的历史是要画的,跳过就会把那几列清零。所以:
|
|
55
|
+
|
|
56
|
+
| 窗口形状 | walked(200 series / 190 settled) |
|
|
57
|
+
|---|---|
|
|
58
|
+
| 整轮 run(`chartWindow` 当前形状,`from` = run 起点) | **200**(一个也跳不掉,且**必须**如此) |
|
|
59
|
+
| 尾部 60s(`from = to − 60s`,reviewer 的测量形状) | **10**(跳过 190) |
|
|
60
|
+
|
|
61
|
+
结论:**「结束的 child 不再每帧收税」这句话不成立,本契约撤回它。** 本契约保证的是:
|
|
62
|
+
「`changedAt` 早于窗口起点一个桶窗口以上的 series 零成本」,以及「一次 pass、无 per-series 临时数组」。
|
|
63
|
+
要让「结束的 child 真的不再进 pass」,得改 `chartWindow` 的 x-domain(滚动窗口),那是**另一个决定**
|
|
64
|
+
(可见输出的语义变化),不在本契约授权范围内。
|
|
65
|
+
|
|
66
|
+
防回归用例:「a frame walks the series that CHANGED inside its window, not every settled row」——
|
|
67
|
+
190 个 settled 行 + 10 个活跃行,尾部 60s 窗口下 `sampler.lastPass.walked === 10` /
|
|
68
|
+
`skipped === 190`,且**同一帧的列值与逐个 series 的参照值(`nodeBuckets`,从不跳过)完全相等**;
|
|
69
|
+
同时钉住残余:整轮窗口下 `walked === 200`(把「窗口内的历史必须画」变成断言,防止有人用清零换速度)。
|
|
70
|
+
|
|
71
|
+
**R3 — 图表的 `avg` 跟同一份可见集走。**
|
|
72
|
+
`ChartStats.avg = sampler.totalRate(to, to−from, visibleRateNodeIds(data.nodes))`。
|
|
73
|
+
理由不是省时间,而是正确性:不可见的 series 会把 avg 抬起来(实测 40 个已结束的 ghost
|
|
74
|
+
把 300 tok/s 抬到 1.5k tok/s),而它们在图上一个像素都没有。
|
|
75
|
+
防回归用例:「dropping a node from the frame does not repaint the chart」——
|
|
76
|
+
同一帧 + 46 个 ghost series 必须与 6 个 series 渲染出**逐字节相同**的 canvas。
|
|
77
|
+
|
|
78
|
+
**R4 — 可证明为零的 series 直接跳过(不是近似)。**
|
|
79
|
+
一个 series 若 `changedAt <= 首桶边界 − 窗口`,则它在此之后所有采样值都等于 `changedAt` 处的值
|
|
80
|
+
(flat-run collapse 只挪时间戳、不改值),`interpolate` 在每桶两端都夹到同一个值,每桶 Δ 恒为 0。
|
|
81
|
+
跳过它 **每个桶的数值都与算完再加 0 相同**(用例把快路径与 `nodeBuckets` 参照值逐桶比对)。
|
|
82
|
+
|
|
83
|
+
唯一允许的差异是**结构**而非数值:某个 role 的所有 series 都被跳过时,该 role 的整列不会出现在
|
|
84
|
+
返回的 `RateSeries.byRole` 里(`undefined` 视作 0)。图体的 role 列表来自 `computeStats().byRole`
|
|
85
|
+
(即 `data.nodes`),且值为 0 的列本来就不画,所以**画面逐字节不变**。
|
|
86
|
+
|
|
87
|
+
**R5 — `buckets()` 不再分配 per-series 临时数组。**
|
|
88
|
+
直接累加进 role 行;`nodeBuckets()`(选中 agent 的亮线)保留,因为它是单 series 的公开入口。
|
|
89
|
+
|
|
90
|
+
**R6 — 不动的边界。**
|
|
91
|
+
`RateSampler` 的采样语义与保留策略(`RATE_WINDOW_MS`、`MIN_RATE_GENERATION_MS`、
|
|
92
|
+
`DEFAULT_RETENTION_MS`、`MAX_SAMPLES_PER_NODE`)、图表外观/布局/`plotW`/`plotH`、
|
|
93
|
+
`paintPageFloor`(#308)、`drawGalaxy` 的呈现,全部不变;`/legion` 的可见输出除 R3 的
|
|
94
|
+
avg 归属外逐字节不变(既有快照用例守)。
|
|
95
|
+
|
|
96
|
+
新增的可观测面只有一处:`RateSampler.lastPass = { walked, skipped }`(上一次 `buckets()` 的
|
|
97
|
+
计数,纯诊断/测试探针,采样逻辑不读它)。
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## §3 实测(120×40 / 200 节点,真 `Theme(dark)`)
|
|
102
|
+
|
|
103
|
+
### 3.1 上一轮(r1)记录:master vs 分支
|
|
104
|
+
|
|
105
|
+
基准机与 issue 不同,**比值**是可迁移的(同机、同脚本、同 flag 前后各跑两遍)。
|
|
106
|
+
分配用「3 次 gc + 落定后取单次调用 heapUsed 差值」,轮转取中位;两遍复现误差 <0.1%。
|
|
107
|
+
|
|
108
|
+
| 指标 | 改前(master) | 改后(分支) | 变化 |
|
|
109
|
+
|---|---|---|---|
|
|
110
|
+
| 帧 median | 6.68 / 6.32 / 6.51 ms | 4.37 / 4.61 / 4.28 ms | ≈−32% |
|
|
111
|
+
| 帧 p95 | 9.12 / 9.08 / 6.65 ms | 5.04 / 4.76 / 4.62 ms | ≈−40% |
|
|
112
|
+
| `drawChart` | 2.62 / 2.70 ms | 0.59 / 0.59 ms | ≈−77% |
|
|
113
|
+
| `buckets()` | 1.24 ms | 0.47 ms | ≈−62% |
|
|
114
|
+
| 帧分配 | 4214 KB | 3306 KB | ≈−22% |
|
|
115
|
+
| `drawChart` 分配 | 1432 KB | 530 KB | ≈−63% |
|
|
116
|
+
| `buckets()` 分配 | 600 KB | 226 KB | ≈−62% |
|
|
117
|
+
| `computeStats()` 分配 | 644 KB | 282 KB | ≈−56% |
|
|
118
|
+
|
|
119
|
+
### 3.2 本轮复测(同一脚本跑三侧,各 3 次 run 取中位)
|
|
120
|
+
|
|
121
|
+
同一台机器上三次交叉跑(master / 上一轮提交 b80a9ab / 本轮 HEAD),`live` = 200 series 全部
|
|
122
|
+
持续产出(最坏情况),`settled` = 190 个在 120s 停下 + 10 个跑到 400s(`data.nodes` 里 200 行全在,
|
|
123
|
+
即生产的子树形状)。跨 run 范围见括号——本机负载高,绝对值与 3.1 有偏差,**只有同一次 run 内的
|
|
124
|
+
前后比值可比**。
|
|
125
|
+
|
|
126
|
+
| 指标(120×40/200) | master | b80a9ab(上一轮) | 本轮 HEAD |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| 帧 median,live | 9.95(8.56–13.19)ms | 4.33(4.30–10.10)ms | 4.56(4.37–6.30)ms |
|
|
129
|
+
| 帧 median,settled | 5.03(4.76–8.71)ms | 3.15(2.78–5.33)ms | 3.19(3.16–4.71)ms |
|
|
130
|
+
| `drawChart`,live | 11.00 ms | 2.98 ms | 2.95 ms |
|
|
131
|
+
| `buckets()` 整轮窗口,settled | 2.29 ms | 1.17 ms | 1.16 ms |
|
|
132
|
+
| `buckets()` 尾部 60s,settled | 1.61 ms | 0.89 ms | **0.10 ms** |
|
|
133
|
+
| 帧分配,live | 8116 KB | 7135 KB | 7259 KB |
|
|
134
|
+
| `buckets()` 分配,live | 1115 KB | 347 KB | 347 KB |
|
|
135
|
+
|
|
136
|
+
| 指标(200×60/200) | master | b80a9ab | 本轮 HEAD |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| 帧 median,live | 10.05 ms | 9.36 ms | 6.42 ms |
|
|
139
|
+
| 帧 median,settled | 7.36 ms | 5.38 ms | 7.62 ms(范围 4.58–8.36) |
|
|
140
|
+
| 帧分配,settled | 5539 KB | 4218 KB | 4244 KB |
|
|
141
|
+
| `buckets()` 尾部 60s,settled | 2.77 ms | 1.05 ms | **0.22 ms** |
|
|
142
|
+
|
|
143
|
+
本轮相对上一轮**不改**整轮窗口的每帧成本(walked 都是 200)——本轮改的是
|
|
144
|
+
「可证明为零时才跳」这一条的**正确性**,以及 #349 的驻留/交付。尾部 60s 形状下
|
|
145
|
+
walked 200 → 10(跳过 190),`buckets()` 0.89 → 0.10ms / 1.05 → 0.22ms,即 R2 的那条快路径确实成立。
|
|
146
|
+
|
|
147
|
+
### 3.3 未达标项(如实记录)
|
|
148
|
+
|
|
149
|
+
1. **`200×60` / 200 节点 median 实测 7.30–7.34 ms**(上一轮,issue #346 的目标是 ≤7.0 ms);
|
|
150
|
+
上一轮**没有测 p95**。本轮补测 p95:live 9.17 ms / settled 14.72 ms(本轮 HEAD,本机负载高,
|
|
151
|
+
范围 7.52–31.38 ms)——**p95 也未达标**,且本机噪声大,这两个数只能当量级看。
|
|
152
|
+
2. **帧中位 4.28–4.61 ms**(120×40/200;任务卡目标是 ≤4 ms——**未达标**;issue #346 写的是
|
|
153
|
+
≤5.0 ms——达标)。
|
|
154
|
+
3. **分配的「减半(≤2MB)」在本契约改动范围内做不到**:`200×60/200` 帧分配 4.2MB(本契约改前
|
|
155
|
+
5.5MB),`120×40/200` 7.1–7.3MB(改前 8.1MB)。剩下的大块不在 `panels.ts`/`rate.ts`,
|
|
156
|
+
见 §4。
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### 3.4 #362 之后复测(120×40 / 200×60,200 节点,真 `Theme(dark)`)
|
|
161
|
+
|
|
162
|
+
同一脚本(`/tmp` 一次性基准,不进仓)、单进程、`--expose-gc`、GC 落定后取「单帧 heapUsed 差值」
|
|
163
|
+
的中位(20 次);时间取 5 轮 × 50 帧、前后交替各 1250 样本。**只有同一次 run 内的前后比值可比。**
|
|
164
|
+
|
|
165
|
+
| 指标 | #362 前 | #362 后 | 变化 |
|
|
166
|
+
|---|---|---|---|
|
|
167
|
+
| `Canvas.set` 分配,120×40 | 52.4 B/次 | **0.03 B/次** | ≈−100% |
|
|
168
|
+
| `Canvas.set` 分配,200×60 | 128.0 B/次 | **0.002 B/次** | ≈−100% |
|
|
169
|
+
| `new Canvas` 预填,120×40 / 200×60 | 269.3 / 616.1 KB | **190.0 / 472.4 KB** | −30% / −23% |
|
|
170
|
+
| 整帧分配,120×40 live | 2839.9 KB | **2551.8 KB** | −10% |
|
|
171
|
+
| 整帧分配,200×60 live | 4556.8 KB | **4018.2 KB** | −12% |
|
|
172
|
+
| 整帧 median,120×40 live | 4.53 ms | **3.87 ms** | −15% |
|
|
173
|
+
| 整帧 median,200×60 live | 6.95 ms | **5.73 ms** | −18% |
|
|
174
|
+
| 整帧 p95,120×40 live | 6.22 ms | **5.47 ms** | −12% |
|
|
175
|
+
| 整帧 p95,200×60 live | 8.59 ms | **7.44 ms** | −13% |
|
|
176
|
+
| 可见输出(4 场景 × plain/ANSI 摘要) | — | **逐字节相同** | — |
|
|
177
|
+
|
|
178
|
+
结论:#346 的**耗时**目标(120×40 ≤5.0ms / p95 ≤7.0ms、200×60 ≤7.0ms / p95 ≤10.0ms)达成,
|
|
179
|
+
任务卡更严的 120×40 ≤4.0ms 也达成(3.87ms);**整帧分配 ≤2.0MB 仍未达成**(2.55 / 4.02MB),
|
|
180
|
+
残项见 §4 与 follow-up #364。
|
|
181
|
+
|
|
182
|
+
## §4 后续(不在本契约内)
|
|
183
|
+
|
|
184
|
+
- **已修(#362)**:`Canvas.set()` 不再每次写新建 `Cell`。`Canvas` 构造时一次性建好 `W×H` 个可变
|
|
185
|
+
cell,`set()` 原地写 `ch/color/bold/inverse`(显式字段,无 `{...style}`),默认 style 用共享常量,
|
|
186
|
+
`sanitizeChar` 按字符记忆化。同一脚本前后:**52.4 → 0.03 B/次**(120×40)、**128.0 → 0.002 B/次**
|
|
187
|
+
(200×60),一帧 4,583 / 9,691 次写入 ⇒ 0.24 MB / 0.91 MB 的分配消失。预填也变小
|
|
188
|
+
(53–57 → 39–41 B/格)。`get()` / `cells` 的语义因此写死为**活视图**(下一次 `set` 改同一对象),
|
|
189
|
+
两个既有读点(`drawChart` 的 peak 规则、`railway` 的表头守卫)都只读 `ch` 即弃。
|
|
190
|
+
可见输出逐字节不变(§3.4)。上一轮契约里「drawGalaxy 每帧写约 5 万次」的说法仍以实测为准:4,583/9,691 次。
|
|
191
|
+
- **剩余分配(同一进程内分量,±40% 抖动,只用于排序)**:`drawGalaxy` ≈0.93–1.23 MB(整帧 36–45%,
|
|
192
|
+
时间也最大 2.2 ms,其中 braille `px` 网格 80.9 KB/58×19、262.5 KB/118×31 已隔离)、
|
|
193
|
+
`Canvas.toLines(theme)` ≈0.44–1.01 MB(每个 role 色格都 `{...cell, color}` 复制一遍)、
|
|
194
|
+
`drawChart` ≈0.38–0.62 MB(含 `computeStats` 157 KB、`buckets` 129 KB)、`Canvas` 预填
|
|
195
|
+
0.19–0.47 MB、其余(header/log/timing/status/layout/`buildLog`)≈0.35–0.5 MB 未逐项隔离。
|
|
196
|
+
120×40/live 整帧 2.55 MB、200×60/live 4.02 MB,离 ≤2.0 MB 还有 0.5–2.0 MB:**follow-up #364**。
|
|
197
|
+
- **滚动 `chartWindow`(尾部 60s)本轮评估后不采纳,保持「整轮 run 窗口」。** 实测:
|
|
198
|
+
live 形状**零收益**(窗口内每个 series 都在变,`walked 196 / skipped 0`,`buckets()` 0.61 ms vs
|
|
199
|
+
0.74 ms 在噪声内),settled 形状省 0.1–0.4 ms;代价是 x 轴域从整轮 run 压成 60s,
|
|
200
|
+
**4 个场景的 plain/ANSI 摘要全部改变**——这是「可见输出的语义变化」,本契约 §3.3/R2 已注明需单独授权,
|
|
201
|
+
所以它要作为独立决定(带前后对比)来评审,不并入分配优化。
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# /legion 主屏挂载契约 v1(page mount)
|
|
2
|
+
|
|
3
|
+
> 状态:**生效**,随 #347 落地。这是一条**记录性 + 约束性**契约:把 `/legion` 的挂载方式
|
|
4
|
+
> 与它带来的三条不变量写死,避免以后有人「顺手」把它改回全屏 overlay。
|
|
5
|
+
> 冲突时**停下报告**,不得自行重新解释本契约。
|
|
6
|
+
> 关联:#347(逐行合成占整帧 57–70%)、#308(详情浮层右侧黑带 → `paintPageFloor`)、
|
|
7
|
+
> #193(浮层不画遮罩)、#157(详情浮层 75% 尺寸)。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## §1 问题陈述
|
|
12
|
+
|
|
13
|
+
`/legion` 曾以**全屏 overlay** 打开:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// 改动前 src/extension/legion-commands.ts
|
|
17
|
+
await ctx.ui.custom(createLegionViewFactory({ ... }), {
|
|
18
|
+
overlay: true,
|
|
19
|
+
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 },
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
pi-tui 对**每个可见 overlay 的每一行**都跑 `compositeTuiLine`
|
|
24
|
+
(`extractSegments` + `sliceWithWidth` + `visibleWidth`,三次 `Intl.Segmenter` 字素扫描),
|
|
25
|
+
且行宽缓存按**整行字符串**做 key —— 每秒都在变的帧永远命中不了。`/legion` 恰恰是这条路径的
|
|
26
|
+
最坏输入:非空格里 56%–68% 是 braille `⣿`、box `┏│┄`、块 `█▁▂`,没有任何一行是纯 ASCII。
|
|
27
|
+
issue #347 实测:整帧 5.05 / 8.59 / 17.06 ms(80×24 / 120×40 / 200×60),
|
|
28
|
+
其中合成+行重置占 **65% / 57% / 70%**,非 ASCII 行单价是 ASCII 的 **3.67×**。
|
|
29
|
+
|
|
30
|
+
根因不是页面画得慢(`component.render(cols)` 只要 1.79 / 3.70 / 5.09 ms),而是**挂载方式**:
|
|
31
|
+
overlay 意味着整屏行都要被宿主再合成一遍。
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## §2 约定
|
|
36
|
+
|
|
37
|
+
**R1 — `/legion` 页面必须挂主屏(`overlay: false`),不得以 overlay 挂载。**
|
|
38
|
+
唯一出口:`src/extension/legion-commands.ts` 里 paged viz 视图的
|
|
39
|
+
`ctx.ui.custom(createLegionViewFactory(...), { overlay: false })`。
|
|
40
|
+
|
|
41
|
+
- 主屏挂载 = 宿主把组件**替换**进 base 布局(editor 容器),而不是叠在 base 之上 ⇒
|
|
42
|
+
pi-tui 的 overlay 合成层完全不参与,页面只付自己那一次 `render()`。
|
|
43
|
+
- 因此**不得**给该视图传 `overlayOptions`:没有 overlay rect,就没有需要合成的矩形。
|
|
44
|
+
- 经典 `--tree` 视图(`legion-commands.ts` 里 `createTreeViewFactory` 那条)本来就是这个挂载
|
|
45
|
+
方式,两者现在一致。
|
|
46
|
+
- 变异验证:把这一处改回 `overlay: true` + `overlayOptions`,
|
|
47
|
+
`test/unit/legion-integration.test.ts` 的
|
|
48
|
+
「`/legion` mounts the full page on the main screen, not as a full-screen overlay (#347)」立刻变红。
|
|
49
|
+
|
|
50
|
+
**R2 — 主屏挂载不等于「拿到布局高度」,页面继续自己按 `terminal.rows` 定高。**
|
|
51
|
+
`ui.custom` 的非 overlay 组件是**布局叶子**,宿主不会给它分配视口高度(`src/tui/tree-view.ts`
|
|
52
|
+
的注释同样如此)。所以 `createLegionViewFactory().render()` 仍旧
|
|
53
|
+
`Math.max(6, tui.terminal.rows ?? 24)` 自己定高,跟 `createTreeViewFactory` 注入
|
|
54
|
+
`terminalRows` 的做法同源。**不要**为了「看起来像主屏」把它改成固定行数或改成
|
|
55
|
+
`width` 推导高度。
|
|
56
|
+
|
|
57
|
+
**R3 — 详情浮层仍是 75% 浮层,几何一个像素都不动。**
|
|
58
|
+
`/legion` 里按 `o`/`⏎` 打开的详情(`openLiveOverlay`)仍然
|
|
59
|
+
`ui.custom(factory, { overlay: true, overlayOptions: resolveModalOverlayOptions() })`:
|
|
60
|
+
`MODAL_MAX_WIDTH_RATIO = 0.75`、`MODAL_MAX_HEIGHT_RATIO = 0.65`、
|
|
61
|
+
`MODAL_MIN_FRAME_ROWS = 8`(`src/tui/live-overlay.ts`)。#157/#193 的取舍不变。
|
|
62
|
+
`resolveModalOverlayOptions()` 用百分比而非绝对格数,所以 resize 后 rect 由 pi-tui 按当前
|
|
63
|
+
终端重新推导 —— 这条语义**不因页面改主屏挂载而变化**,`test/unit/legion-live-overlay.test.ts`
|
|
64
|
+
的 resize 用例原样绿。
|
|
65
|
+
|
|
66
|
+
**R4 — `Esc`/`q` 关闭后必须回到会话视图。**
|
|
67
|
+
主屏挂载的关闭路径由宿主提供:`showExtensionCustom` 的非 overlay 分支在打开时
|
|
68
|
+
`editorContainer.clear()` + `addChild(component)`,关闭时把 editor 重新挂回并
|
|
69
|
+
`setFocus(editor)`。所以页面自己只需要 `done(undefined)`(`handleViewKey` 返回 `close`),
|
|
70
|
+
**不要**自己缓存/重建 editor,也不要在 `dispose()` 里碰宿主容器。
|
|
71
|
+
|
|
72
|
+
**R5 — `paintPageFloor` 在主屏挂载后**仍然必需**,不是保险。**
|
|
73
|
+
`src/tui/viz/view.ts` 的 `paintPageFloor` 保持原样(不改、不删、不弱化)。#308 的前提是
|
|
74
|
+
「详情浮层是 overlay,合成器对 rect 右侧段的样式继承取 base 行尾的一次性 SGR 快照,而
|
|
75
|
+
`/legion` 行尾是裸空格」,这与**页面本身**怎么挂载无关:
|
|
76
|
+
|
|
77
|
+
- 页面改主屏挂载后,详情浮层依旧叠在页面行上,合成器依旧按行取快照。
|
|
78
|
+
- 实测(`test/helpers/modal-composite.ts` 的 `assertOverlayGutterInheritsBase({ mount: "main" })`):
|
|
79
|
+
- 有 floor:`frameRightBg = \u001b[48;5;236m`(= base 的页面底色),与 overlay 挂载时一致;
|
|
80
|
+
- 去掉 floor(把 `render()` 改回直接返回 `canvas.toLines(theme)`):**两种挂载**的
|
|
81
|
+
`rowBg` / `baseRightBg` / `frameRightBg` 全部退化成 `""`(终端默认背景)⇒ 黑带重现。
|
|
82
|
+
- 结论:floor 的代价(issue 实测 +21 B/行、整帧 +504/840/1260 B、0.002–0.003 ms)在主屏挂载后
|
|
83
|
+
依旧换来「浮层右侧无黑带」,不属于本 issue 的优化对象。
|
|
84
|
+
- 验收用例:`test/unit/legion-live-overlay.test.ts` 的
|
|
85
|
+
「the /legion page mounted on the main screen keeps its floor under the modal's gutters (#347)」
|
|
86
|
+
(`mount: "main"`)+ 原 #308 用例(`mount: "overlay"`,保留为历史几何的记录)。
|
|
87
|
+
|
|
88
|
+
**R6 — 不改宿主合成器、不 fork `compositeTuiLine`、不动 pi-tui 节流常量。**
|
|
89
|
+
本仓不复制/重写 `node_modules/@earendil-works/pi-tui` 的合成实现,也不改
|
|
90
|
+
`MIN_RENDER_INTERVAL_MS`(16ms)一类的宿主节流;`/legion` 自己的重绘节流
|
|
91
|
+
(`createLegionViewFactory` 的 `refreshMs`,默认 1000ms)同样不动。
|
|
92
|
+
主屏挂载之所以有效,正是因为它**绕开**了那条路径,而不是优化它。
|
|
93
|
+
|
|
94
|
+
**R7 — 视觉内容等价。**
|
|
95
|
+
挂载方式不影响画什么:同一组件、同一数据在两种挂载下写出字节相差 <4%(见 §3 表),
|
|
96
|
+
`filter`/宽度/行数断言全部沿用。若将来某次改动让每帧字节增长 >2%,视为回归。
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## §3 验收
|
|
101
|
+
|
|
102
|
+
**单测**(`npm test`):
|
|
103
|
+
|
|
104
|
+
| 用例 | 断言 |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `legion-integration` → `/legion mounts the full page on the main screen…(#347)` | `overlay !== true`、`overlayOptions === undefined`、`j`+`o` 仍开 75% 浮层(`resolveModalOverlayOptions()`)、浮层 Esc 后页面仍在、`q` 回会话视图。**改回 overlay 立刻红**。 |
|
|
107
|
+
| `legion-live-overlay` → `the /legion page mounted on the main screen keeps its floor…(#347)` | 主屏挂载 + 75% 浮层:左右 gutter 文本 = base,`rowBg`/`baseRightBg`/`frameRightBg` 全 = `PAGE_FLOOR_ANSI`(显式要求非空)。**去掉 floor 立刻红**。 |
|
|
108
|
+
| `legion-live-overlay` → #308 原用例 | overlay 挂载的同一断言,保留。 |
|
|
109
|
+
| `legion-live-overlay` → `the modal frame survives a terminal resize…` | resize 语义原样绿(几何不变)。 |
|
|
110
|
+
|
|
111
|
+
**基准**(`bench/perf-core/09-legion-mount.ts`,同一组件/数据/主题,一个 TUI 实例内成对测
|
|
112
|
+
base → 浮层 → 空白对照,`written[]` 每帧清空,50 帧 × 3 轮):
|
|
113
|
+
|
|
114
|
+
| 终端 | 页面帧 median(overlay → 主屏) | p95 | 页面帧字节 | 开浮层额外成本(主屏) | 空白 rect 对照 |
|
|
115
|
+
|---|---|---|---|---|---|
|
|
116
|
+
| 80×24 | 2.49 → **0.96 ms**(2.6×) | 5.30 → **1.25** | 7837 → 7597(−3.1%) | +1.45 ms(≤2.0 ✓) | +1.16 ms |
|
|
117
|
+
| 120×40 | 4.41 → **1.77 ms**(2.5×) | 17.64 → **2.85** | 16277 → 15877(−2.5%) | +2.69 ms | +2.39 ms |
|
|
118
|
+
| 200×60 | 8.63 → **3.35 ms**(2.6×) | 41.51 → **4.67** | 32575 → 31975(−1.8%) | +5.35 ms | +4.86 ms |
|
|
119
|
+
|
|
120
|
+
(共享机噪声大,绝对值只看同一量级:同一脚本多次运行的页面 median 是
|
|
121
|
+
0.93–0.97 / 1.69–1.77 / 3.26–3.39 ms,主屏侧 p95 稳定在 1.2–4.7 ms,而 overlay 侧 p95 在
|
|
122
|
+
共享机上是 5–42 ms 的噪声区间。脚本输出每个 cell 的分轮中位数。)
|
|
123
|
+
|
|
124
|
+
> 该基准**必须在空闲独占的机器上运行**(#368):并发跑 `npm test` 时它会报
|
|
125
|
+
> `ok: "inconclusive"`、时间类门 `not judged`、退出码 0,而不是时红时绿;`inconclusive`
|
|
126
|
+
> 既不算回归也不算通过,去空闲机器上复跑。读法与探针阈值见 `bench/perf-core/README.md`。
|
|
127
|
+
|
|
128
|
+
**关于「开浮层额外成本 ≤2.0 ms/帧」。**
|
|
129
|
+
这一条**只在 80×24 成立**(+1.35 ms),在 120×40 / 200×60 上不可达,且与 #347 的实现无关:
|
|
130
|
+
|
|
131
|
+
1. 详情浮层仍是 75% overlay(R3 锁定),120×40 / 200×60 时它有 26 / 39 行,每行都要过一次
|
|
132
|
+
`compositeTuiLine`(issue 实测 183 µs/行 ⇒ 26 行 ≈ 4.76 ms);
|
|
133
|
+
2. 决定性对照:把同一个 75% rect 换成**纯空格组件**(什么都不画),主屏挂载下仍然
|
|
134
|
+
+2.4–2.9 / +4.9–6.1 ms —— 也就是说「只要浮层存在」这笔合成费就必须付,页面挂载方式动不了它;
|
|
135
|
+
3. 浮层组件自己的 `render(75% 宽)` 只要 0.09 / 0.15 ms,所以 Δ 的 95%+ 是宿主合成而非浮层绘制。
|
|
136
|
+
|
|
137
|
+
因此基准把这一条按 80×24 立门禁,另加两条对所有尺寸成立的门禁:
|
|
138
|
+
**页面帧 median/p95**(issue POC 1.65 / 3.84 / 6.37 ms 的 +30% / ≈2×)与
|
|
139
|
+
**浮层 Δ 不得超过同尺寸空白 rect 对照 + 一个实测余量**(即 Δ 完全由浮层自身行数解释)。
|
|
140
|
+
|
|
141
|
+
最后这一条自 #368 起按**配对差**判定:同一轮内交替测 base / modal / blank(5 轮),
|
|
142
|
+
取每轮 `slack = Δmodal − Δblank` 的**中位数**,与余量比较;余量取
|
|
143
|
+
`max(实测上界, 0.5 ms)`(当前 0.9 ms,实测分布见 `reports/368-mount-gate.md`)。
|
|
144
|
+
原写法是两个相隔一分钟的批次中位数相减(`Δmodal ≤ Δblank + 0.5 ms`),
|
|
145
|
+
共享机上两个中位数各自漂 ±0.5 ms,同一提交会时红时绿,不能当判据。
|
|
146
|
+
若 5 轮的 slack 符号分歧(≥2 正 ≥2 负),报 `inconclusive` 而不是红。
|
|
147
|
+
要真正压掉这 26/39 行的合成,只有 issue 里的 B(降字形密度)或 C(上游 ASCII 快路径),
|
|
148
|
+
不在本契约范围。
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## §4 不做(边界)
|
|
153
|
+
|
|
154
|
+
- **不改浮层几何**:75% 宽 / 65% 高、`MODAL_MAX_WIDTH_RATIO`、`MODAL_MAX_HEIGHT_RATIO`、
|
|
155
|
+
`MODAL_MIN_FRAME_ROWS`、`resolveModalOverlayOptions()` 全部保持。
|
|
156
|
+
- **不改 pi-tui**:不 fork/重写 `compositeTuiLine`,不改 `MIN_RENDER_INTERVAL_MS` 等节流常量,
|
|
157
|
+
不 patch `node_modules`。
|
|
158
|
+
- **不动 `paintPageFloor`**:见 R5,它在主屏挂载后仍然必需。
|
|
159
|
+
- **不改 `/legion <node>` / 状态栏 widget 的打开路径**:前者本来就是 75% 浮层,后者躺在宿主
|
|
160
|
+
聊天区(`docs/contracts/legion-page-floor-v1.md` R1)。
|
|
161
|
+
- **不为「主屏挂载」重写页面布局**:`renderLegionView` 的画法、分栏、footer 快捷键一律不变。
|