@zhuxixi/pi-agent-board 0.5.2 → 0.6.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 +32 -0
- package/README.md +41 -3
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +573 -2
- package/runner/state-runner.mjs +3 -0
- package/runner/title-runner.mjs +1 -1
- package/scripts/release_helper.mjs +277 -0
- package/src/commands/agent-board.ts +38 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +40 -2
- package/src/core/host-coordination.mjs +159 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch.mjs +3 -1
- package/src/core/locks.mjs +196 -1
- package/src/core/paths.mjs +24 -0
- package/src/core/store.mjs +164 -5
- package/src/core/types.mjs +17 -1
- package/src/core/warm-host-sweeper.mjs +150 -0
- package/src/index.ts +29 -1
- package/src/runtime/service.mjs +967 -109
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +26 -12
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
# Warm-Host Sweep Reclaim Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 让空闲 PTY host 的回收(TTL 10min / MAX_WARM 4,设计已有但只惰性触发)真正生效:周期 sweep + 宿主启动即 sweep + session_shutdown 清理,消除 detach 后无限期进程泄漏(实测 8.5h)。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 把淘汰判定提取为纯函数 `selectIdleHostsToEvict`(新增 graceMs 豁免防 attach 竞态)→ 新增调度器 `createWarmHostSweeper`(周期 + unref)→ `attachWarmHostSweeper` 生命周期接线函数(非 child 宿主启动即 sweep、session_shutdown 清理;isHostedChild no-op)→ service.mjs 暴露 `pruneWarmHosts` 服务方法供接线调用。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node ≥20(node:test,mjs),pi 扩展(pi.on 事件 API),无新依赖。
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- 本仓库测试命令:`node --test test/*.test.mjs`(Windows 本机直接可跑;既有基线含 16-17 个已知环境性失败,见知识库笔记,**以无新增失败为准**);typecheck:`npm run typecheck`
|
|
16
|
+
- 所有源文件为 .mjs(node:test);`src/index.ts` 是唯一 .ts 改动点(类型不严,import mjs 无类型可容忍)
|
|
17
|
+
- 现有环境变量别名必须保留:`AGENT_BOARD_*` 主名 + `AGENT_VIEW_*` legacy 别名(envInt 第五参 legacyName)
|
|
18
|
+
- **isHostedChild 必须 no-op**:child pi 内 sweep 会向自己的 runner 发 terminate(自杀链),环境变量 `AGENT_BOARD_CHILD=1` / `AGENT_VIEW_CHILD=1`
|
|
19
|
+
- 淘汰判定语义(spec §2 M1,与原 pruneWarmHosts 等价):idle = hostAlive && !isAgentBusy && attachedClients===0 && 非 keepViewId;TTL 淘汰 → survivors 按 idleSince 升序超额淘汰;`maxWarm===0 && ttlMs===0` = 整体禁用
|
|
20
|
+
- **Spec 偏差记录**:spec §2 M4(dashboard POLL_MS 轮询顺带 prune)**取消**——POLL_MS=700ms 周期做全量 listRows(每行读 meta/state/host 三文件)磁盘 IO 不划算;60s 周期 sweep 已覆盖 dashboard 打开场景。不引入对应代码与验收。
|
|
21
|
+
- 验收矩阵(spec §4):A1-A5 unit / A6-A7 integration / A8 全量回归 / U1 用户实测
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Task 1: 纯判定函数 `selectIdleHostsToEvict` + busy 判定迁入新模块
|
|
26
|
+
|
|
27
|
+
**Files:**
|
|
28
|
+
- Create: `src/core/warm-host-sweeper.mjs`
|
|
29
|
+
- Create: `test/warm-host-sweeper.test.mjs`
|
|
30
|
+
- Modify: `src/runtime/service.mjs`(isAgentBusy/hasPendingQuestions 本地定义删除,改为 import;仅此,pruneWarmHosts 重构在 Task 3)
|
|
31
|
+
|
|
32
|
+
**Interfaces:**
|
|
33
|
+
- Produces: `selectIdleHostsToEvict(rows, opts) -> { ttlEvicted: string[], excessEvicted: string[] }`;`isAgentBusy(row) -> boolean`;`hasPendingQuestions(row) -> boolean`
|
|
34
|
+
- Consumes: `listRows(root)` 的 Row 结构(`{ meta:{id,updatedAt}, alive, hostAlive, host:{state,attachedClients,startedAt,runnerPid}|null, state:{semanticState,lastActivityAt,pendingQuestions,processState,currentRunId}|null }`)
|
|
35
|
+
|
|
36
|
+
**验收:** A1, A2, A3, A4(unit)
|
|
37
|
+
|
|
38
|
+
- [ ] **Step 1: 写 failing 测试** `test/warm-host-sweeper.test.mjs`
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import assert from "node:assert/strict";
|
|
42
|
+
import { test } from "node:test";
|
|
43
|
+
import { isAgentBusy, selectIdleHostsToEvict } from "../src/core/warm-host-sweeper.mjs";
|
|
44
|
+
|
|
45
|
+
// ---- rows fixture helpers(内存构造,不触盘)----
|
|
46
|
+
const NOW = 1_800_000_000_000;
|
|
47
|
+
const now = () => NOW;
|
|
48
|
+
/** idle 可淘汰 host:hostAlive、非 busy、无客户端、startedAt 很久以前 */
|
|
49
|
+
function idleRow(id, overrides = {}) {
|
|
50
|
+
return {
|
|
51
|
+
meta: { id, updatedAt: NOW - 3600_000 },
|
|
52
|
+
alive: false,
|
|
53
|
+
hostAlive: true,
|
|
54
|
+
host: { state: "alive", attachedClients: 0, startedAt: NOW - 3600_000, runnerPid: 9999 },
|
|
55
|
+
state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 3600_000, pendingQuestions: [] },
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const opts = (overrides = {}) => ({ now: now(), maxWarm: 4, ttlMs: 600_000, graceMs: 30_000, keepViewId: null, ...overrides });
|
|
60
|
+
|
|
61
|
+
test("A1: ttl 到期的 idle host 进入 ttlEvicted", () => {
|
|
62
|
+
const r = selectIdleHostsToEvict([idleRow("v1")], opts());
|
|
63
|
+
assert.deepEqual(r.ttlEvicted, ["v1"]);
|
|
64
|
+
assert.deepEqual(r.excessEvicted, []);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("A1: ttl 未到期(lastActivity 距今 < ttl)保留", () => {
|
|
68
|
+
const r = selectIdleHostsToEvict([idleRow("v1", { state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 60_000, pendingQuestions: [] } })], opts());
|
|
69
|
+
assert.deepEqual(r.ttlEvicted, []);
|
|
70
|
+
assert.deepEqual(r.excessEvicted, []);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("A1: keepViewId 豁免", () => {
|
|
74
|
+
const r = selectIdleHostsToEvict([idleRow("v1")], opts({ keepViewId: "v1" }));
|
|
75
|
+
assert.deepEqual(r.ttlEvicted, []);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("A2: busy host 永不淘汰(queued/working/pendingQuestions)", () => {
|
|
79
|
+
for (const [label, patch] of [
|
|
80
|
+
["working", { alive: true, state: { semanticState: "working", processState: "alive", pendingQuestions: [], lastActivityAt: NOW - 3600_000 } }],
|
|
81
|
+
["queued", { alive: true, state: { semanticState: "queued", processState: "alive", pendingQuestions: [], lastActivityAt: NOW - 3600_000 } }],
|
|
82
|
+
["pendingQuestions", { alive: true, state: { semanticState: "idle", processState: "alive", pendingQuestions: ["q"], lastActivityAt: NOW - 3600_000 } }],
|
|
83
|
+
]) {
|
|
84
|
+
const r = selectIdleHostsToEvict([idleRow("v1", patch)], opts());
|
|
85
|
+
assert.deepEqual(r.ttlEvicted, [], label);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("A2: attachedClients>0 永不淘汰", () => {
|
|
90
|
+
const r = selectIdleHostsToEvict([idleRow("v1", { host: { state: "alive", attachedClients: 1, startedAt: NOW - 3600_000, runnerPid: 9999 } })], opts());
|
|
91
|
+
assert.deepEqual(r.ttlEvicted, []);
|
|
92
|
+
assert.deepEqual(r.excessEvicted, []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("A2: hostAlive=false 不淘汰", () => {
|
|
96
|
+
const r = selectIdleHostsToEvict([idleRow("v1", { hostAlive: false })], opts());
|
|
97
|
+
assert.deepEqual(r.ttlEvicted, []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("A3: graceMs 豁免窗口内(startedAt 距今 < grace)不淘汰;窗口外淘汰", () => {
|
|
101
|
+
const fresh = idleRow("v1", { host: { state: "alive", attachedClients: 0, startedAt: NOW - 5_000, runnerPid: 9999 } });
|
|
102
|
+
assert.deepEqual(selectIdleHostsToEvict([fresh], opts()).ttlEvicted, []);
|
|
103
|
+
const aged = idleRow("v1", { host: { state: "alive", attachedClients: 0, startedAt: NOW - 120_000, runnerPid: 9999 } });
|
|
104
|
+
assert.deepEqual(selectIdleHostsToEvict([aged], opts()).ttlEvicted, ["v1"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("A3: startedAt 缺失时不做 grace 豁免(按 ttl 判)", () => {
|
|
108
|
+
const noStarted = idleRow("v1", { host: { state: "alive", attachedClients: 0, runnerPid: 9999 } });
|
|
109
|
+
assert.deepEqual(selectIdleHostsToEvict([noStarted], opts()).ttlEvicted, ["v1"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("A4: maxWarm 超额淘汰最旧 survivors(idleSince 升序)", () => {
|
|
113
|
+
const older = idleRow("old", { state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 100_000, pendingQuestions: [] } });
|
|
114
|
+
const newer = idleRow("new", { state: { semanticState: "completed", processState: "exited", lastActivityAt: NOW - 50_000, pendingQuestions: [] } });
|
|
115
|
+
// ttl 600s 内都不触发 ttl;maxWarm=1 → 淘汰最旧的 old
|
|
116
|
+
const r = selectIdleHostsToEvict([newer, older], opts({ maxWarm: 1 }));
|
|
117
|
+
assert.deepEqual(r.excessEvicted, ["old"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("A4: maxWarm=0 且 ttlMs=0 返回空(整体禁用语义在调用方;此处按传入参数执行)", () => {
|
|
121
|
+
// 纯函数无早退:ttlMs=0 不走 ttl(保持原逻辑 ttlMs>0 条件),maxWarm=0 → 全淘汰
|
|
122
|
+
const r = selectIdleHostsToEvict([idleRow("v1")], opts({ maxWarm: 0, ttlMs: 0 }));
|
|
123
|
+
assert.deepEqual(r.ttlEvicted, []);
|
|
124
|
+
assert.deepEqual(r.excessEvicted, ["v1"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("isAgentBusy 语义保持", () => {
|
|
128
|
+
assert.equal(isAgentBusy(idleRow("x")), false);
|
|
129
|
+
assert.equal(isAgentBusy(idleRow("x", { alive: true, state: { semanticState: "working", processState: "alive", pendingQuestions: [] } })), true);
|
|
130
|
+
assert.equal(isAgentBusy(idleRow("x", { alive: true, state: { semanticState: "idle", processState: "alive", pendingQuestions: ["q"] } })), true);
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- [ ] **Step 2: 跑测试确认 fail**
|
|
135
|
+
|
|
136
|
+
Run: `node --test test/warm-host-sweeper.test.mjs`
|
|
137
|
+
Expected: FAIL(模块不存在,ERR_MODULE_NOT_FOUND)
|
|
138
|
+
|
|
139
|
+
- [ ] **Step 3: 实现 `src/core/warm-host-sweeper.mjs`**
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
/**
|
|
143
|
+
* Warm-host sweep reclaim (issue #75).
|
|
144
|
+
*
|
|
145
|
+
* Idle PTY hosts must not live forever: after detach the runner is a detached
|
|
146
|
+
* orphan that nothing else will reap (Windows has no parent-death cascade, and
|
|
147
|
+
* the dashboard host may exit without a cleanup hook). The design intent
|
|
148
|
+
* (AGENT_BOARD_WARM_HOST_TTL_MS / MAX_WARM_HOSTS) already exists in
|
|
149
|
+
* service.mjs pruneWarmHosts but only fires lazily on attach/prewarm/dispatch.
|
|
150
|
+
* This module extracts the pure eviction decision plus a periodic sweeper so
|
|
151
|
+
* the TTL actually runs.
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
/** @param {import("./store.mjs").Row} row */
|
|
155
|
+
export function hasPendingQuestions(row) {
|
|
156
|
+
return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Busy = an agent run is active (queued/working) or the session is waiting on
|
|
161
|
+
* user questions. Idle/completed/failed/stopped are never busy.
|
|
162
|
+
* @param {import("./store.mjs").Row} row
|
|
163
|
+
*/
|
|
164
|
+
export function isAgentBusy(row) {
|
|
165
|
+
const st = row.state?.semanticState;
|
|
166
|
+
return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pure eviction decision for warm PTY hosts. No IO, no env: every threshold is
|
|
171
|
+
* passed in so the logic is directly unit-testable.
|
|
172
|
+
*
|
|
173
|
+
* idle = host alive && !busy && no attached clients && not keepViewId.
|
|
174
|
+
* graceMs exempts freshly started hosts (attach handoff race: ensureHost has
|
|
175
|
+
* started a host but the client has not connected yet).
|
|
176
|
+
* TTL eviction runs first; survivors over maxWarm are evicted oldest-first by
|
|
177
|
+
* idleSince (state.lastActivityAt ?? host.startedAt ?? meta.updatedAt).
|
|
178
|
+
*
|
|
179
|
+
* @param {Array<import("./store.mjs").Row>} rows
|
|
180
|
+
* @param {{ now: number, maxWarm: number, ttlMs: number, graceMs?: number, keepViewId?: string|null }} o
|
|
181
|
+
* @returns {{ ttlEvicted: string[], excessEvicted: string[] }} viewIds, ttl group first
|
|
182
|
+
*/
|
|
183
|
+
export function selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs = 0, keepViewId = null }) {
|
|
184
|
+
const idle = [];
|
|
185
|
+
for (const row of rows) {
|
|
186
|
+
if (keepViewId != null && row.meta.id === keepViewId) continue;
|
|
187
|
+
if (!row.hostAlive) continue;
|
|
188
|
+
if (isAgentBusy(row)) continue;
|
|
189
|
+
if ((row.host?.attachedClients ?? 0) !== 0) continue;
|
|
190
|
+
const startedAt = row.host?.startedAt;
|
|
191
|
+
if (graceMs > 0 && startedAt != null && now - startedAt < graceMs) continue;
|
|
192
|
+
const idleSince = row.state?.lastActivityAt ?? startedAt ?? row.meta.updatedAt;
|
|
193
|
+
idle.push({ id: row.meta.id, idleSince });
|
|
194
|
+
}
|
|
195
|
+
const ttlEvicted = [];
|
|
196
|
+
const survivors = [];
|
|
197
|
+
for (const it of idle) {
|
|
198
|
+
// Keep the historical semantics: ttlMs === 0 disables the ttl branch
|
|
199
|
+
// (only the maxWarm cap applies); both zero disables eviction entirely.
|
|
200
|
+
if (ttlMs > 0 && now - it.idleSince > ttlMs) ttlEvicted.push(it.id);
|
|
201
|
+
else survivors.push(it);
|
|
202
|
+
}
|
|
203
|
+
survivors.sort((a, b) => a.idleSince - b.idleSince);
|
|
204
|
+
const excess = Math.max(0, survivors.length - maxWarm);
|
|
205
|
+
return { ttlEvicted, excessEvicted: survivors.slice(0, excess).map((it) => it.id) };
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- [ ] **Step 4: 切换 service.mjs 的 busy 判定为 import(删除本地重复定义)**
|
|
210
|
+
|
|
211
|
+
在 `src/runtime/service.mjs`:
|
|
212
|
+
1. imports 区(约 35 行 `import { normalizeScreenLogMaxBytes...` 之后)加:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
import { isAgentBusy, selectIdleHostsToEvict } from "../core/warm-host-sweeper.mjs";
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
2. 删除模块尾部两个本地函数(1098 行附近):
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
/** @param {import("../core/store.mjs").Row} row */
|
|
222
|
+
function hasPendingQuestions(row) {
|
|
223
|
+
return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {import("../core/store.mjs").Row} row */
|
|
227
|
+
function isAgentBusy(row) {
|
|
228
|
+
const st = row.state?.semanticState;
|
|
229
|
+
return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
(hasPendingQuestions 一并删除:`isAgentBusy` 新实现内置同逻辑;先确认 service.mjs 无其他 hasPendingQuestions 引用:`grep -n "hasPendingQuestions" src/runtime/service.mjs` 应只剩删除区两行。)
|
|
234
|
+
|
|
235
|
+
- [ ] **Step 5: 全量跑测试确认无回归**
|
|
236
|
+
|
|
237
|
+
Run: `node --test test/*.test.mjs`
|
|
238
|
+
Expected: 与基线一致(本机基线含已知 Windows 环境性失败 16-17 个;`test/warm-host-sweeper.test.mjs` 全过)
|
|
239
|
+
|
|
240
|
+
- [ ] **Step 6: Commit**
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
git add src/core/warm-host-sweeper.mjs test/warm-host-sweeper.test.mjs src/runtime/service.mjs
|
|
244
|
+
git commit -m "feat: pure warm-host eviction selector with grace exemption (issue #75)"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
### Task 2: 调度器 `createWarmHostSweeper` + 生命周期接线函数
|
|
250
|
+
|
|
251
|
+
**Files:**
|
|
252
|
+
- Modify: `src/core/warm-host-sweeper.mjs`(追加两个导出)
|
|
253
|
+
- Modify: `test/warm-host-sweeper.test.mjs`(追加用例)
|
|
254
|
+
|
|
255
|
+
**Interfaces:**
|
|
256
|
+
- Produces: `createWarmHostSweeper({ sweep, intervalMs }) -> { start(), stop(), sweepNow(), active }`;`attachWarmHostSweeper(pi, { isHostedChild, sweep, intervalMs }) -> { dispose(), active }`
|
|
257
|
+
- Consumes: Task 1 模块;调用方(Task 4 index.ts)提供 sweep 闭包与 intervalMs
|
|
258
|
+
|
|
259
|
+
**验收:** A5(unit),A6 的接线语义部分(child no-op / 启动即 sweep / shutdown 时 sweepNow+stop,用 fake pi 与 fake sweep)
|
|
260
|
+
|
|
261
|
+
- [ ] **Step 1: 写 failing 测试(追加到 `test/warm-host-sweeper.test.mjs`)**
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
import { attachWarmHostSweeper, createWarmHostSweeper } from "../src/core/warm-host-sweeper.mjs";
|
|
265
|
+
|
|
266
|
+
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
267
|
+
|
|
268
|
+
test("A5: createWarmHostSweeper 周期触发 sweep;stop 后不再触发;sweepNow 立即执行", async () => {
|
|
269
|
+
let calls = 0;
|
|
270
|
+
const sweeper = createWarmHostSweeper({ sweep: () => { calls += 1; }, intervalMs: 20 });
|
|
271
|
+
sweeper.start();
|
|
272
|
+
await sleepMs(75); // ≥3 个周期
|
|
273
|
+
assert.ok(calls >= 2, `expected >=2 periodic sweeps, got ${calls}`);
|
|
274
|
+
sweeper.stop();
|
|
275
|
+
const after = calls;
|
|
276
|
+
await sleepMs(50);
|
|
277
|
+
assert.equal(calls, after, "stop 后不得再触发");
|
|
278
|
+
sweeper.sweepNow();
|
|
279
|
+
assert.equal(calls, after + 1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("A5: intervalMs<=0 时 start 不启周期,sweepNow 仍可用", async () => {
|
|
283
|
+
let calls = 0;
|
|
284
|
+
const sweeper = createWarmHostSweeper({ sweep: () => { calls += 1; }, intervalMs: 0 });
|
|
285
|
+
sweeper.start();
|
|
286
|
+
await sleepMs(40);
|
|
287
|
+
assert.equal(calls, 0);
|
|
288
|
+
sweeper.sweepNow();
|
|
289
|
+
assert.equal(calls, 1);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("A5: start 幂等(重复 start 不叠加定时器)", async () => {
|
|
293
|
+
let calls = 0;
|
|
294
|
+
const sweeper = createWarmHostSweeper({ sweep: () => { calls += 1; }, intervalMs: 20 });
|
|
295
|
+
sweeper.start();
|
|
296
|
+
sweeper.start();
|
|
297
|
+
await sleepMs(60);
|
|
298
|
+
assert.ok(calls >= 1);
|
|
299
|
+
sweeper.stop();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("A6(接线): 非 child:启动即 sweep 一次 + 注册 session_shutdown;shutdown 触发 sweepNow+stop", async () => {
|
|
303
|
+
const events = {};
|
|
304
|
+
const fakePi = { on: (ev, fn) => { events[ev] = fn; } };
|
|
305
|
+
let sweeps = 0;
|
|
306
|
+
const attached = attachWarmHostSweeper(fakePi, {
|
|
307
|
+
isHostedChild: false,
|
|
308
|
+
sweep: () => { sweeps += 1; },
|
|
309
|
+
intervalMs: 0, // 关周期,只验启动与 shutdown 语义
|
|
310
|
+
});
|
|
311
|
+
assert.equal(sweeps, 1, "启动即 sweep 一次");
|
|
312
|
+
assert.equal(typeof events.session_shutdown, "function", "注册了 session_shutdown");
|
|
313
|
+
events.session_shutdown();
|
|
314
|
+
assert.equal(sweeps, 2, "shutdown 时 sweepNow");
|
|
315
|
+
attached.dispose();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("A6(接线): child:完全 no-op(不 sweep、不注册、active=false)", () => {
|
|
319
|
+
const events = {};
|
|
320
|
+
const fakePi = { on: (ev, fn) => { events[ev] = fn; } };
|
|
321
|
+
let sweeps = 0;
|
|
322
|
+
const attached = attachWarmHostSweeper(fakePi, {
|
|
323
|
+
isHostedChild: true,
|
|
324
|
+
sweep: () => { sweeps += 1; },
|
|
325
|
+
intervalMs: 1000,
|
|
326
|
+
});
|
|
327
|
+
assert.equal(attached.active, false);
|
|
328
|
+
assert.equal(sweeps, 0);
|
|
329
|
+
assert.equal(events.session_shutdown, undefined);
|
|
330
|
+
attached.dispose();
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- [ ] **Step 2: 跑测试确认 fail**
|
|
335
|
+
|
|
336
|
+
Run: `node --test test/warm-host-sweeper.test.mjs`
|
|
337
|
+
Expected: FAIL(createWarmHostSweeper / attachWarmHostSweeper 未定义)
|
|
338
|
+
|
|
339
|
+
- [ ] **Step 3: 实现(追加到 `src/core/warm-host-sweeper.mjs` 尾部)**
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
/**
|
|
343
|
+
* Periodic sweeper for warm hosts. The interval timer is unref'd so it never
|
|
344
|
+
* holds the host pi's event loop open on exit. intervalMs <= 0 disables the
|
|
345
|
+
* periodic part; sweepNow() always works.
|
|
346
|
+
* @param {{ sweep: () => void, intervalMs: number }} o
|
|
347
|
+
*/
|
|
348
|
+
export function createWarmHostSweeper({ sweep, intervalMs }) {
|
|
349
|
+
let timer = null;
|
|
350
|
+
let stopped = false;
|
|
351
|
+
return {
|
|
352
|
+
active: true,
|
|
353
|
+
start() {
|
|
354
|
+
if (stopped || timer !== null) return;
|
|
355
|
+
if (intervalMs > 0) {
|
|
356
|
+
timer = setInterval(() => {
|
|
357
|
+
try { sweep(); } catch { /* best-effort */ }
|
|
358
|
+
}, intervalMs);
|
|
359
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
sweepNow() {
|
|
363
|
+
if (stopped) return;
|
|
364
|
+
try { sweep(); } catch { /* best-effort */ }
|
|
365
|
+
},
|
|
366
|
+
stop() {
|
|
367
|
+
stopped = true;
|
|
368
|
+
if (timer !== null) {
|
|
369
|
+
clearInterval(timer);
|
|
370
|
+
timer = null;
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Wire the sweeper to a host pi extension lifetime: sweep once on attach
|
|
378
|
+
* (reclaims hosts leaked by a previous host that died without cleanup), run
|
|
379
|
+
* periodically, and sweep again on session_shutdown (host pi exiting or the
|
|
380
|
+
* extension instance being reloaded for a session switch).
|
|
381
|
+
*
|
|
382
|
+
* Child pi processes (AGENT_BOARD_CHILD=1 / AGENT_VIEW_CHILD=1) must never
|
|
383
|
+
* sweep: they share the same board root and would terminate their own runner
|
|
384
|
+
* (suicide chain). They get a strict no-op.
|
|
385
|
+
*
|
|
386
|
+
* @param {{ on?: (event: string, fn: () => void) => unknown }} pi
|
|
387
|
+
* @param {{ isHostedChild: boolean, sweep: () => void, intervalMs: number }} o
|
|
388
|
+
*/
|
|
389
|
+
export function attachWarmHostSweeper(pi, { isHostedChild, sweep, intervalMs }) {
|
|
390
|
+
if (isHostedChild) {
|
|
391
|
+
return { active: false, dispose() {} };
|
|
392
|
+
}
|
|
393
|
+
const sweeper = createWarmHostSweeper({ sweep, intervalMs });
|
|
394
|
+
const onShutdown = () => {
|
|
395
|
+
sweeper.sweepNow();
|
|
396
|
+
sweeper.stop();
|
|
397
|
+
};
|
|
398
|
+
pi.on?.("session_shutdown", onShutdown);
|
|
399
|
+
sweeper.start();
|
|
400
|
+
sweeper.sweepNow(); // 回收上一个宿主(可能非正常退出)遗留的 warm hosts
|
|
401
|
+
return {
|
|
402
|
+
active: true,
|
|
403
|
+
dispose() {
|
|
404
|
+
onShutdown();
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
- [ ] **Step 4: 跑测试确认过**
|
|
411
|
+
|
|
412
|
+
Run: `node --test test/warm-host-sweeper.test.mjs`
|
|
413
|
+
Expected: 全部 PASS(真定时器 20ms 周期测试约需 200ms 实耗)
|
|
414
|
+
|
|
415
|
+
- [ ] **Step 5: Commit**
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
git add src/core/warm-host-sweeper.mjs test/warm-host-sweeper.test.mjs
|
|
419
|
+
git commit -m "feat: periodic warm-host sweeper + host-pi lifecycle wiring (issue #75)"
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
### Task 3: service.mjs 的 pruneWarmHosts 重构(纯函数调用 + grace env)+ 暴露服务方法
|
|
425
|
+
|
|
426
|
+
**Files:**
|
|
427
|
+
- Modify: `src/runtime/service.mjs`
|
|
428
|
+
- `pruneWarmHosts` 函数体(现 404-432 行附近)
|
|
429
|
+
- createService 返回对象尾部(`row(viewId)` 方法之后、`};` 之前,约 1090 行)加服务方法
|
|
430
|
+
- `envInt` 加 export(1177 行)
|
|
431
|
+
|
|
432
|
+
**Interfaces:**
|
|
433
|
+
- Consumes: Task 1 `selectIdleHostsToEvict`;现有 `envInt`、`listRows`、`loadRow`、`sendHostMessage`(均 service.mjs 闭包内)
|
|
434
|
+
- Produces: createService 返回值新增方法 `pruneWarmHosts(pruneOpts?: { keepViewId?: string|null }) -> void`;`export function envInt`(index.ts Task 4 用)
|
|
435
|
+
|
|
436
|
+
**验收:** A3(env 接线),A8(重构等价回归)
|
|
437
|
+
|
|
438
|
+
- [ ] **Step 1: 重构 `pruneWarmHosts`(替换现有 404-432 行函数体)**
|
|
439
|
+
|
|
440
|
+
现有函数(含 warm pool 注释与 maxWarm/ttlMs 读取)替换为:
|
|
441
|
+
|
|
442
|
+
```js
|
|
443
|
+
/**
|
|
444
|
+
* Evict idle warm PTY hosts past TTL / over the warm pool cap.
|
|
445
|
+
* Idle = host alive, not agent-busy, no attached clients.
|
|
446
|
+
* Called lazily from dispatch/ensureHost (keepViewId = the view being used)
|
|
447
|
+
* and periodically / on shutdown by the warm-host sweeper (issue #75).
|
|
448
|
+
* @param {{ keepViewId?: string|null }} [pruneOpts]
|
|
449
|
+
*/
|
|
450
|
+
function pruneWarmHosts(pruneOpts = {}) {
|
|
451
|
+
const maxWarm = envInt("AGENT_BOARD_MAX_WARM_HOSTS", 4, 0, 50, "AGENT_VIEW_MAX_WARM_HOSTS");
|
|
452
|
+
const ttlMs = envInt("AGENT_BOARD_WARM_HOST_TTL_MS", 10 * 60 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_TTL_MS");
|
|
453
|
+
if (maxWarm === 0 && ttlMs === 0) return;
|
|
454
|
+
const graceMs = envInt("AGENT_BOARD_WARM_HOST_GRACE_MS", 30 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_GRACE_MS");
|
|
455
|
+
const { ttlEvicted, excessEvicted } = selectIdleHostsToEvict(listRows(root), {
|
|
456
|
+
now: Date.now(),
|
|
457
|
+
maxWarm,
|
|
458
|
+
ttlMs,
|
|
459
|
+
graceMs,
|
|
460
|
+
keepViewId: pruneOpts.keepViewId ?? null,
|
|
461
|
+
});
|
|
462
|
+
for (const viewId of [...ttlEvicted, ...excessEvicted]) {
|
|
463
|
+
const row = loadRow(root, viewId);
|
|
464
|
+
if (row?.hostAlive) sendHostMessage(row, { type: "terminate" });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
注意:原实现直接对 listRows 结果发 terminate(行即最新);改为淘汰后按 viewId `loadRow` 复查 `hostAlive`——**消除 listRows 与发消息之间的竞态窗口**(原实现同样竞态,此行改动是净改善且行为等价:host 已死则跳过)。
|
|
470
|
+
|
|
471
|
+
- [ ] **Step 2: createService 返回对象暴露服务方法**(`row(viewId)` 方法后加)
|
|
472
|
+
|
|
473
|
+
```js
|
|
474
|
+
/**
|
|
475
|
+
* Evict idle warm PTY hosts (TTL / warm-pool cap). Safe to call anytime:
|
|
476
|
+
* busy and attached hosts are never touched.
|
|
477
|
+
* @param {{ keepViewId?: string|null }} [pruneOpts]
|
|
478
|
+
*/
|
|
479
|
+
pruneWarmHosts(pruneOpts = {}) {
|
|
480
|
+
pruneWarmHosts(pruneOpts);
|
|
481
|
+
},
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
(方法体内裸标识符 `pruneWarmHosts` 词法解析到闭包函数——方法名不在方法体内自绑定,合法且无歧义。)
|
|
485
|
+
|
|
486
|
+
- [ ] **Step 3: envInt 加 export**(1177 行 `function envInt` → `export function envInt`)
|
|
487
|
+
|
|
488
|
+
- [ ] **Step 4: 验证**
|
|
489
|
+
|
|
490
|
+
Run: `node --test test/*.test.mjs`
|
|
491
|
+
Expected: 无新增失败(与基线一致);新用例暂无——Task 4 的 integration 测试覆盖本任务产出
|
|
492
|
+
|
|
493
|
+
- [ ] **Step 5: Commit**
|
|
494
|
+
|
|
495
|
+
```bash
|
|
496
|
+
git add src/runtime/service.mjs
|
|
497
|
+
git commit -m "refactor: pruneWarmHosts via pure selector + grace env, expose service method (issue #75)"
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
### Task 4: integration 测试——真实行 + net server 捕获 terminate
|
|
503
|
+
|
|
504
|
+
**Files:**
|
|
505
|
+
- Create: `test/warm-host-sweep.integration.test.mjs`
|
|
506
|
+
|
|
507
|
+
**Interfaces:**
|
|
508
|
+
- Consumes: Task 1-3(`createService(...).pruneWarmHosts`、store 基建 `createView/writeState/writeHost/writeHostPid`、`P.controlSocketPath`);Task 2 `attachWarmHostSweeper`
|
|
509
|
+
- Produces: 无(纯验证)
|
|
510
|
+
|
|
511
|
+
**验收:** A6(integration:idle 收 terminate / busy 不收 / 启动即 sweep)、A7(session_shutdown 触发 sweepNow)
|
|
512
|
+
|
|
513
|
+
- [ ] **Step 1: 写测试**
|
|
514
|
+
|
|
515
|
+
```js
|
|
516
|
+
import assert from "node:assert/strict";
|
|
517
|
+
import { createServer } from "node:net";
|
|
518
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
519
|
+
import { tmpdir } from "node:os";
|
|
520
|
+
import { join } from "node:path";
|
|
521
|
+
import { test } from "node:test";
|
|
522
|
+
import { createService } from "../src/runtime/service.mjs";
|
|
523
|
+
import { createView, readState, writeState, writeHost, writeHostPid } from "../src/core/store.mjs";
|
|
524
|
+
import * as P from "../src/core/paths.mjs";
|
|
525
|
+
import { attachWarmHostSweeper } from "../src/core/warm-host-sweeper.mjs";
|
|
526
|
+
|
|
527
|
+
function freshRoot() {
|
|
528
|
+
return mkdtempSync(join(tmpdir(), "agentboard-warmhost-"));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function makeService(root) {
|
|
532
|
+
return createService({
|
|
533
|
+
root,
|
|
534
|
+
runnerScript: "/no/runner.mjs",
|
|
535
|
+
piCommand: "pi",
|
|
536
|
+
piArgsPrefix: [],
|
|
537
|
+
defaultCwd: process.cwd(),
|
|
538
|
+
launch: () => ({ pid: null, configPath: "/no/config.json" }),
|
|
539
|
+
launchHost: () => ({ pid: null, configPath: "/no/host-config.json" }),
|
|
540
|
+
launchTitle: () => ({ pid: null, configPath: "/no/title-config.json" }),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 起一个监听 viewId socketPath 的 net server,返回 { server, received } */
|
|
545
|
+
async function captureHostSocket(root, viewId) {
|
|
546
|
+
const received = [];
|
|
547
|
+
const server = createServer((socket) => {
|
|
548
|
+
let buf = "";
|
|
549
|
+
socket.on("data", (chunk) => {
|
|
550
|
+
buf += chunk.toString("utf8");
|
|
551
|
+
for (const line of buf.split("\n")) {
|
|
552
|
+
if (!line.trim()) continue;
|
|
553
|
+
try { received.push(JSON.parse(line)); } catch { /* ignore */ }
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
await new Promise((resolve, reject) => {
|
|
558
|
+
server.once("error", reject);
|
|
559
|
+
server.listen(P.controlSocketPath(root, viewId), resolve);
|
|
560
|
+
});
|
|
561
|
+
return { server, received };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const waitFor = async (pred, timeoutMs = 3000) => {
|
|
565
|
+
const start = Date.now();
|
|
566
|
+
while (Date.now() - start < timeoutMs) {
|
|
567
|
+
if (pred()) return true;
|
|
568
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
569
|
+
}
|
|
570
|
+
return pred();
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
function idleView(root, id) {
|
|
574
|
+
createView(root, { id, name: id, cwd: "/r" });
|
|
575
|
+
const s = readState(root, id);
|
|
576
|
+
s.semanticState = "completed";
|
|
577
|
+
s.processState = "exited";
|
|
578
|
+
s.lastActivityAt = Date.now() - 3600_000;
|
|
579
|
+
writeState(root, s);
|
|
580
|
+
writeHost(root, {
|
|
581
|
+
viewId: id,
|
|
582
|
+
mode: "pty",
|
|
583
|
+
state: "alive",
|
|
584
|
+
runnerPid: process.pid, // hostAlive 判定用(isAlive(process.pid)=true)
|
|
585
|
+
socketPath: P.controlSocketPath(root, id),
|
|
586
|
+
startedAt: Date.now() - 3600_000,
|
|
587
|
+
attachedClients: 0,
|
|
588
|
+
lastSeenAt: Date.now(),
|
|
589
|
+
});
|
|
590
|
+
writeHostPid(root, id, process.pid);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
test("A6: 周期/手动 sweep 后 idle host 收到 terminate、busy host 不收", async () => {
|
|
594
|
+
const root = freshRoot();
|
|
595
|
+
const oldTtl = process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
|
|
596
|
+
const oldGrace = process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
|
|
597
|
+
process.env.AGENT_BOARD_WARM_HOST_TTL_MS = "1"; // 立即过期
|
|
598
|
+
process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = "0";
|
|
599
|
+
try {
|
|
600
|
+
idleView(root, "idle1");
|
|
601
|
+
const { server, received } = await captureHostSocket(root, "idle1");
|
|
602
|
+
const svc = makeService(root);
|
|
603
|
+
svc.pruneWarmHosts();
|
|
604
|
+
const got = await waitFor(() => received.some((m) => m.type === "terminate"));
|
|
605
|
+
assert.equal(got, true, "idle host 应收到 terminate");
|
|
606
|
+
server.close();
|
|
607
|
+
} finally {
|
|
608
|
+
if (oldTtl === undefined) delete process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
|
|
609
|
+
else process.env.AGENT_BOARD_WARM_HOST_TTL_MS = oldTtl;
|
|
610
|
+
if (oldGrace === undefined) delete process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
|
|
611
|
+
else process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = oldGrace;
|
|
612
|
+
rmSync(root, { recursive: true, force: true });
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("A6: busy host 不被 sweep 触碰", async () => {
|
|
617
|
+
const root = freshRoot();
|
|
618
|
+
const oldTtl = process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
|
|
619
|
+
process.env.AGENT_BOARD_WARM_HOST_TTL_MS = "1";
|
|
620
|
+
try {
|
|
621
|
+
createView(root, { id: "busy1", name: "busy1", cwd: "/r" });
|
|
622
|
+
const s = readState(root, "busy1");
|
|
623
|
+
s.semanticState = "working";
|
|
624
|
+
s.processState = "alive";
|
|
625
|
+
s.lastActivityAt = Date.now() - 3600_000;
|
|
626
|
+
writeState(root, s);
|
|
627
|
+
writeHost(root, {
|
|
628
|
+
viewId: "busy1", mode: "pty", state: "alive", runnerPid: process.pid,
|
|
629
|
+
socketPath: P.controlSocketPath(root, "busy1"),
|
|
630
|
+
startedAt: Date.now() - 3600_000, attachedClients: 0, lastSeenAt: Date.now(),
|
|
631
|
+
});
|
|
632
|
+
writeHostPid(root, "busy1", process.pid);
|
|
633
|
+
const { server, received } = await captureHostSocket(root, "busy1");
|
|
634
|
+
const svc = makeService(root);
|
|
635
|
+
svc.pruneWarmHosts();
|
|
636
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
637
|
+
assert.equal(received.some((m) => m.type === "terminate"), false, "busy host 不得收 terminate");
|
|
638
|
+
server.close();
|
|
639
|
+
} finally {
|
|
640
|
+
if (oldTtl === undefined) delete process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
|
|
641
|
+
else process.env.AGENT_BOARD_WARM_HOST_TTL_MS = oldTtl;
|
|
642
|
+
rmSync(root, { recursive: true, force: true });
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("A7: session_shutdown 触发 sweepNow(lifecycle 接线端到端)", async () => {
|
|
647
|
+
const root = freshRoot();
|
|
648
|
+
const oldTtl = process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
|
|
649
|
+
const oldGrace = process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
|
|
650
|
+
process.env.AGENT_BOARD_WARM_HOST_TTL_MS = "1";
|
|
651
|
+
process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = "0";
|
|
652
|
+
try {
|
|
653
|
+
idleView(root, "idle1");
|
|
654
|
+
const { server, received } = await captureHostSocket(root, "idle1");
|
|
655
|
+
const events = {};
|
|
656
|
+
const fakePi = { on: (ev, fn) => { events[ev] = fn; } };
|
|
657
|
+
const svc = makeService(root);
|
|
658
|
+
const attached = attachWarmHostSweeper(fakePi, {
|
|
659
|
+
isHostedChild: false,
|
|
660
|
+
sweep: () => svc.pruneWarmHosts(),
|
|
661
|
+
intervalMs: 0,
|
|
662
|
+
});
|
|
663
|
+
// 启动即 sweep 已发 terminate(A6);验证 shutdown 再次 sweep(幂等 terminate 无害)
|
|
664
|
+
const got1 = await waitFor(() => received.some((m) => m.type === "terminate"));
|
|
665
|
+
assert.equal(got1, true);
|
|
666
|
+
received.length = 0;
|
|
667
|
+
events.session_shutdown();
|
|
668
|
+
const got2 = await waitFor(() => received.some((m) => m.type === "terminate"));
|
|
669
|
+
assert.equal(got2, true, "shutdown sweep 应再次发 terminate");
|
|
670
|
+
attached.dispose();
|
|
671
|
+
server.close();
|
|
672
|
+
} finally {
|
|
673
|
+
if (oldTtl === undefined) delete process.env.AGENT_BOARD_WARM_HOST_TTL_MS;
|
|
674
|
+
else process.env.AGENT_BOARD_WARM_HOST_TTL_MS = oldTtl;
|
|
675
|
+
if (oldGrace === undefined) delete process.env.AGENT_BOARD_WARM_HOST_GRACE_MS;
|
|
676
|
+
else process.env.AGENT_BOARD_WARM_HOST_GRACE_MS = oldGrace;
|
|
677
|
+
rmSync(root, { recursive: true, force: true });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
(Windows 平台注意:`P.controlSocketPath` 为命名管道(`\\.\pipe\pi-agent-board-<viewId>`),`net.createServer().listen(pipePath)` 直接支持;本机即 Windows。)
|
|
683
|
+
|
|
684
|
+
- [ ] **Step 2: 跑测试**
|
|
685
|
+
|
|
686
|
+
Run: `node --test test/warm-host-sweep.integration.test.mjs`
|
|
687
|
+
Expected: 3 个用例全 PASS(若 `writeHostPid` 未被 store.mjs 导出则改由 `writeHost` 的 runnerPid 字段承载——`loadRow` 用 `host?.runnerPid ?? readHostPid`,两者其一即可;测试已两者都写)
|
|
688
|
+
|
|
689
|
+
- [ ] **Step 3: Commit**
|
|
690
|
+
|
|
691
|
+
```bash
|
|
692
|
+
git add test/warm-host-sweep.integration.test.mjs
|
|
693
|
+
git commit -m "test: integration coverage for warm-host sweep terminate path (issue #75)"
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
### Task 5: index.ts 接线(宿主扩展生命周期挂 sweeper)
|
|
699
|
+
|
|
700
|
+
**Files:**
|
|
701
|
+
- Modify: `src/index.ts`
|
|
702
|
+
|
|
703
|
+
**Interfaces:**
|
|
704
|
+
- Consumes: Task 2 `attachWarmHostSweeper`;Task 3 `envInt`(service.mjs export);本文件已有 `isHostedChild`/`root`/`commandOpts`
|
|
705
|
+
- Produces: 宿主 pi(非 child)加载扩展即注册周期 sweep + session_shutdown 清理
|
|
706
|
+
|
|
707
|
+
**验收:** A6 接线真环境(typecheck + Task 4 lifecycle 测试佐证),A8
|
|
708
|
+
|
|
709
|
+
- [ ] **Step 1: imports(index.ts 顶部现有 import 区加两行)**
|
|
710
|
+
|
|
711
|
+
```ts
|
|
712
|
+
import { attachWarmHostSweeper } from "./core/warm-host-sweeper.mjs";
|
|
713
|
+
import { createService, envInt } from "./runtime/service.mjs";
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
(注意替换现有 `import { createService } from "./runtime/service.mjs";` 行——合并成一个 import。)
|
|
717
|
+
|
|
718
|
+
- [ ] **Step 2: piAgentBoard(pi) 函数内、`registerAgentBoardCommand` 之前加接线**
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
// Warm-host reclaim (issue #75): idle PTY hosts must not leak forever after
|
|
722
|
+
// detach. Sweep once now (reclaims hosts orphaned by a previous host pi that
|
|
723
|
+
// exited without cleanup), then periodically; sweep again on shutdown.
|
|
724
|
+
// Child pi processes skip entirely (they share the board root and would
|
|
725
|
+
// terminate their own runner).
|
|
726
|
+
attachWarmHostSweeper(pi, {
|
|
727
|
+
isHostedChild,
|
|
728
|
+
sweep: () => {
|
|
729
|
+
try {
|
|
730
|
+
serviceForContext().pruneWarmHosts();
|
|
731
|
+
} catch {
|
|
732
|
+
/* best-effort: never break the session over a sweep */
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
intervalMs: envInt("AGENT_BOARD_SWEEP_INTERVAL_MS", 60_000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_SWEEP_INTERVAL_MS"),
|
|
736
|
+
});
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
其中 `serviceForContext` 是模块级工厂,替代现有 `serviceFor(ctx)` 对 ctx 的依赖(sweep 回调在 session_shutdown 时可能无 ctx):
|
|
740
|
+
|
|
741
|
+
在 `serviceFor` 定义附近(现约 53 行)新增并让 `serviceFor` 复用它:
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
const serviceForContext = () =>
|
|
745
|
+
createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: process.cwd() });
|
|
746
|
+
const serviceFor = (ctx: ExtensionContext) =>
|
|
747
|
+
createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
(保持两行独立亦可——sweep 用 `defaultCwd: process.cwd()` 的版本;**不要**用共享同一 service 实例的方案:piAgentBoard 内 createService 的 defaultCwd 依赖 ctx,session 间不同;sweep 每次临时创建 service 与现有 `serviceFor` 行为一致且无状态泄漏。)
|
|
751
|
+
|
|
752
|
+
- [ ] **Step 3: 验证**
|
|
753
|
+
|
|
754
|
+
Run: `npm run typecheck`
|
|
755
|
+
Expected: 无类型错误
|
|
756
|
+
Run: `node --test test/warm-host-sweeper.test.mjs test/warm-host-sweep.integration.test.mjs`
|
|
757
|
+
Expected: 全 PASS
|
|
758
|
+
|
|
759
|
+
- [ ] **Step 4: Commit**
|
|
760
|
+
|
|
761
|
+
```bash
|
|
762
|
+
git add src/index.ts
|
|
763
|
+
git commit -m "feat: wire warm-host sweeper into host pi lifecycle (issue #75)"
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
---
|
|
767
|
+
|
|
768
|
+
### Task 6: 全量回归 + U1 用户实测
|
|
769
|
+
|
|
770
|
+
**Files:**
|
|
771
|
+
- 无代码改动(验证任务)
|
|
772
|
+
|
|
773
|
+
**验收:** A8(全量回归无新增失败)、U1(用户实测回收闭环)
|
|
774
|
+
|
|
775
|
+
- [ ] **Step 1: 全量回归**
|
|
776
|
+
|
|
777
|
+
Run: `npm run typecheck && node --test test/*.test.mjs`
|
|
778
|
+
Expected: typecheck 0 错误;测试与基线一致(无新增失败;新增 3 个测试文件全部 PASS)
|
|
779
|
+
|
|
780
|
+
- [ ] **Step 2: U1 用户实测清单(提交 PR 前在真实环境执行,结果记录到 PR 描述)**
|
|
781
|
+
|
|
782
|
+
1. 在宿主 pi(本机)加载新扩展代码(worktree 内 `node src/...` 不方便——用安装目录替换或等 PR 合入后实测;临时方案:设 `AGENT_BOARD_WARM_HOST_TTL_MS=10000` 与 `AGENT_BOARD_SWEEP_INTERVAL_MS=5000` 重启 pi 会话)
|
|
783
|
+
2. `/agent-board` → 选一个会话 attach → detach 返回 board → 关掉宿主 pi 窗口
|
|
784
|
+
3. 等待 >10s(TTL 10s)+ >5s(sweep 周期)
|
|
785
|
+
4. 检查:该 view 的 host.json `state: "exited"`;runner 与 child pi 进程已退出(任务管理器 / `Get-Process`)
|
|
786
|
+
5. 通过标准:进程消失、host.json finalize、重启 pi 后 dashboard 中该会话可正常重新 attach(冷启动路径)
|
|
787
|
+
|
|
788
|
+
- [ ] **Step 3: 清理 env 痕迹**(若 Step 2 在宿主真实环境设过 env,恢复默认后验证一次默认参数路径)
|
|
789
|
+
|
|
790
|
+
---
|
|
791
|
+
|
|
792
|
+
## Self-Review
|
|
793
|
+
|
|
794
|
+
- **Spec coverage**:M1 → Task 1(selectIdleHostsToEvict 纯函数 + graceMs)✓;M2 → Task 2(createWarmHostSweeper)✓;M3 → Task 2(attachWarmHostSweeper)+ Task 5(index.ts 接线)✓;M4 取消(偏差记录于 Global Constraints)✓;Non-goals 未引入代码 ✓;A1-A4 → Task 1;A5 → Task 2;A6 → Task 2(lifecycle 语义)+ Task 4(integration)+ Task 5(真接线);A7 → Task 4;A8 → Task 1/3/6;U1 → Task 6
|
|
795
|
+
- **Placeholder scan**:所有代码步骤含完整实现;Task 6 实测步骤为可执行清单
|
|
796
|
+
- **Type consistency**:`selectIdleHostsToEvict(rows, {now,maxWarm,ttlMs,graceMs,keepViewId})` 签名在 Task 1/3 一致;`createWarmHostSweeper({sweep,intervalMs})` 与 `attachWarmHostSweeper(pi,{isHostedChild,sweep,intervalMs})` 在 Task 2/4/5 一致;service 方法 `pruneWarmHosts(pruneOpts?)` 在 Task 3/4/5 一致;`envInt(name,fallback,min,max,legacyName)` export 在 Task 3/5 一致
|