@zhuxixi/pi-agent-board 0.6.0 → 0.6.1
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 +12 -0
- package/README.md +2 -2
- package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
- package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +54 -2
- package/src/commands/agent-board.ts +9 -0
- package/src/commands/bg.ts +9 -0
- package/src/core/heuristics.mjs +35 -0
- package/src/core/host-coordination.mjs +23 -0
- package/src/core/launch-options.mjs +17 -0
- package/src/core/launch.mjs +32 -34
- package/src/index.ts +11 -2
- package/src/runtime/service.mjs +106 -1
- package/src/ui/dashboard.ts +41 -1
- package/src/ui/pty-attach.ts +23 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ conventional commits by `scripts/release_helper.mjs`. Entries are
|
|
|
5
5
|
forward-only: they begin with the first release after this file landed —
|
|
6
6
|
for earlier history, see the git log and the pull-request list.
|
|
7
7
|
|
|
8
|
+
## [0.6.1] - 2026-09-08
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
|
|
12
|
+
- add an unconditional Ctrl+Left detach chord to the attach surface (issue #89) (#97)
|
|
13
|
+
- force full-clear repaint on dashboard mount and content shrink (issue #88) (#96)
|
|
14
|
+
- fail host launch fast on a provably stale defaultModel + exit attribution (issue #90) (#94)
|
|
15
|
+
- finalize provably-dead legacy hosts in attach resolver and self-heal (issue #87) (#93)
|
|
16
|
+
- swallow async spawn errors in detached runner launches (issue #86) (#92)
|
|
17
|
+
|
|
18
|
+
[0.6.1]: https://github.com/zhuxixi/pi-agent-board/compare/v0.6.0...v0.6.1
|
|
19
|
+
|
|
8
20
|
## [0.6.0] - 2026-09-07
|
|
9
21
|
|
|
10
22
|
### Features
|
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ From the board:
|
|
|
93
93
|
- In Peek, press `r` to reply without attaching.
|
|
94
94
|
- Press `v` for a read-only transcript, or `e` for evidence and diagnostics.
|
|
95
95
|
- Press `Enter`, `Right`, or `>` to attach to the real Pi session.
|
|
96
|
-
- In PTY attach mode, press `Left` on an empty child input line to return to the board. `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. When the host is disconnected, `Left` always exits.
|
|
96
|
+
- In PTY attach mode, press `Left` on an empty child input line to return to the board, or `Ctrl+Left` at any time (even mid-draft). `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. When the host is disconnected, `Left` always exits.
|
|
97
97
|
|
|
98
98
|
## Dashboard Workflow
|
|
99
99
|
|
|
@@ -192,7 +192,7 @@ The `e` view shows durable session evidence, including changed files, commands a
|
|
|
192
192
|
|
|
193
193
|
### PTY attach
|
|
194
194
|
|
|
195
|
-
PTY attach opens the real interactive Pi session. On an empty child input line, use `Left` to detach and return to the board; while you are editing text, `Left` is forwarded to the Pi editor, and a disconnected host can always be exited with `Left`. `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. While attached, `PageUp`, `PageDown`, `Home`, `End`, and the mouse wheel scroll local scrollback. Mouse drag or double-click selects and copies text, clicks open detected links, and middle-click paste is available on systems with the required X11 tooling.
|
|
195
|
+
PTY attach opens the real interactive Pi session. On an empty child input line, use `Left` to detach and return to the board; while you are editing text, `Left` is forwarded to the Pi editor, `Ctrl+Left` detaches regardless of editor state, and a disconnected host can always be exited with `Left`. `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. While attached, `PageUp`, `PageDown`, `Home`, `End`, and the mouse wheel scroll local scrollback. Mouse drag or double-click selects and copies text, clicks open detected links, and middle-click paste is available on systems with the required X11 tooling.
|
|
196
196
|
|
|
197
197
|
The attach surface can forward terminal clipboard and image/file passthrough sequences. These behaviors can be disabled individually in [Configuration](#configuration). Cold hosts may briefly show a loading/reconnect surface while their PTY becomes ready.
|
|
198
198
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# issue #89 plan:Ctrl+← detach 和弦实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:Ctrl+← 分支 + header 文案(A1, A2, A4)
|
|
8
|
+
|
|
9
|
+
文件:`src/ui/pty-attach.ts`
|
|
10
|
+
|
|
11
|
+
1. `handleInput` 在 `Key.left` 分支(~L247)之前插入 Ctrl+← 分支(spec D1 代码原样,含注释)。确认 `Key.ctrl` 已从 pi-tui import(文件顶部现有 `Key` import——matchesKey/Key 都在用,无需新 import)。
|
|
12
|
+
2. header 两处文案(~L282 `← detach`、~L298 `← to detach`)→ `←/Ctrl+← detach` / `←/Ctrl+← to detach`。
|
|
13
|
+
|
|
14
|
+
### T2:测试扩建(A1-A4)
|
|
15
|
+
|
|
16
|
+
文件:`test-support/detach-gate-smoke.ts` + `test/pty-attach-detach-gate.test.mjs`
|
|
17
|
+
|
|
18
|
+
1. harness 加场景(照现有场景范式:makeAttach + writeToTerm + handleInput 注入):
|
|
19
|
+
- **ctrlLeftDetachesOnDraft**:造一个 editor_state=draft(或写非空内容行进 buffer——看现有 `leftEditorStateBlocksDetachOnDraft` 场景怎么造 draft)→ `handleInput("\x1b[1;5D")` → didDetach() === true;
|
|
20
|
+
- **ctrlLeftDetachesOnEmptyInput**:空编辑器场景(照现有 C 场景)→ 同序列 → didDetach() === true;
|
|
21
|
+
- **headerMentionsCtrlLeft**:`attach.render(80)` 输出行里含 "Ctrl+←"(header 在 render 输出组装;注意 attaching 状态——renderLoading 的 L298 在 loading 时显示,L282 在正常帧。两个都改后任一断言即可,取正常帧:先 writeToTerm 一点输出让组件脱离 loading)。
|
|
22
|
+
2. 测试文件加 3 条断言(照现有断言风格)。
|
|
23
|
+
|
|
24
|
+
### T3:全量回归(A5)
|
|
25
|
+
|
|
26
|
+
`npm test`(基线 618)+ `npm run typecheck`。
|
|
27
|
+
|
|
28
|
+
## 验收对账
|
|
29
|
+
- A1/A2/A4 → T2 · A3 → 现有断言回归 · A5 → T3
|
|
30
|
+
- U1(用户实测,合并后):attach 活跃 session 输入文字后 Ctrl+← 退出。
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# issue #88 plan:dashboard 花屏修复实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:dashboard.ts render 包装(A1, A2, A3)
|
|
8
|
+
|
|
9
|
+
文件:`src/ui/dashboard.ts`(唯一运行时代码改动文件,.ts 层满足 jiti 可重载约束)
|
|
10
|
+
|
|
11
|
+
1. 组件新增字段(class 字段区,~L126-147 附近):
|
|
12
|
+
```ts
|
|
13
|
+
/** First frame after mount must clear the screen: pi-tui's first render
|
|
14
|
+
* "assumes clean screen" (fullRender(false)) and overlays never get
|
|
15
|
+
* clearOnShrink — crash output / dirty bottoms would persist (issue #88). */
|
|
16
|
+
private needsFullClear = true;
|
|
17
|
+
/** Content line count BEFORE fitToHeight padding (padding always fills the
|
|
18
|
+
* terminal height, so padded counts never shrink — shrink must be detected
|
|
19
|
+
* on content lines). */
|
|
20
|
+
private lastContentLineCount: number | null = null;
|
|
21
|
+
private frameContentLineCount = 0;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
2. `fitToHeight` 开头记录 pad 前行数:`this.frameContentLineCount = lines.length;`
|
|
25
|
+
|
|
26
|
+
3. 现有 `render(width: number): string[]`(L1158 起)整体改名为 `private renderLines(width: number): string[]`,新增包装:
|
|
27
|
+
```ts
|
|
28
|
+
render(width: number): string[] {
|
|
29
|
+
const lines = this.renderLines(width);
|
|
30
|
+
// Self-heal frames (issue #88): pi-tui disables clearOnShrink under overlays,
|
|
31
|
+
// so a content shrink would leave stale rows forever. Force a full clear on
|
|
32
|
+
// the first frame and on any content-line shrink. requestRender(true) is
|
|
33
|
+
// nextTick-async — safe to call from inside render.
|
|
34
|
+
if (this.needsFullClear || (this.lastContentLineCount != null && this.frameContentLineCount < this.lastContentLineCount)) {
|
|
35
|
+
this.needsFullClear = false;
|
|
36
|
+
this.tui.requestRender(true);
|
|
37
|
+
}
|
|
38
|
+
this.lastContentLineCount = this.frameContentLineCount;
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
注意点:
|
|
44
|
+
- `renderLines` 保持原签名与所有 return 分支不变(仅改名);
|
|
45
|
+
- `this.tui.requestRender` 在 Component 的 TUI 类型上存在(pi-tui 导出类型含它;若类型缺 force 参数签名,用 `(this.tui as any).requestRender(true)` 并在注释说明,或查 TUI 类型定义确认——typecheck 必须过);
|
|
46
|
+
- 不改 `src/core/dashboard-render.mjs`(requestDashboardRender 保持差分语义,现有测试不动)。
|
|
47
|
+
|
|
48
|
+
### T2:冒烟测试(A1, A2)
|
|
49
|
+
|
|
50
|
+
1. 新增 `test-support/dashboard-shrink-render.ts`(范式照搬 dashboard-refs-render.ts:真实 root + createView + service + fake tui/theme/keybindings):
|
|
51
|
+
- fake tui 的 `requestRender` 记录调用参数(spy),`terminal.rows` 固定 40;
|
|
52
|
+
- 造 3 个 view → 第 1 次 render(160)(首帧);
|
|
53
|
+
- 第 2 次 render(160)(同数据,无变化);
|
|
54
|
+
- 删掉 1 个 view(service 的删除 API——先看 refs-render 或 service 里 deleteView/removeView 叫什么;若无删除 API,用第三个 view 的 cwd folder 折叠/直接操作 store 删目录亦可,目标是让内容行数减少);
|
|
55
|
+
- 第 3 次 render(160)(内容行数减少);
|
|
56
|
+
- 输出 JSON:`{ calls: [...] , ok: true }`(requestRender 的参数序列)。
|
|
57
|
+
2. `test/dashboard-render.test.mjs` 新增用例(execFileSync + --experimental-transform-types,照抄 refs 用例)断言:
|
|
58
|
+
- 首帧后 calls 含 `[true]`(D1);
|
|
59
|
+
- 第二帧(无变化)无新增 `[true]`;
|
|
60
|
+
- 第三帧(收缩)后再次出现 `[true]`(D2)。
|
|
61
|
+
|
|
62
|
+
### T3:全量回归(A4)
|
|
63
|
+
|
|
64
|
+
`npm test`(基线 617)+ `npm run typecheck`。
|
|
65
|
+
|
|
66
|
+
## 验收对账
|
|
67
|
+
- A1/A2 → T2 断言 · A3 → diff 审查(运行时改动仅 dashboard.ts)· A4 → T3
|
|
68
|
+
- U1(用户实测,合并后):密集增删 session 观察无残影。
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# issue #87 plan:legacy 死 host 安全回收实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:纯决策函数 `canFinalizeLegacyHost`(A1)
|
|
8
|
+
|
|
9
|
+
**文件**:`src/core/host-coordination.mjs`(新增导出,放 `classifyProbeResult` 附近)
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
/**
|
|
13
|
+
* Whether a legacy (pre-instanceId) host record can be safely finalized as
|
|
14
|
+
* exited: the runner pid is provably dead (a reused pid reads alive →
|
|
15
|
+
* conservative false) AND the endpoint is unreachable. Satisfies spec §10.1's
|
|
16
|
+
* "never recovered" conservatism — recovery only fires when identity is
|
|
17
|
+
* certain (issue #87).
|
|
18
|
+
* @param {{ host: HostStatus|null, hostPid: number|null|undefined, hostPidAlive: boolean, probeClassification: string }} input
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
|
|
22
|
+
if (!host || host.instanceId != null) return false;
|
|
23
|
+
if (host.state !== "starting" && host.state !== "alive") return false;
|
|
24
|
+
if (hostPid == null || hostPidAlive) return false;
|
|
25
|
+
return probeClassification === "missing" || probeClassification === "stale";
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**测试**:`test/host-coordination.test.mjs` 新增真值表用例(在文件既有风格下):
|
|
30
|
+
- 全条件满足(starting/alive × missing/stale 四组合)→ true
|
|
31
|
+
- instanceId 非 null → false
|
|
32
|
+
- state ∈ {exited, failed, stopping} → false
|
|
33
|
+
- hostPid null / hostPidAlive true → false
|
|
34
|
+
- classification ∈ {ready, starting, occupied, unknown} → false
|
|
35
|
+
- host null → false
|
|
36
|
+
|
|
37
|
+
### T2:resolver 两处改造(A2-A5)
|
|
38
|
+
|
|
39
|
+
**文件**:`src/runtime/service.mjs`
|
|
40
|
+
|
|
41
|
+
1. **import 更新**:`readHostPid` 加入 store.mjs import 列表;`canFinalizeLegacyHost` 加入 host-coordination.mjs import(L28 现有 `canReplaceHost` 同处)。
|
|
42
|
+
|
|
43
|
+
2. **新增模块内 helper**(resolver 函数前,模块作用域):
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
/**
|
|
47
|
+
* Finalize a provably-dead legacy host as exited so the resolver loop's next
|
|
48
|
+
* iteration sees hostActive=false and claims a fresh new-protocol host
|
|
49
|
+
* (issue #87). Legacy records have no instanceId — no concurrent owner exists,
|
|
50
|
+
* so an unfenced writeHost is safe; the claim path is serialized by the
|
|
51
|
+
* host-start lease.
|
|
52
|
+
* @returns {boolean} true when finalized (caller should `continue` the loop).
|
|
53
|
+
*/
|
|
54
|
+
function finalizeDeadLegacyHost(root, viewId, host, probeClassification) {
|
|
55
|
+
const hostPid = Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
|
|
56
|
+
if (!canFinalizeLegacyHost({ host, hostPid, hostPidAlive: isAlive(hostPid), probeClassification })) return false;
|
|
57
|
+
writeHost(root, { ...host, state: "exited", endedAt: Date.now(), lastSeenAt: Date.now(), error: "legacy host finalized: runner pid dead (issue #87)" });
|
|
58
|
+
try {
|
|
59
|
+
appendDiagnostic(root, viewId, {
|
|
60
|
+
source: "service", level: "info", code: "legacy_host_finalized",
|
|
61
|
+
message: `Finalized stale legacy host (pid ${hostPid} dead, probe ${probeClassification}) — next attach claims a fresh host`,
|
|
62
|
+
details: { hostPid, probeClassification, previousState: host.state },
|
|
63
|
+
});
|
|
64
|
+
} catch { /* best effort */ }
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
注意:`writeHost` 签名是 `(root, host)`(host 内含 viewId),与 updateOwnedHost 不同。appendDiagnostic 签名参照文件内现有调用(`appendDiagnostic(root, viewId, {...})`,如 recoverHost 附近用法——实现时以现有调用为准)。
|
|
70
|
+
|
|
71
|
+
3. **resolver legacy alive 分支**(现 L963 附近):
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
} else if (legacy) {
|
|
75
|
+
// Legacy host unreachable. spec §10.1 says never recover — unless the
|
|
76
|
+
// runner pid is provably dead, in which case finalize and self-heal
|
|
77
|
+
// (issue #87).
|
|
78
|
+
if (finalizeDeadLegacyHost(root, viewId, host, probe.classification)) {
|
|
79
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
return pending(sessionFile, "legacy host unreachable — manual restart needed");
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
4. **resolver starting-legacy 分支**(现 L953-957 附近,`withinGrace || legacy` 等待分支):
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
if (withinGrace || legacy) {
|
|
90
|
+
// A legacy starting host whose runner is provably dead would wait out
|
|
91
|
+
// the grace window forever (withinGrace is always true for legacy) —
|
|
92
|
+
// finalize it now instead (issue #87).
|
|
93
|
+
if (legacy && finalizeDeadLegacyHost(root, viewId, host, probe.classification)) {
|
|
94
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Normal cold start (or a legacy starting host — legacy is never recovered,
|
|
98
|
+
// spec §10.1): wait out the grace window.
|
|
99
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
(此分支 probe 已在上方 L939 执行,classification 可得。)
|
|
105
|
+
|
|
106
|
+
**测试**:`test/host-resolver.test.mjs` 新增 4 个测试(fixtures 复用 hostRecord/scriptProbe/instantSleep/resolverService;launchHost override 模仿现有 adoption 测试的成功 spawn 模式):
|
|
107
|
+
|
|
108
|
+
- **A2 自愈闭环**:legacy alive host(hostRecord:instanceId:null、state:"alive"、**无 runnerPid 属性**——用 delete 或构造时排除)+ host-pid.json 写死 pid(writeHostPid 或 readHostPid 对应写法,参照 store.mjs 导出;用不可能存活的 pid 如 999999——注意须确认该 pid 在测试机不存在,现有 fixture aliveHost 已用 999999 表达"死 pid",同法)+ scriptProbe(["missing"]) + launchHost override 成功 spawn → 断言:resolveAttachTarget 最终返回 kind:"pty"(新 host);readHost 状态变迁——最终 host 是新 instance(launchHost 写入 starting);diagnostics 含 legacy_host_finalized。
|
|
109
|
+
- 实现细节:claim/launch 后 readHost 读到的是新 host 记录,验证 finalize 发生要靠 diagnostics 断言 + resolve 结果非 pending。
|
|
110
|
+
- **A3 保守分支**:同上但 host-pid.json 写 `process.pid`(活 pid)→ 断言返回 pending(reason 含 "legacy host unreachable")且 host.json 未被改写(state 仍 alive)。
|
|
111
|
+
- **A4 starting-legacy 回收**:legacy starting host(claimAt 久远超 grace,pid 死)+ probe missing + launchHost spawn 成功 → 断言不自 deadline timeout、最终 kind:"pty"、diagnostics 含 legacy_host_finalized。
|
|
112
|
+
- **A5 unknown 不回收**:pid 死 + scriptProbe(["unknown"]) → 断言 pending 且不写盘。
|
|
113
|
+
|
|
114
|
+
⚠️ fixture 要点:hostRecord 默认写 `runnerPid: null`——这会让 `Object.hasOwn(host, "runnerPid")` 为 true,fallback 不到 host-pid.json。legacy fixture 必须构造**没有 runnerPid 属性**的记录(hostRecord 加 `legacy: true` 选项删除 instanceId/runnerPid 等字段,或新写 `legacyHostRecord` helper),并配 `writeHostPid(root, viewId, pid)` 写镜像。
|
|
115
|
+
|
|
116
|
+
### T3:全量回归(A6)
|
|
117
|
+
|
|
118
|
+
`npm test` + `npm run typecheck`(基线 602)。
|
|
119
|
+
|
|
120
|
+
## 验收对账
|
|
121
|
+
|
|
122
|
+
- A1 → T1 真值表单测
|
|
123
|
+
- A2/A3/A4/A5 → T2 四个集成测试
|
|
124
|
+
- A6 → T3
|
|
125
|
+
- U1(用户实测,合并前):本机 6 个真实卡死 legacy view attach 验证——主 session 合并前提示用户执行,或合并后实测反馈。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# issue #86 plan:spawn 异步 error 兜底实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:launch.mjs spawnDetached helper + 4 处收敛(A1, A2)
|
|
8
|
+
|
|
9
|
+
**改动**(`src/core/launch.mjs`):
|
|
10
|
+
1. 文件底部(或 import 后)新增模块内 helper:
|
|
11
|
+
```js
|
|
12
|
+
function spawnDetached(command, args, cwd) {
|
|
13
|
+
const child = spawn(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
detached: true,
|
|
16
|
+
stdio: "ignore",
|
|
17
|
+
env: process.env,
|
|
18
|
+
// Windows: detached children get their own console window unless
|
|
19
|
+
// suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
});
|
|
22
|
+
// Swallow async spawn failures (e.g. transient ENOENT on the node binary):
|
|
23
|
+
// without an 'error' listener the EventEmitter rethrows as uncaughtException
|
|
24
|
+
// and takes down the whole host Pi process (issue #86). Callers already
|
|
25
|
+
// record state "failed" via the pid == null branch.
|
|
26
|
+
child.on("error", () => {});
|
|
27
|
+
child.unref();
|
|
28
|
+
return child;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
2. `launchRun` / `launchHost` / `launchTitle` / `launchAutoState` 四处的 `spawn(...)` + `child.unref()` 收敛为 `const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);`,返回值逻辑(`child.pid ?? null`、writePid 等)不变。
|
|
32
|
+
|
|
33
|
+
**测试**(`test/launch.test.mjs`,复用现有注入模式):
|
|
34
|
+
- 新增 1 个测试:注入 `node: "/nonexistent/node-ENOENT-test"`,依次调用 4 个入口,断言:各自正常返回 `{ pid: null }`;`process.on("uncaughtException")` spy 不被触发;等待 ~300ms 让异步 error 有机会冒泡(进程崩 = 测试天然失败)。
|
|
35
|
+
- 现有 4 个测试不动(A2 回归)。
|
|
36
|
+
|
|
37
|
+
### T2:pty-attach.ts openExternalTarget error 兜底(A3)
|
|
38
|
+
|
|
39
|
+
**改动**(`src/ui/pty-attach.ts` L1150-1167):三分支统一为:
|
|
40
|
+
```ts
|
|
41
|
+
const child = spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" });
|
|
42
|
+
child.on("error", () => {});
|
|
43
|
+
child.unref();
|
|
44
|
+
```
|
|
45
|
+
(darwin `open`、win32 `cmd /c start` 同理)。参照 L847-848 xclip 既有模式。
|
|
46
|
+
|
|
47
|
+
### T3:全量回归(A4)
|
|
48
|
+
|
|
49
|
+
`npm test` + `npm run typecheck`,568+ 全绿。
|
|
50
|
+
|
|
51
|
+
## 验收对账
|
|
52
|
+
|
|
53
|
+
- A1 → T1 新测试
|
|
54
|
+
- A2 → T1 既有测试回归
|
|
55
|
+
- A3 → T2 代码审查 + `rg 'child.on\("error"' src/ui/pty-attach.ts` 断言三分支(A3 static)
|
|
56
|
+
- A4 → T3
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# issue #90 plan:defaultModel 失效防护实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:core 匹配函数 + service 注入(A1, A4)
|
|
8
|
+
|
|
9
|
+
1. `src/core/launch-options.mjs` 新增导出(spec D1.1 签名原样):
|
|
10
|
+
```js
|
|
11
|
+
export function modelRefAvailable(modelRef, availableModels) {
|
|
12
|
+
const ref = String(modelRef ?? "").trim().toLowerCase();
|
|
13
|
+
if (!ref) return true; // no constraint
|
|
14
|
+
if (!availableModels || availableModels.length === 0) return true; // can't judge → allow
|
|
15
|
+
return availableModels.some((m) => `${m.provider}/${m.id}`.toLowerCase() === ref);
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
2. 单测:若已有 `test/launch-options*.test.mjs` 则加入,否则新建。真值表:精确匹配(大小写混合)→ true;不匹配 → false;null/空串 → true;availableModels undefined/[] → true;部分匹配("glm-5.3" 不配 provider)→ false。
|
|
19
|
+
3. `src/ui/dashboard.ts` L1738 `findLaunchModelByRef` 改为内部调用 core 的匹配逻辑(保持返回 LaunchModel 对象:find 复用 `modelRefAvailable` 不可行——它返回 boolean;改为直接复用 core 里新增的共享比较,或最简单:dashboard 本地函数改为 `models.find(m => modelRefAvailable(ref, [m])) ?? null`。选最简单且单一事实源的写法)。
|
|
20
|
+
- 注:若这步让 diff 变脏(import 路径等),可以跳过,dashboard 本地函数保持——在 plan 偏差里说明即可。优先级低。
|
|
21
|
+
|
|
22
|
+
### T2:service 校验接线(A2, A3, A5)
|
|
23
|
+
|
|
24
|
+
1. `createService` opts 新增:`availableModels`(`() => Array<{provider:string,id:string}> | undefined`;默认 undefined → 跳过校验)。JSDoc typedef 更新(文件内 createService opts 注释处)。
|
|
25
|
+
2. service.mjs 模块内新增 helper:
|
|
26
|
+
```js
|
|
27
|
+
/** @returns {string|null} error message when the view's defaultModel is provably unavailable. */
|
|
28
|
+
function validateViewModelMeta(meta) {
|
|
29
|
+
const model = meta.defaultModel ?? null;
|
|
30
|
+
if (!model || !optsAvailableModels) return null;
|
|
31
|
+
const list = optsAvailableModels() ?? undefined;
|
|
32
|
+
if (modelRefAvailable(model, list)) return null;
|
|
33
|
+
return `Model "${model}" configured for this session is no longer available — update the view's model (or clear defaultModel) and retry attach.`;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
(注意 availableModels() 调用本身 try/catch → undefined。)
|
|
37
|
+
3. `startHostUnderLease`(L205):在 canReplaceHost 检查之后、instanceId 生成之前插入:
|
|
38
|
+
```js
|
|
39
|
+
const modelError = validateViewModelMeta(meta);
|
|
40
|
+
if (modelError) return { ok: false, error: modelError };
|
|
41
|
+
```
|
|
42
|
+
(校验失败不留 claim、不 spawn。)
|
|
43
|
+
4. `adoptClaimedHost`(L775 起):config 构建前插入校验;失败时:
|
|
44
|
+
```js
|
|
45
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: modelError, claimPid: null, claimIdentity: null }));
|
|
46
|
+
return { ok: true, pending: true, socketPath: null, instanceId };
|
|
47
|
+
```
|
|
48
|
+
(对齐该函数既有 spawnError 失败处理模式。)
|
|
49
|
+
5. `src/index.ts` serviceFor 与 `src/commands/agent-board.ts` flag 路径注入:
|
|
50
|
+
```ts
|
|
51
|
+
availableModels: () => { try { return ctx.modelRegistry.getAvailable(); } catch { return undefined; } },
|
|
52
|
+
```
|
|
53
|
+
(agent-board.ts flag 路径 L115 的 createService 调用同款注入;dashboard 路径 L95 已有 availableModels 变量但那是 UI deps,service 注入仍需单独传。)
|
|
54
|
+
6. 集成测试(`test/host-resolver.test.mjs` 或 `test/service.test.mjs`,看 ensure 测试在哪更顺):
|
|
55
|
+
- A2:view meta defaultModel="glm/glm-5.3",注入 availableModels: () => [{provider:"zai-coding-cn",id:"glm-5.3"}],launchHost spy → ensureHost("v1") 断言 ok:false、error 含模型名、spy 零调用、readHost 无 starting 残留;
|
|
56
|
+
- A3 对照:availableModels 含 glm/glm-5.3 → 正常 claim+spawn(断言 started/started pending 路径与现状一致);
|
|
57
|
+
- A4:不注入 availableModels → 跳过校验正常 launch;
|
|
58
|
+
- A5:废弃 starting claim(adopt 路径,参照现有 adoption 测试 fixture)+ 失效模型 → 断言落 failed、不 spawn。
|
|
59
|
+
|
|
60
|
+
### T3:runner exit 归因(A6, A7)
|
|
61
|
+
|
|
62
|
+
1. `src/core/heuristics.mjs` 新增导出:
|
|
63
|
+
```js
|
|
64
|
+
/**
|
|
65
|
+
* Last non-empty visible line of a raw terminal log chunk (ANSI/OSC stripped,
|
|
66
|
+
* per-line carriage-return resolved). @returns {string|null}
|
|
67
|
+
*/
|
|
68
|
+
export function lastVisibleLogLine(text, maxLen = 200)
|
|
69
|
+
```
|
|
70
|
+
实现:strip `/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g`(OSC)→ `/\x1b\[[0-9;?]*[ -/]*[@-~]/g`(CSI)→ 其他 `\x1b.` 单字符转义 → 按 `\n` 切分 → 每行取最后一个 `\r` 之后段 → trim → 过滤空 → 取最后一行 → truncate(maxLen)(复用同文件 truncate)。
|
|
71
|
+
2. 单测(heuristics 测试文件):ANSI 颜色、OSC 链接、`\r` 覆盖行、空行跳过、截断、空输入 → null。
|
|
72
|
+
3. `runner/pty-runner.mjs` child exit 回调(L213-223 区域):
|
|
73
|
+
```js
|
|
74
|
+
if (!crashed) {
|
|
75
|
+
let exitError = null;
|
|
76
|
+
if (exitCode !== 0) {
|
|
77
|
+
try {
|
|
78
|
+
const tail = readScreenLogTail(screenLog, 8192); // openSync/readSync seek 尾部
|
|
79
|
+
exitError = lastVisibleLogLine(tail);
|
|
80
|
+
} catch { /* best effort */ }
|
|
81
|
+
}
|
|
82
|
+
update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null, ...(exitError ? { error: exitError } : {}) });
|
|
83
|
+
...
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
(screenLog 变量在 L62 作用域内可见;确认该回调能访问到。readScreenLogTail 写成 runner 内私有小函数。)
|
|
87
|
+
4. 集成测试(test/pty-runner.integration.test.mjs):用 test-support/ 下 fake pi 模式(看 fake-slow-start-pi.mjs;若无"立即 exit 1 + 输出错误"的 fake 就新增一个 `fake-failing-pi.mjs`:打印 `Error: Model "glm/glm-5.3" not found.` 后 exit 1)→ spawn runner → 等 exit → 断言 readHost 的 error 含 "Model" / "not found";对照 exit 0(现有 fake)→ error 字段保持 null。注意 hasNodePty skip 守卫参照现有测试。
|
|
88
|
+
|
|
89
|
+
### T4:全量回归(A8)
|
|
90
|
+
|
|
91
|
+
`npm test` + `npm run typecheck`(基线 608)。
|
|
92
|
+
|
|
93
|
+
## 验收对账
|
|
94
|
+
|
|
95
|
+
- A1 → T1.2 · A2/A3/A4/A5 → T2.6 · A6 → T3.2 · A7 → T3.4 · A8 → T4
|
|
96
|
+
- U1(用户实测,合并后):view_539a5e9e20 恢复失效 defaultModel → attach 看 notify + diagnostics 不再累积;改回有效模型 attach 成功。
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# issue #89 spec:attach 界面 Ctrl+← detach 和弦
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · 状态:已授权全自动推进(用户 2026-09-08 决策)
|
|
4
|
+
|
|
5
|
+
## 背景与问题
|
|
6
|
+
|
|
7
|
+
attach 界面里编辑器非空时 `←` 被判为光标左移转发给 child(#66/#68/#69 门禁链的有意行为),用户不知道要先清空输入才能 `←` 退出,被困后只能 Ctrl+C/D 强退(连带 shutdown child Pi——实录一次事故导致 host 反复冷启动 7 次)。需要一个不与编辑冲突、始终可用的退出和弦。
|
|
8
|
+
|
|
9
|
+
## 核心设计(issue 已定稿,本 spec 为落地细化)
|
|
10
|
+
|
|
11
|
+
### D1:Ctrl+← 无条件 detach
|
|
12
|
+
`src/ui/pty-attach.ts` `handleInput`:在 `Key.left` 分支**之前**新增:
|
|
13
|
+
```ts
|
|
14
|
+
if (matchesKey(data, Key.ctrl("left"))) {
|
|
15
|
+
// Explicit detach chord (issue #89): single ← is gated on editor state
|
|
16
|
+
// (it doubles as cursor-left in a non-empty draft), so a user with a draft
|
|
17
|
+
// had no way out. Ctrl+← is unambiguous intent — detach unconditionally,
|
|
18
|
+
// regardless of editor state or socket liveness (same guarantee as the
|
|
19
|
+
// disconnected-← escape, issue #48).
|
|
20
|
+
this.detach();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
- 放 Key.left 分支前:确保组合键不被单键分支截获(matchesKey 语义上两者不相交,但顺序防御更稳);
|
|
25
|
+
- 无条件:和弦语义 = "我要退出",不需要门禁(门禁防的是误触,组合键无误触);
|
|
26
|
+
- pi-tui Key 支持现成:keys.js L845-846 legacy(`\x1b[1;5D`)+ kitty 序列均映射 ctrl+left。
|
|
27
|
+
|
|
28
|
+
### D2:header 提示更新
|
|
29
|
+
- L282:`← detach` → `←/Ctrl+← detach`
|
|
30
|
+
- L298(renderLoading 中心提示):`← to detach` → `←/Ctrl+← to detach`
|
|
31
|
+
|
|
32
|
+
### 否决项(issue 已论证,记录防重提)
|
|
33
|
+
- Esc:child 可能跑 vim/nvim,Esc 必须透传;
|
|
34
|
+
- 双击 ←:编辑时连按 ← 移动光标是高频操作,必误触。
|
|
35
|
+
|
|
36
|
+
## 非目标
|
|
37
|
+
- 不改 ← 单键门禁链任何行为(#66/#68/#69 的判定逻辑保持原样);
|
|
38
|
+
- 不改其他键位;不改 detach() 本身语义。
|
|
39
|
+
|
|
40
|
+
## 可测性拆分设计
|
|
41
|
+
|
|
42
|
+
| 单元 | 性质 | 测法 |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Ctrl+← 分支 | 组件 handleInput | 扩建现有 `test-support/detach-gate-smoke.ts` harness(fake tui + send spy + didDetach,#42/#48/#66 同款):注入 `\x1b[1;5D` 序列 |
|
|
45
|
+
| 文案 | render 输出 | harness 内 render(width) 断言含 "Ctrl+←" |
|
|
46
|
+
|
|
47
|
+
## 验收矩阵
|
|
48
|
+
|
|
49
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
50
|
+
|----|--------|----------|----------|----------|
|
|
51
|
+
| A1 | Ctrl+← 编辑器非空(draft)时 detach | 自动化(冒烟) | `node --test test/pty-attach-detach-gate.test.mjs` | didDetach() === true(editor_state draft 场景) |
|
|
52
|
+
| A2 | Ctrl+← 编辑器空时 detach | 自动化(冒烟) | 同上 | didDetach() === true |
|
|
53
|
+
| A3 | 单次 ← 门禁链回归 | 自动化(冒烟) | 同上 | 现有 15+ 断言全绿 |
|
|
54
|
+
| A4 | header 文案 | 自动化(冒烟) | 同上 render 输出断言 | 含 "Ctrl+←" |
|
|
55
|
+
| A5 | 全量回归 | 自动化(static/build) | `npm test` + `npm run typecheck` | 618+ 全绿 |
|
|
56
|
+
| U1 | 真实场景 | 用户实测 | 合并后重启 pi:attach 活跃 session,输入几个字 → Ctrl+← | 立即 detach 回 dashboard,child 不受影响 |
|
|
57
|
+
|
|
58
|
+
U1 需重启 pi,合并后用户执行。
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# issue #88 spec:dashboard 花屏修复(首帧全清 + 收缩自愈帧)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · 状态:已授权全自动推进(用户 2026-09-08 决策)
|
|
4
|
+
|
|
5
|
+
## 背景与问题
|
|
6
|
+
|
|
7
|
+
dashboard(pi-tui 全屏 overlay)花屏:行重复、folder 计数错位。根因:pi-tui 差分渲染在 overlay 激活时禁用 clearOnShrink(tui-main-screen.js L315 `!hasOverlayEntries`),且首帧不清屏("assumes clean screen")——脏底/收缩残留无任何自愈通道。拖窗口(widthChanged → fullRender(true))可恢复,证明全清是有效兜底。973d492(v0.3.0)起存在的存量问题。
|
|
8
|
+
|
|
9
|
+
## 核心设计(单文件:src/ui/dashboard.ts)
|
|
10
|
+
|
|
11
|
+
### D1:mount 首帧全清
|
|
12
|
+
组件新增 `needsFullClear = true`;首帧 render 时 `this.tui.requestRender(true)`(nextTick 异步,无递归)——下一帧全量重绘,给干净底。
|
|
13
|
+
|
|
14
|
+
### D2:内容收缩自愈帧
|
|
15
|
+
- `fitToHeight` 记录 pad 前内容行数到实例字段(pad 后行数恒满屏,检测无效——必须用 pad 前值);
|
|
16
|
+
- `render(width)` 改为包装方法:调原逻辑(改名 `renderLines`)拿 lines → 若 `needsFullClear` 或内容行数较上帧**减少** → `requestRender(true)` → 更新记录 → 返回 lines;
|
|
17
|
+
- 只响应**减少**(增长/同行数由差分正确处理),避免无意义全清。
|
|
18
|
+
|
|
19
|
+
### 为什么这样安全
|
|
20
|
+
- `requestRender(true)` 经 nextTick 异步执行(tui.js L612-628),render() 内调用不递归;
|
|
21
|
+
- 全清帧被 DECSET 2026 同步输出包裹,支持终端无闪烁;dashboard-render.mjs 注释警告的是"每帧 true",本设计仅首帧+收缩帧低频触发;
|
|
22
|
+
- 不动 pi-tui 上游、不动差分语义、不动 dashboard-render.mjs。
|
|
23
|
+
|
|
24
|
+
## 非目标
|
|
25
|
+
- pi-tui 上游修复(node_modules 不可控;且 overlay 禁 clearOnShrink 是有意设计);
|
|
26
|
+
- 每帧全清(闪烁,明确放弃);
|
|
27
|
+
- attach 视图(PtyAttachComponent)的渲染问题(不同组件,不在本 issue)。
|
|
28
|
+
|
|
29
|
+
## 可测性拆分设计
|
|
30
|
+
|
|
31
|
+
| 单元 | 性质 | 测法 |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| 首帧/收缩触发逻辑 | 组件 render 包装(.ts) | 子进程冒烟:`test-support/dashboard-shrink-render.ts` 用 --experimental-transform-types 加载组件,fake tui spy + fake deps,render 三帧(首帧/增行/减行)输出 requestRender 调用序列 JSON |
|
|
34
|
+
| 既有差分语义 | dashboard-render.mjs | 现有测试 "dashboard repaint preserves Pi TUI differential render state" 不动(requestDashboardRender 不改成 force) |
|
|
35
|
+
|
|
36
|
+
冒烟脚本 deps 构造照搬 `test-support/dashboard-refs-render.ts`(同组件既有范式)。
|
|
37
|
+
|
|
38
|
+
## 验收矩阵
|
|
39
|
+
|
|
40
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
41
|
+
|----|--------|----------|----------|----------|
|
|
42
|
+
| A1 | mount 首帧全清 | 自动化(integration/冒烟) | `node --test test/dashboard-render.test.mjs` | 首帧 render 后 requestRender 收到 [true] |
|
|
43
|
+
| A2 | 收缩帧自愈 | 自动化(integration/冒烟) | 同上 | 减行帧触发 [true];增行/不变帧不触发 |
|
|
44
|
+
| A3 | 修复在 .ts 层(jiti 可重载) | 自动化(static) | diff 审查 | 运行时代码改动仅在 dashboard.ts(.mjs 无运行时行为变更) |
|
|
45
|
+
| A4 | 全量回归 | 自动化(static/build) | `npm test` + `npm run typecheck` | 617+ 全绿 |
|
|
46
|
+
| U1 | 真实花屏场景 | 用户实测 | 合并后重启 pi:密集创建/删除若干 session(可配合 host 崩溃场景),观察 dashboard | 无行重复/计数错位残留;无需拖窗口恢复 |
|
|
47
|
+
|
|
48
|
+
U1 需重启 pi(git 包不热重载),合并后用户执行。
|
|
49
|
+
|
|
50
|
+
## 风险与降级
|
|
51
|
+
- 老终端无同步输出支持时全清帧可见一闪——低频可接受;
|
|
52
|
+
- 若 U1 发现仍有残留场景(如运行期外部写屏非首帧非收缩),后续可加"定时低频全清"兜底,本 spec 不做。
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# issue #87 spec:legacy 死 host 安全回收(resolver 自愈)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · 状态:待用户确认
|
|
4
|
+
|
|
5
|
+
## 背景与问题
|
|
6
|
+
|
|
7
|
+
v0.5.x 时代(无 instanceId 协议)的 legacy PTY host,当 runner 进程被异常杀死(SIGKILL / 承载终端关闭)时 host.json 永远停在 `state: "alive"`(或 `"starting"`)。v0.6.0 的 attach resolver 对 legacy host 执行「never recovered」(spec §10.1 保守决策),probe 失败直接 pending,同时 `hostActive`(纯磁盘状态)让 ensureHost 拒绝重新 claim——三层叠加成死锁,attach 永久失败。本机实录 6 个 view 处于该状态。
|
|
8
|
+
|
|
9
|
+
## 设计目标
|
|
10
|
+
|
|
11
|
+
resolver 对「pid 可验证已死 + endpoint 不可达」的 legacy host 自动 finalize 为 `exited` 并走正常 ensure/claim 自愈,消除死锁;不满足安全条件时保持现有 pending 行为(不推翻 spec 的保守原意)。
|
|
12
|
+
|
|
13
|
+
## 核心设计
|
|
14
|
+
|
|
15
|
+
### D1:纯决策函数 `canFinalizeLegacyHost`(host-coordination.mjs)
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ host: HostStatus|null, hostPid: number|null, hostPidAlive: boolean,
|
|
20
|
+
* probeClassification: string }} input
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
返回 true 当且仅当四条件同时成立:
|
|
26
|
+
1. `host.instanceId == null`(legacy)
|
|
27
|
+
2. `host.state ∈ {"starting", "alive"}`(stopping 由既有恢复路径 L907-933 覆盖,不重复处理)
|
|
28
|
+
3. `hostPid != null && hostPidAlive === false` —— pid 已死是核心安全证据;pid 复用只会让 isAlive=true → 走保守分支,方向安全
|
|
29
|
+
4. `probeClassification ∈ {"missing", "stale"}` —— missing=ENOENT(socket/pipe 不存在);stale=ECONNREFUSED+isSocket(进程死、socket 文件残留)。`unknown`/`occupied`/`starting` 一律不回收
|
|
30
|
+
|
|
31
|
+
纯函数、零副作用、零注入,真值表可全枚举测试。
|
|
32
|
+
|
|
33
|
+
### D2:resolver legacy 分支改造(service.mjs `resolveAttachTargetInner`)
|
|
34
|
+
|
|
35
|
+
- pid 解析复用 loadRow 同款 fallback:`Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId)`(legacy 的 pid 在 host-pid.json 镜像)
|
|
36
|
+
- 现 L963 `else if (legacy) return pending(...)` 改为:
|
|
37
|
+
- `canFinalizeLegacyHost(...)` 为 true → finalize(见 D3)→ `continue`(下一轮 row 重载 hostActive=false → 走正常 ensure/claim)
|
|
38
|
+
- 否则保持原 pending(行为不变)
|
|
39
|
+
- `state === "starting"` 的 legacy(L936 `withinGrace` 恒 true 的等死分支):在 grace 等待分支内同样先查 `canFinalizeLegacyHost`(该分支 probe 已执行,classification 可得),满足即 finalize + continue,不再等 grace 到期
|
|
40
|
+
|
|
41
|
+
### D3:finalize 动作(service.mjs 内联,写路径唯一)
|
|
42
|
+
|
|
43
|
+
`writeHost(root, viewId, { ...host, state: "exited", endedAt: now, lastSeenAt: now, error: "legacy host finalized: runner pid dead" })` + `appendDiagnostic({ source: "service", level: "info", code: "legacy_host_finalized", ... })`。
|
|
44
|
+
|
|
45
|
+
- legacy 无 instanceId,不存在并发 owner,无需 fencing(与新协议 updateOwnedHost 路径区分);竞态窗口由 host-start lease 串行化兜底(claim 走全新 instanceId,与 exited 记录不冲突)。
|
|
46
|
+
- 选 `exited` 而非 `failed`:进程是正常死亡语义(被外部杀死),failed 在现有代码里语义是"spawn/启动失败"(#86 场景),exited 与 v0.5.x runner 自然退出时写的状态一致,下游(canReplaceHost/ensure)对两者处理相同。
|
|
47
|
+
|
|
48
|
+
### D4:平台兼容性
|
|
49
|
+
|
|
50
|
+
不引入任何文件存在性检查(Windows 命名管道 existsSync 不可用,#45);证据只来自 probe classification(connect+hello 是唯一权威,spec §7.1)+ isAlive(kill(pid,0) 跨平台)。不碰 prewarm keypress 路径(2s TTL 纪律)。
|
|
51
|
+
|
|
52
|
+
## 非目标(明确排除)
|
|
53
|
+
|
|
54
|
+
- **升级迁移扫描**(issue 建议 2):resolver 自愈后功能冗余(下次 attach 自然恢复);若后续要 dashboard 行状态立刻正确可单开 issue。
|
|
55
|
+
- **UI 兜底提示**(issue 建议 3):永久 pending 状态被消除后无存在意义。
|
|
56
|
+
- **stopping 状态 legacy**:已有恢复路径覆盖(L907-933 对 instanceId != null 生效;legacy stopping 的 `staleStop` 判定要求 instanceId != null——属现存另一个小缺口,本次不扩范围,记为遗留观察项)。
|
|
57
|
+
|
|
58
|
+
## 可测性拆分设计
|
|
59
|
+
|
|
60
|
+
| 单元 | 位置 | 性质 | 测法 |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `canFinalizeLegacyHost` | host-coordination.mjs 新增导出 | 纯函数 | 真值表单测:4 条件 × 关键组合(全满足/缺 pid/pid 活/unknown/stopping/新协议 host) |
|
|
63
|
+
| pid fallback 解析 | service.mjs resolver 内联(复用 store.readHostPid) | 副作用隔离 | 集成测试造 legacy host.json(无 runnerPid 属性)+ host-pid.json |
|
|
64
|
+
| finalize + 自愈闭环 | service.mjs resolver | 集成 | 注入 scriptProbe(missing) + 死 pid → 断言 host.json 落 exited、diagnostic 写入、resolver 继续 claim 新 host |
|
|
65
|
+
| 保守分支 | 同上 | 集成 | pid 活(用 process.pid)→ 断言 pending 且不写盘 |
|
|
66
|
+
|
|
67
|
+
测试基建现成:`resolverService` + `scriptProbe` + `aliveHost` fixtures + `instantSleep`(test/host-resolver.test.mjs 模式)。`aliveHost` fixture 需支持造 legacy host(无 instanceId、无 runnerPid 属性 + host-pid.json 镜像),必要时加 `legacyHost` fixture helper。
|
|
68
|
+
|
|
69
|
+
## 验收矩阵
|
|
70
|
+
|
|
71
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
72
|
+
|----|--------|----------|----------|----------|
|
|
73
|
+
| A1 | canFinalizeLegacyHost 决策正确性 | 自动化(unit) | `node --test test/host-coordination.test.mjs` | 真值表全组合通过 |
|
|
74
|
+
| A2 | legacy alive + pid 死 + missing → 自愈闭环 | 自动化(integration) | `node --test test/host-resolver.test.mjs` | host.json 落 exited、diagnostic 有 legacy_host_finalized、resolver 成功 claim 新 instance |
|
|
75
|
+
| A3 | legacy alive + pid 活 → 不回收 | 自动化(integration) | 同上 | pending 返回、host.json 未被改写 |
|
|
76
|
+
| A4 | legacy starting + pid 死 → 不等 grace 即回收 | 自动化(integration) | 同上 | 不 sleep 到 deadline 即完成回收 + claim |
|
|
77
|
+
| A5 | probe unknown → 不回收 | 自动化(integration) | 同上 | pending、不写盘 |
|
|
78
|
+
| A6 | 全量回归 | 自动化(static/build) | `npm test` + `npm run typecheck` | 568+ 全绿、无新类型错误 |
|
|
79
|
+
| U1 | 本机 6 个真实卡死 legacy view 实测 | 用户实测 | 运行副本 checkout PR 分支 → 重启 pi → board 对卡死 view 按 enter | 全部自动拉起新 host 可正常 attach,无 manual restart 提示 |
|
|
80
|
+
|
|
81
|
+
U1 必须用户执行(重启 pi 会断开实现 session)。执行时机:PR 合并前。
|
|
82
|
+
|
|
83
|
+
## 风险与降级
|
|
84
|
+
|
|
85
|
+
- pid 复用误判方向恒为保守(不回收),不会误杀;
|
|
86
|
+
- finalize 后若 claim 失败(如 PTY 不可用),行为与现有 ensure 失败路径一致(pending + 原因),无新增失败模式;
|
|
87
|
+
- Windows 无 legacy named-pipe 实测环境(U2 类)——设计只依赖平台无关的 probe/isAlive,风险评估为低,最终报告标注未实测。
|