@zhuxixi/pi-agent-board 0.6.0 → 0.6.2
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 +20 -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/plans/2026-09-09-claimpid-blocks-replace.md +267 -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/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +147 -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 +32 -7
- 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 +109 -4
- package/src/ui/dashboard.ts +41 -1
- package/src/ui/pty-attach.ts +23 -5
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# claimPid-Blocks-Replace Fix 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:** Fix issue #99 — an exited/failed host whose `claimPid` (the dashboard process) is still alive must be replaceable, so attach no longer pends to `host start timed out`.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The fix relaxes one pure decision function (`canReplaceHost` in `src/core/host-coordination.mjs`): the claim role no longer participates in the replacement gate for terminal hosts, because claim protection (a launcher mid-transaction between claim and spawn) only matters while a host is `starting`. The observation helper (`observeHostForReplace` in `src/runtime/service.mjs`) drops its now-unused `claimObservation` field.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js (node:test, node:assert/strict), plain ESM modules, no new dependencies.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Change only the claim-role gate semantics; runner/child unknown observations must still block replacement (spec A2), `launchLeaseActive` must still block (spec A3), non-terminal hosts must still refuse (spec A4).
|
|
16
|
+
- `canReplaceHost` signature loses `claimObservation`; `observeHostForReplace` stops computing it (one fewer `process.kill(pid, 0)` syscall).
|
|
17
|
+
- All existing tests in `test/host-coordination.test.mjs` and `test/host-resolver.test.mjs` must keep passing (no behavioral regressions outside the claim-role gate).
|
|
18
|
+
- `npm run typecheck` must pass (service.mjs is .mjs but the repo runs tsc over TS sources; keep JSDoc types consistent).
|
|
19
|
+
- No changes to pty-runner.mjs, store.mjs, or host.json structure (spec non-goals).
|
|
20
|
+
|
|
21
|
+
## Acceptance traceability
|
|
22
|
+
|
|
23
|
+
| Spec ID | Plan coverage |
|
|
24
|
+
|---------|---------------|
|
|
25
|
+
| A1 (exited/failed + live claimPid → replaceable) | Task 1 Step 1 test `canReplaceHost allows replacing a terminal host whose claimPid is still alive` |
|
|
26
|
+
| A2 (runner/child unknown still blocks) | Task 1 Step 1 test `canReplaceHost still refuses unknown runner/child observations` + legacy case 1 of `canReplaceHost refuses unknown observations` |
|
|
27
|
+
| A3 (launchLeaseActive still blocks) | Task 1 Step 1 legacy case 3 of `canReplaceHost refuses unknown observations` |
|
|
28
|
+
| A4 (non-terminal hosts refuse; host null) | Task 1 Step 1 test `canReplaceHost refuses non-terminal hosts and null host` |
|
|
29
|
+
| A5 (foreign/not_started releasable) | Task 1 Step 1 test `canReplaceHost SAFE_TO_RELEASE boundary regression` |
|
|
30
|
+
| A6 (resolver integration: exited + live claimPid → new host) | Task 2 |
|
|
31
|
+
| U1 (real dashboard attach) | Post-implementation manual task (Task 3) |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
### Task 1: Relax `canReplaceHost` claim-role gate + drop `claimObservation` (A1-A5)
|
|
36
|
+
|
|
37
|
+
**Files:**
|
|
38
|
+
- Modify: `src/core/host-coordination.mjs:60-80` (canReplaceHost + its JSDoc)
|
|
39
|
+
- Modify: `src/runtime/service.mjs:2006-2013` (observeHostForReplace)
|
|
40
|
+
- Test: `test/host-coordination.test.mjs:30-35`
|
|
41
|
+
|
|
42
|
+
**Interfaces:**
|
|
43
|
+
- Consumes: `SAFE_TO_RELEASE` set (already defined at `host-coordination.mjs:57`).
|
|
44
|
+
- Produces: `canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) → boolean` — the `claimObservation` parameter is REMOVED. Sole caller `observeHostForReplace` (service.mjs, internal, not exported) stops passing it. Task 2's integration test relies on this behavior only indirectly (through `resolveAttachTarget`), so no signature dependency.
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1: Write the failing tests**
|
|
47
|
+
|
|
48
|
+
In `test/host-coordination.test.mjs`, first EDIT the existing `canReplaceHost refuses unknown observations` test (line 30-35) to drop the `claimObservation` argument from all three assertions (the parameter is being removed):
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
test("canReplaceHost refuses unknown observations", () => {
|
|
52
|
+
const host = { state: "failed" };
|
|
53
|
+
assert.equal(canReplaceHost({ host, runnerObservation: "unknown", childObservation: "dead", launchLeaseActive: false }), false);
|
|
54
|
+
assert.equal(canReplaceHost({ host, runnerObservation: "dead", childObservation: "not_started", launchLeaseActive: false }), true);
|
|
55
|
+
assert.equal(canReplaceHost({ host, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: true }), false);
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then ADD these four tests right after it:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
test("canReplaceHost allows replacing a terminal host whose claimPid is still alive (issue #99)", () => {
|
|
63
|
+
// The bug: an exited/failed host keeps its claimPid (the dashboard process
|
|
64
|
+
// that wrote the claim), and a live pid observed as "unknown" used to block
|
|
65
|
+
// replacement forever — attach pended to "host start timed out". Claim
|
|
66
|
+
// protection only matters while a claim is mid-transaction (state
|
|
67
|
+
// "starting"); a terminal host cannot still be being launched.
|
|
68
|
+
assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), true);
|
|
69
|
+
assert.equal(canReplaceHost({ host: { state: "failed" }, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("canReplaceHost still refuses unknown runner/child observations (issue #99 conservatism)", () => {
|
|
73
|
+
assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "unknown", childObservation: "dead", launchLeaseActive: false }), false);
|
|
74
|
+
assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "dead", childObservation: "unknown", launchLeaseActive: false }), false);
|
|
75
|
+
assert.equal(canReplaceHost({ host: { state: "failed" }, runnerObservation: "unknown", childObservation: "unknown", launchLeaseActive: false }), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("canReplaceHost refuses non-terminal hosts and null host (issue #99)", () => {
|
|
79
|
+
for (const state of ["starting", "alive", "stopping"]) {
|
|
80
|
+
assert.equal(canReplaceHost({ host: { state }, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), false, `state ${state} must refuse`);
|
|
81
|
+
}
|
|
82
|
+
assert.equal(canReplaceHost({ host: null, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("canReplaceHost SAFE_TO_RELEASE boundary regression (issue #99)", () => {
|
|
86
|
+
assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "foreign", childObservation: "dead", launchLeaseActive: false }), true, "foreign runner (pid reuse) is releasable");
|
|
87
|
+
assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "not_started", childObservation: "not_started", launchLeaseActive: false }), true);
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
92
|
+
|
|
93
|
+
Run: `node --test test/host-coordination.test.mjs 2>&1 | tail -30`
|
|
94
|
+
Expected: FAIL — the first new test fails (`exited` + dead runner/child returns `false` under the old three-role gate). The edited legacy test fails too: the old implementation ignores the removed `claimObservation` key but still requires `claimObservation` in SAFE_TO_RELEASE via `undefined → not in set → false`… actually with `claimObservation` absent, `SAFE_TO_RELEASE.has(undefined)` is `false`, so case 2 of the legacy test (`"dead"` args → expected `true`) FAILS under the old code. Both failures prove the tests exercise the gate.
|
|
95
|
+
|
|
96
|
+
- [ ] **Step 3: Implement the relaxation**
|
|
97
|
+
|
|
98
|
+
In `src/core/host-coordination.mjs`, replace the `canReplaceHost` function (lines ~63-80) with:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
/**
|
|
102
|
+
* Whether an exited/failed host can be replaced by a new claim. The runner and
|
|
103
|
+
* child roles must be provably gone; any `unknown` observation or an active
|
|
104
|
+
* launch lease blocks replacement. The claim role does NOT participate: claim
|
|
105
|
+
* protection (a launcher mid-transaction between claim and spawn) only matters
|
|
106
|
+
* while the host is `starting`, and this gate only ever sees terminal hosts —
|
|
107
|
+
* a terminal host cannot still be being launched (issue #99: a live claimPid —
|
|
108
|
+
* the dashboard process that wrote the claim — must not block re-attach).
|
|
109
|
+
* @param {{
|
|
110
|
+
* host: HostStatus|null|undefined,
|
|
111
|
+
* runnerObservation: string,
|
|
112
|
+
* childObservation: string,
|
|
113
|
+
* launchLeaseActive: boolean,
|
|
114
|
+
* }} input
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
|
|
118
|
+
if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
|
|
119
|
+
if (launchLeaseActive) return false;
|
|
120
|
+
return (
|
|
121
|
+
SAFE_TO_RELEASE.has(runnerObservation) &&
|
|
122
|
+
SAFE_TO_RELEASE.has(childObservation)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
In `src/runtime/service.mjs`, edit `observeHostForReplace` (line ~2006) to drop the `claimObservation` line:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
/** @param {import("../core/types.mjs").HostStatus|null} host */
|
|
131
|
+
function observeHostForReplace(host) {
|
|
132
|
+
return {
|
|
133
|
+
host,
|
|
134
|
+
runnerObservation: conservativeObservation(host?.runnerPid ?? null),
|
|
135
|
+
childObservation: conservativeObservation(host?.childPid ?? null),
|
|
136
|
+
launchLeaseActive: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
142
|
+
|
|
143
|
+
Run: `node --test test/host-coordination.test.mjs 2>&1 | tail -10`
|
|
144
|
+
Expected: PASS — all tests in the file pass (4 new + edited legacy + all untouched).
|
|
145
|
+
|
|
146
|
+
Run: `node --test test/host-resolver.test.mjs test/host-recovery.test.mjs test/host-crash.test.mjs 2>&1 | tail -10`
|
|
147
|
+
Expected: PASS — no regressions in adjacent host suites.
|
|
148
|
+
|
|
149
|
+
Run: `npm run typecheck`
|
|
150
|
+
Expected: exit 0.
|
|
151
|
+
|
|
152
|
+
- [ ] **Step 5: Commit**
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
git add src/core/host-coordination.mjs src/runtime/service.mjs test/host-coordination.test.mjs
|
|
156
|
+
git commit -m "fix(host): claim role no longer blocks terminal host replacement (#99)"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### Task 2: Resolver integration test — exited host + live claimPid attaches via fresh spawn (A6)
|
|
162
|
+
|
|
163
|
+
**Files:**
|
|
164
|
+
- Test: `test/host-resolver.test.mjs` (add one test after the `resolver finalizes a provably-dead legacy alive host` test, ~line 163)
|
|
165
|
+
|
|
166
|
+
**Interfaces:**
|
|
167
|
+
- Consumes: existing helpers `freshRoot`, `resolverService`, `healServiceOverrides(probe, spawns)`, `scriptProbe(seq)`, `hostRecord(root, viewId, over)` (defaults `claimPid: process.pid` — exactly the live-claimer shape), `createView` (returns `{ sessionFile, ... }`), and `writeFileSync` (already imported). Task 1's relaxed `canReplaceHost` must be in place — this test verifies the full attach chain (resolver → ensureHost → startHostUnderLease → canReplaceHost → spawn → probe ready).
|
|
168
|
+
- Produces: nothing downstream (terminal verification task).
|
|
169
|
+
|
|
170
|
+
- [ ] **Step 1: Write the integration test**
|
|
171
|
+
|
|
172
|
+
Add to `test/host-resolver.test.mjs` after the issue #87 legacy-alive test (~line 163):
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
test("resolver replaces an exited host whose claimPid is still alive (issue #99)", async () => {
|
|
176
|
+
const root = freshRoot();
|
|
177
|
+
try {
|
|
178
|
+
const meta = createView(root, { id: "v1", name: "a", cwd: "/r" });
|
|
179
|
+
writeFileSync(meta.sessionFile, "");
|
|
180
|
+
// The bug's exact shape: the host ran to completion (exited, exitCode 0,
|
|
181
|
+
// stopReason child_exit) but its claimPid — the dashboard process that
|
|
182
|
+
// wrote the claim — is STILL ALIVE (hostRecord defaults claimPid to
|
|
183
|
+
// process.pid). Before the fix, canReplaceHost saw the live claim as
|
|
184
|
+
// "unknown" and the resolver pended to "host start timed out".
|
|
185
|
+
hostRecord(root, "v1", {
|
|
186
|
+
instanceId: "i1",
|
|
187
|
+
state: "exited",
|
|
188
|
+
runnerPid: 999999,
|
|
189
|
+
childPid: null,
|
|
190
|
+
endedAt: Date.now(),
|
|
191
|
+
exitCode: 0,
|
|
192
|
+
stopReason: "child_exit",
|
|
193
|
+
});
|
|
194
|
+
const probe = scriptProbe(["ready"]);
|
|
195
|
+
const spawns = [];
|
|
196
|
+
const svc = resolverService(root, healServiceOverrides(probe, spawns));
|
|
197
|
+
const result = await svc.resolveAttachTarget("v1", { timeoutMs: 2_000 });
|
|
198
|
+
assert.equal(result.kind, "pty", `must replace the exited host despite the live claimPid: ${JSON.stringify(result)}`);
|
|
199
|
+
assert.equal(spawns.length, 1, "exactly one fresh claim spawn");
|
|
200
|
+
assert.notEqual(result.instanceId, "i1", "attaches to the replacement instance");
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(root, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- [ ] **Step 2: Sanity-verify the test fails against the pre-fix gate (optional but recommended)**
|
|
208
|
+
|
|
209
|
+
Temporarily `git stash` the Task 1 commit (`git stash` won't work across commits — instead: `git checkout HEAD~1 -- src/core/host-coordination.mjs src/runtime/service.mjs`), then run:
|
|
210
|
+
|
|
211
|
+
Run: `node --test --test-name-pattern "issue #99" test/host-resolver.test.mjs 2>&1 | tail -15`
|
|
212
|
+
Expected: the new test FAILS or times out (resolver pends — the pre-fix behavior). Then restore: `git checkout HEAD -- src/core/host-coordination.mjs src/runtime/service.mjs`.
|
|
213
|
+
|
|
214
|
+
If the timeout makes the run slow, the 2_000 ms timeoutMs bounds it.
|
|
215
|
+
|
|
216
|
+
- [ ] **Step 3: Run the test against the fix**
|
|
217
|
+
|
|
218
|
+
Run: `node --test --test-name-pattern "issue #99" test/host-resolver.test.mjs 2>&1 | tail -10`
|
|
219
|
+
Expected: PASS — `kind: "pty"`, exactly one spawn, replacement instanceId.
|
|
220
|
+
|
|
221
|
+
- [ ] **Step 4: Run the full suite**
|
|
222
|
+
|
|
223
|
+
Run: `npm test 2>&1 | tail -15`
|
|
224
|
+
Expected: PASS — all suites green, no regressions.
|
|
225
|
+
|
|
226
|
+
Run: `npm run typecheck`
|
|
227
|
+
Expected: exit 0.
|
|
228
|
+
|
|
229
|
+
- [ ] **Step 5: Commit**
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
git add test/host-resolver.test.mjs
|
|
233
|
+
git commit -m "test(resolver): exited host with live claimPid attaches via fresh spawn (#99)"
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### Task 3: U1 manual verification (post-implementation, user-executed)
|
|
239
|
+
|
|
240
|
+
**Files:** none (manual).
|
|
241
|
+
|
|
242
|
+
**Interfaces:** none.
|
|
243
|
+
|
|
244
|
+
- [ ] **Step 1: Restart dashboard process** (loads new code — the "immediately effective on existing bad records" property requires restart).
|
|
245
|
+
|
|
246
|
+
- [ ] **Step 2: Attach view_2472d82627 from the dashboard** — observe: attach enters the session, history renders, no `host start timed out`.
|
|
247
|
+
|
|
248
|
+
- [ ] **Step 3: Exit the session, attach again** — confirm repeatability.
|
|
249
|
+
|
|
250
|
+
- [ ] **Step 4: Same check on view_4b667ad75d / view_c038badb30** (the other two exited + live-claimPid views).
|
|
251
|
+
|
|
252
|
+
- [ ] **Step 5: Record results in the issue** (comment each view's outcome; mark U1 pass/pending in the final report).
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Self-Review
|
|
257
|
+
|
|
258
|
+
**1. Spec coverage:**
|
|
259
|
+
- A1-A5 → Task 1 Step 1 (three new tests + edited legacy test covers A2/A3 cases) ✓
|
|
260
|
+
- A6 → Task 2 ✓
|
|
261
|
+
- U1 → Task 3 ✓
|
|
262
|
+
- 改动文件清单 (spec) → Task 1 + Task 2 files match exactly (host-coordination.mjs, service.mjs, host-coordination.test.mjs, host-resolver.test.mjs) ✓
|
|
263
|
+
- 非目标: no pty-runner/store/host.json changes in any task ✓
|
|
264
|
+
|
|
265
|
+
**2. Placeholder scan:** no TBD/TODO; every code step has full code; verification commands concrete. ✓
|
|
266
|
+
|
|
267
|
+
**3. Type consistency:** `canReplaceHost` new signature `{host, runnerObservation, childObservation, launchLeaseActive}` used consistently in Task 1 tests, Task 1 implementation, and matches Task 2's indirect usage (no direct call). `healServiceOverrides(probe, spawns)` helper name matches file. ✓
|
|
@@ -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,风险评估为低,最终报告标注未实测。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# issue #86 spec:spawn 异步 error 兜底(spawnDetached 统一封装)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · 状态:已授权全自动推进(用户 2026-09-08 决策)
|
|
4
|
+
|
|
5
|
+
## 背景与问题
|
|
6
|
+
|
|
7
|
+
`launch.mjs` 4 个启动函数与 `pty-attach.ts openExternalTarget()` 的 spawn 均为 `spawn(...)` + `unref()` 无 `'error'` listener。spawn 启动失败走异步 `'error'` 事件,EventEmitter 无 listener 的 error 直接 throw → uncaughtException → 整个 pi 宿主进程退出。实录:WSL2 上一次 node 二进制瞬时 ENOENT 直接带崩 pi(host.json 已正确落 failed,但进程没活下来走重试)。
|
|
8
|
+
|
|
9
|
+
## 核心设计
|
|
10
|
+
|
|
11
|
+
### D1:launch.mjs 新增内部 helper `spawnDetached`
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
function spawnDetached(command, args, cwd) {
|
|
15
|
+
const child = spawn(command, args, { cwd, detached: true, stdio: "ignore", env: process.env, windowsHide: true });
|
|
16
|
+
child.on("error", () => {}); // 接住异步启动失败;调用方按 pid==null 记 failed,宿主不崩
|
|
17
|
+
child.unref();
|
|
18
|
+
return child;
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`launchRun` / `launchHost` / `launchTitle` / `launchAutoState` 四处收敛到该 helper。helper 不导出(模块内部实现细节,测试通过公开入口行为验证)。
|
|
23
|
+
|
|
24
|
+
### D2:openExternalTarget 三分支补 error listener
|
|
25
|
+
|
|
26
|
+
darwin/win32/其他三分支统一为 `const child = spawn(...); child.on("error", () => {}); child.unref();`。fire-and-forget 语义与返回值不变(打开链接是 best-effort,失败静默)。参照同文件 L847-848 xclip 既有正确模式。
|
|
27
|
+
|
|
28
|
+
## 非目标
|
|
29
|
+
|
|
30
|
+
- runner/*.mjs 内的 spawn(跑在分离 runner 进程里,崩了不拖垮 pi,issue 已明确排除);
|
|
31
|
+
- 顶层 `process.on("uncaughtException")` 兜底(会掩盖未知错误,不引入);
|
|
32
|
+
- 改变任何返回值/状态机语义。
|
|
33
|
+
|
|
34
|
+
## 可测性拆分设计
|
|
35
|
+
|
|
36
|
+
| 单元 | 性质 | 测法 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| spawnDetached 异步 error 兜底 | 副作用隔离(仅 child_process) | integration:注入不存在 node 路径,断言返回 pid:null 且进程存活(uncaughtException listener 不触发;node:test 进程崩=测试天然失败) |
|
|
39
|
+
| 4 个公开入口行为不变 | 公开 API | 复用现有 4 测试 + 新增 ENOENT 用例覆盖 launchRun/launchHost/launchTitle/launchAutoState |
|
|
40
|
+
| openExternalTarget | UI 层(c8 阈值外,惯例冒烟保护) | 静态验证 error listener 存在;不单加测试 |
|
|
41
|
+
|
|
42
|
+
## 验收矩阵
|
|
43
|
+
|
|
44
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
45
|
+
|----|--------|----------|----------|----------|
|
|
46
|
+
| A1 | spawn 失败不带崩进程(4 入口) | 自动化(integration) | `node --test test/launch.test.mjs` | 注入 `/nonexistent/node-ENOENT-test` 后各入口正常返回 pid:null,进程存活 |
|
|
47
|
+
| A2 | 既有行为回归 | 自动化(integration) | `node --test test/launch.test.mjs` | 原 4 测试全绿 |
|
|
48
|
+
| A3 | openExternalTarget error 兜底 | 自动化(static) | 代码审查 + grep 断言 | 三分支均有 error listener |
|
|
49
|
+
| A4 | 全量回归 | 自动化(static/build) | `npm test` + `npm run typecheck` | 568+ 全绿、无类型错误 |
|
|
50
|
+
| U1 | issue 复现脚本对照 | 自动化替代(A1 等价) | issue 自带复现脚本逻辑已并入 A1 | 见 A1 |
|
|
51
|
+
|
|
52
|
+
U1 说明:issue 的复现脚本本质是"注入坏 node 路径 + uncaughtException 监听",A1 测试完全等价覆盖,故不需要独立用户实测。无 U 类纯人工项。
|
|
53
|
+
|
|
54
|
+
## 风险
|
|
55
|
+
|
|
56
|
+
极低:纯增量兜底,不改成功路径任何行为;失败路径从"进程崩"变为"走既有 pid==null 记 failed 重试路径"。
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# issue #90 spec:defaultModel 失效防护(launch 前校验 + child exit 归因)
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · 状态:已授权全自动推进(用户 2026-09-08 决策)
|
|
4
|
+
|
|
5
|
+
## 背景与问题
|
|
6
|
+
|
|
7
|
+
session 创建时记录的 `meta.defaultModel` 失效后(provider 前缀改名/移除),attach 走「launchHost → child --model 失效 → exit 1 → host exited → 再 attach 再 launch」死循环(实录单 view 数百次 launch_host)。且 runner child exit 不写 error 字段(pty-runner.mjs L217),host.json 只有 exitCode:1 无任何归因线索。
|
|
8
|
+
|
|
9
|
+
## 核心设计
|
|
10
|
+
|
|
11
|
+
### D1:launch 前模型校验(断死循环)
|
|
12
|
+
|
|
13
|
+
1. **core 导出匹配函数**(`src/core/launch-options.mjs`):
|
|
14
|
+
```js
|
|
15
|
+
/**
|
|
16
|
+
* Whether a stored model reference resolves to a currently-available model.
|
|
17
|
+
* Same rule as the dashboard launch picker: case-insensitive exact "provider/id".
|
|
18
|
+
* @param {string|null|undefined} modelRef
|
|
19
|
+
* @param {Array<{provider: string, id: string}>|undefined|null} availableModels
|
|
20
|
+
* @returns {boolean} true when modelRef is empty/null (no constraint) or matched.
|
|
21
|
+
*/
|
|
22
|
+
export function modelRefAvailable(modelRef, availableModels)
|
|
23
|
+
```
|
|
24
|
+
`modelRef` 空 → true(无校验对象);`availableModels` 空/undefined → true(调用方无法判断时保守放行,不制造新阻塞)。
|
|
25
|
+
|
|
26
|
+
2. **service 注入**:`createService` 新增 `opts.availableModels`(`() => Array<{provider,id}>`,每次调用实时取值;默认 undefined → 跳过校验)。`src/index.ts` serviceFor 与 `src/commands/agent-board.ts` flag 路径注入 `() => ctx.modelRegistry.getAvailable()`(try/catch → undefined,对齐 agent-board.ts L75-79 现有防御模式)。
|
|
27
|
+
|
|
28
|
+
3. **统一校验 helper**(service.mjs 模块内):`validateViewModelMeta(meta) → null | string`(null=通过;string=错误消息)。错误消息明确可行动:`Model "X" configured for this session is no longer available — update the view's model or clear defaultModel, then retry attach.`
|
|
29
|
+
|
|
30
|
+
4. **两个校验点**(service.mjs 仅有的 host spawn 路径):
|
|
31
|
+
- `startHostUnderLease`:在 claim 之前校验(首选;若代码结构上 claim 已发生,则落 failed 清理后返回)→ `{ ok: false, error }`;
|
|
32
|
+
- `adoptClaimedHost`:claim 是既有废弃记录 → 校验失败时 `updateOwnedHost` 落 failed(error 带模型消息)+ 返回 pending。
|
|
33
|
+
- resolver 链路无需改动:ensureHostImpl 的 `{ ok:false, error }` 走现有 `pending(sessionFile, res.error)` → attach-flow notify 展示(渠道现成)。
|
|
34
|
+
|
|
35
|
+
5. **dashboard.ts L1738 `findLaunchModelByRef` 改为复用 core 导出**(单一事实源;LaunchModel 结构类型 JSDoc 化)。
|
|
36
|
+
|
|
37
|
+
### D2:child exit 错误归因(补线索)
|
|
38
|
+
|
|
39
|
+
1. **纯函数**(`src/core/heuristics.mjs` 新增导出):`lastVisibleLogLine(text, maxLen = 200)`——strip ANSI(CSI `\x1b\[[0-9;?]*[ -/]*[@-~]` + OSC `\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)` + 杂项转义),按行切分,返回最后一个非空可见行(`\r` 处理:取每行最后一个 `\r` 段),截断 maxLen。空输入/全空 → null。
|
|
40
|
+
2. **runner 接线**(`runner/pty-runner.mjs` child exit 回调 L213-223):`exitCode != null && exitCode !== 0` 时 best-effort(try/catch 包裹,绝不影响 exit 路径)读 screen.log 尾部 ~8KB(openSync/readSync seek 尾部,不整文件读)→ `lastVisibleLogLine` → 非 null 则 `update({ ..., error })`。exitCode=0 不改 error。
|
|
41
|
+
|
|
42
|
+
## 非目标
|
|
43
|
+
|
|
44
|
+
- **失败退避**(issue 建议 2):D1 已断 spawn 死循环;通用退避需跨 claim 持久计数,复杂度/收益不划算,未来场景再单开;
|
|
45
|
+
- **run 路径模型校验**(launchRun 的 model):detach run 失败是一次性的,不循环——观察项;
|
|
46
|
+
- **UI 改动**:notify 渠道现成;
|
|
47
|
+
- 自动 fallback 到其他模型:静默换模型会让用户不知 session 行为已变,fail-fast + 明确提示更安全(issue 建议 1 的两个选项中选 fail-fast)。
|
|
48
|
+
|
|
49
|
+
## 可测性拆分设计
|
|
50
|
+
|
|
51
|
+
| 单元 | 位置 | 性质 | 测法 |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `modelRefAvailable` | launch-options.mjs | 纯函数 | 真值表:大小写/null/空列表/部分匹配不匹配 |
|
|
54
|
+
| `validateViewModelMeta` + ensure 路径 | service.mjs | 副作用隔离(注入 availableModels + launchHost spy) | integration:失效模型 → ok:false + launchHost 零调用 + 无 starting claim 残留;有效/null/未注入三对照 |
|
|
55
|
+
| adopt 路径校验 | service.mjs | 同上 | integration:废弃 claim + 失效模型 → 落 failed + 不 spawn |
|
|
56
|
+
| `lastVisibleLogLine` | heuristics.mjs | 纯函数 | 单测:ANSI/OSC/`\r`/空行/截断/空输入 |
|
|
57
|
+
| runner exit 归因 | pty-runner.mjs | 集成 | pty-runner.integration.test.mjs 模式:fake child exit 1 + 预置 screen.log → host.json.error 含错误行;exit 0 → error 不变 |
|
|
58
|
+
|
|
59
|
+
## 验收矩阵
|
|
60
|
+
|
|
61
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
62
|
+
|----|--------|----------|----------|----------|
|
|
63
|
+
| A1 | modelRefAvailable 匹配规则 | 自动化(unit) | `node --test test/launch-options.test.mjs`(或既有对应文件) | 真值表全过 |
|
|
64
|
+
| A2 | 失效模型 attach 不再 spawn | 自动化(integration) | `node --test test/host-resolver.test.mjs` / service 测试 | ok:false、launchHost spy 零调用、无 starting claim 残留、error 消息含模型名 |
|
|
65
|
+
| A3 | 有效模型/null 模型回归 | 自动化(integration) | 同上 | 正常 launch(claim → spawn) |
|
|
66
|
+
| A4 | 未注入 availableModels 向后兼容 | 自动化(integration) | 同上 | 跳过校验正常 launch |
|
|
67
|
+
| A5 | adopt 路径失效模型 | 自动化(integration) | 同上 | 落 failed + 不 spawn |
|
|
68
|
+
| A6 | lastVisibleLogLine 提取 | 自动化(unit) | heuristics 测试 | ANSI/截断/空行用例全过 |
|
|
69
|
+
| A7 | runner exit≠0 归因 | 自动化(integration) | runner 测试 | host.json.error 含 screen.log 尾部错误行;exit 0 不写 |
|
|
70
|
+
| A8 | 全量回归 | 自动化(static/build) | `npm test` + `npm run typecheck` | 608+ 全绿 |
|
|
71
|
+
| U1 | 真实失效 view 实测 | 用户实测 | 合并后:view_539a5e9e20 恢复失效 defaultModel=glm/glm-5.3 → attach → 观察 notify 与 diagnostics | notify 明确提示模型失效;diagnostics 不再累积 launch_host;改回有效模型后 attach 成功 |
|
|
72
|
+
|
|
73
|
+
U1 需重启 pi(git 包不热重载),合并后由用户执行并回 issue 记录。
|
|
74
|
+
|
|
75
|
+
## 风险与降级
|
|
76
|
+
|
|
77
|
+
- availableModels 为空数组时保守放行(不制造新阻塞)——校验仅在能确定"失效"时拦截;
|
|
78
|
+
- 校验失败路径每次 attach 仅一次文件级操作,无进程开销;
|
|
79
|
+
- D2 全 try/catch best-effort,exit 路径行为不变。
|