@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,147 @@
|
|
|
1
|
+
# Spec: exited host 的 claimPid 存活导致 attach 永久 pending(issue #99)
|
|
2
|
+
|
|
3
|
+
## 背景
|
|
4
|
+
|
|
5
|
+
dashboard 里对已退出的 session 点 attach,永远连不上:attach 一直转圈,最终超时提示 `host start timed out`。实测对象 `view_2472d82627`(host 已正常退出 `state: "exited"`,`exitCode: 0`),同机另有 `view_4b667ad75d`、`view_c038badb30` 两个 view 处于相同状态(exited + claimPid 存活),全部无法 attach。
|
|
6
|
+
|
|
7
|
+
## 根因
|
|
8
|
+
|
|
9
|
+
### 触发链条
|
|
10
|
+
|
|
11
|
+
1. 用户从 dashboard(进程 P)attach session → `claimHost`(`src/core/store.mjs:172`)把 `claimPid` 记为 **dashboard 进程的 pid**(`claimPid: provisionalHost.claimPid ?? null`,即 service 进程 pid)
|
|
12
|
+
2. pi 子进程正常退出(`pty-runner.mjs:262` `child.onExit` → `state: "exited"`)→ **退出路径不清除 claimPid**
|
|
13
|
+
3. dashboard 进程 P 继续存活(用户一直开着 dashboard)
|
|
14
|
+
4. 再次 attach → `startHostUnderLease`(`service.mjs:243`)替换 terminal host 前调 `canReplaceHost(observeHostForReplace(existing))`
|
|
15
|
+
5. `observeHostForReplace`(`service.mjs:2006`)对 claim 角色用 `conservativeObservation(host.claimPid)`:**pid 活着 → `"unknown"`**
|
|
16
|
+
6. `canReplaceHost`(`src/core/host-coordination.mjs:72`)要求 runner / child / claim 三角色都 `SAFE_TO_RELEASE`(`not_started | dead | foreign`),claim 为 `"unknown"` → **返回 false**
|
|
17
|
+
7. → `pendingLaunchResult` → attach 循环等待直到 deadline → `pending(sessionFile, "host start timed out")`
|
|
18
|
+
|
|
19
|
+
实机验证(node 直接调用 `canReplaceHost`):
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
host.state: exited
|
|
23
|
+
runnerPid 1004724 → dead
|
|
24
|
+
childPid null → dead
|
|
25
|
+
claimPid 1003423 → unknown ← 阻塞点
|
|
26
|
+
canReplaceHost → false
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### claimPid 的语义(代码注释 + 测试确认)
|
|
30
|
+
|
|
31
|
+
- **非 null = "launcher 可能还在 claim 和 spawn 之间"**:保护 mid-transaction,让 ensureHost/adopt 等 grace 窗口(`service.mjs:976` `withinGrace` 判定依赖 `claimPid != null`)
|
|
32
|
+
- **recovery claim 用 `claimPid: null`**(`service.mjs:723-736`):recovery 事务在 claim 落盘时已完成,spawning 是 adopter 的活;`host-recovery.test.mjs:317` 断言 `recovery claim must not carry a live claimPid`
|
|
33
|
+
- **spawn 失败路径清除 claimPid**(`service.mjs:832, 869`):failed fenced
|
|
34
|
+
- **正常退出路径不清除** ← 缺口:host 进入 `exited` 后 claimPid 残留,而 claim 保护语义(launcher mid-transaction)在 host 已 terminal 时**不可能成立**(runner 都跑完退出了)
|
|
35
|
+
|
|
36
|
+
### 为什么是 bug
|
|
37
|
+
|
|
38
|
+
`canReplaceHost` 的 claim 角色检查在 host 已 terminal 的场景下过度保守。claim 保护只对 `starting` 状态有意义;host 为 `exited/failed` 时,claim 进程不可能还在启动它(启动要么成功——runner 跑过并退出,要么失败——failed fenced 已清 claimPid)。触发条件常见:**dashboard 进程存活 + host exited → 任何从 dashboard 启动又退出的 session 都无法再次 attach**。
|
|
39
|
+
|
|
40
|
+
## 修复方案(选定:放宽 canReplaceHost 的 claim 角色判定)
|
|
41
|
+
|
|
42
|
+
### 方案对比
|
|
43
|
+
|
|
44
|
+
| 方案 | 改动 | 对存量坏记录 | 评价 |
|
|
45
|
+
|------|------|-------------|------|
|
|
46
|
+
| **A. canReplaceHost 放宽 claim 判定** | 纯函数(`host-coordination.mjs`)+ 调用方(`service.mjs`) | **立即生效**(下次 attach 即可替换) | ✅ 选定 |
|
|
47
|
+
| B. terminal 时清除 claimPid | 改 pty-runner 退出路径多处 + recoverHost finalize | 无效(已存在的坏记录不会自动修复,需额外迁移机制) | 改动面大、覆盖不全 |
|
|
48
|
+
| C. conservativeObservation 增加 host 状态感知 | 改观测函数签名(传入 host 状态) | 立即生效 | 污染通用观测函数语义:`conservativeObservation` 的职责是"保守判断单个 pid 是否活着",让它感知 host 状态会把生命周期决策混进观测层,违背 service.mjs 里观测与决策分离的既有结构 |
|
|
49
|
+
|
|
50
|
+
### 设计
|
|
51
|
+
|
|
52
|
+
**1. `canReplaceHost`(`src/core/host-coordination.mjs:72`)判定改为**:host 已 terminal(exited/failed)+ runner/child 均 provably gone + 无 launch lease → 可替换,claim 角色不参与判定。
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
|
|
56
|
+
if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
|
|
57
|
+
if (launchLeaseActive) return false;
|
|
58
|
+
// claim 角色不参与判定:claim 保护语义(launcher mid-transaction)只在 host
|
|
59
|
+
// 处于 starting 时有意义;host 已 terminal 时 claim 进程不可能还在启动它
|
|
60
|
+
// (启动要么成功——runner 跑过并退出,要么失败——failed fenced 已清 claimPid)。
|
|
61
|
+
return (
|
|
62
|
+
SAFE_TO_RELEASE.has(runnerObservation) &&
|
|
63
|
+
SAFE_TO_RELEASE.has(childObservation)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**2. 同步移除 `claimObservation` 参数**(而非保留 unused 参数):
|
|
69
|
+
|
|
70
|
+
- `canReplaceHost` 签名从 `{host, runnerObservation, childObservation, claimObservation, launchLeaseActive}` 改为 `{host, runnerObservation, childObservation, launchLeaseActive}`
|
|
71
|
+
- `observeHostForReplace`(`service.mjs:2006`,内部函数不导出)返回值移除 `claimObservation` 字段——少一次 `process.kill(pid, 0)` 系统调用
|
|
72
|
+
- 既有测试用例同步移除 `claimObservation` 参数
|
|
73
|
+
|
|
74
|
+
**取舍论证**(为什么移除而非保留 unused):
|
|
75
|
+
|
|
76
|
+
- `observeHostForReplace` 只有**一个**调用方(`service.mjs:243` 传给 `canReplaceHost`),且该函数是 service.mjs 内部函数不导出——无外部兼容性问题
|
|
77
|
+
- 保留一个不参与判定的参数会让测试产生误导(传 `claimObservation: "unknown"` 的用例看起来期望 false,实际被忽略)
|
|
78
|
+
- 移除后观测层少一次无用的 `isAlive` 系统调用
|
|
79
|
+
|
|
80
|
+
### 改动文件
|
|
81
|
+
|
|
82
|
+
| 文件 | 改动 |
|
|
83
|
+
|------|------|
|
|
84
|
+
| `src/core/host-coordination.mjs` | `canReplaceHost` 判定放宽 + 签名移除 `claimObservation` + JSDoc 更新 |
|
|
85
|
+
| `src/runtime/service.mjs` | `observeHostForReplace` 返回值移除 `claimObservation` 字段 |
|
|
86
|
+
| `test/host-coordination.test.mjs` | 既有 3 用例移除 `claimObservation` 参数;新增 A1-A5 用例 |
|
|
87
|
+
| `test/host-resolver.test.mjs` | 新增 A6 集成测试 |
|
|
88
|
+
|
|
89
|
+
### 安全性论证
|
|
90
|
+
|
|
91
|
+
| 场景 | 分析 | 结论 |
|
|
92
|
+
|------|------|------|
|
|
93
|
+
| exited + claimPid 活着 | 只可能来自 runner 跑完退出(claim 事务早已完成) | 安全,**修复目标** |
|
|
94
|
+
| failed + claimPid 活着 | runner 崩溃 / child spawn 失败(claim 事务已完成或 failed fenced 已清);child 存活时 childObservation 仍阻塞 | 安全 |
|
|
95
|
+
| runner 活着 | runnerObservation = "unknown" → 仍阻塞(不受影响) | 保守性保留 |
|
|
96
|
+
| child 活着 | childObservation = "unknown" → 仍阻塞(不受影响) | 保守性保留 |
|
|
97
|
+
| alive/starting host + claimPid 存活 | attach 直接到现有 host(probe ready → 返回 pty),**不经过 canReplaceHost** | 不受影响 |
|
|
98
|
+
| recoverHost 并发 | recoverHost 与 startHostUnderLease 都在 host-start lease 内执行,互斥 | 无竞争 |
|
|
99
|
+
| starting 状态 | canReplaceHost 对非 terminal 直接返回 false(不进入新判定) | 不受影响 |
|
|
100
|
+
|
|
101
|
+
### 非目标
|
|
102
|
+
|
|
103
|
+
- 不改 pty-runner 退出路径(方案 B 不做)
|
|
104
|
+
- 不做存量 host.json 迁移/清理(方案 A 对存量记录天然生效)
|
|
105
|
+
- 不改 `conservativeObservation`(其保守语义在 starting 场景仍需要)
|
|
106
|
+
- 不改 dashboard UI、不改 host.json 结构(不加新字段)
|
|
107
|
+
|
|
108
|
+
## 验收矩阵
|
|
109
|
+
|
|
110
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
111
|
+
|----|--------|----------|----------|----------|
|
|
112
|
+
| A1 | canReplaceHost:exited/failed + runner/child dead + **claimPid 存活** → 可替换(修复点) | 自动化验证(unit) | `node --test test/host-coordination.test.mjs` | 新增用例通过:`{host:{state:"exited"}, runner:"dead", child:"dead", lease:false}` → `true`;`{host:{state:"failed"}, ...}` 同 → `true` |
|
|
113
|
+
| A2 | canReplaceHost:runner/child 任一 unknown 仍阻塞(保守性不破坏) | 自动化验证(unit) | 同上 | `runnerObservation:"unknown"` 或 `childObservation:"unknown"` 时返回 `false` |
|
|
114
|
+
| A3 | canReplaceHost:launchLeaseActive 仍阻塞 | 自动化验证(unit) | 同上 | `launchLeaseActive: true` 时返回 `false`(既有用例回归) |
|
|
115
|
+
| A4 | canReplaceHost:非 terminal host(starting/alive/stopping)仍拒绝 | 自动化验证(unit) | 同上 | 三个状态均返回 `false`(新增断言) |
|
|
116
|
+
| A5 | canReplaceHost:SAFE_TO_RELEASE 边界回归——foreign/not_started → 可替换;host null → false | 自动化验证(unit) | 同上 | `runnerObservation:"foreign"` 或 `childObservation:"not_started"` 时返回 `true`;`host: null` 返回 `false` |
|
|
117
|
+
| A6 | resolver 集成:exited host + 存活 claimPid → attach 启动新 host | 自动化验证(integration) | `node --test test/host-resolver.test.mjs` | 新增用例:构造 exited host 记录(`claimPid: process.pid`,存活),`resolveAttachTarget` 返回 `{kind:"pty"}` 且 spawn 恰好 1 次 |
|
|
118
|
+
| U1 | 真实 dashboard 场景:attach 已退出 session(dashboard 进程存活) | 用户实测 | 见下方步骤 | attach 成功进入 session,历史消息正常显示,无超时提示 |
|
|
119
|
+
|
|
120
|
+
**U1 实测步骤**:
|
|
121
|
+
|
|
122
|
+
1. **重启 dashboard 进程**(加载新代码——方案 A 对存量坏记录的"立即生效"以重启为前提)
|
|
123
|
+
2. 打开 dashboard,找到已知坏记录 `view_2472d82627`(issue-225 session,host `state: "exited"` + claimPid 存活)
|
|
124
|
+
3. 点 attach → 观察:成功进入 session、历史消息正常显示、无 `host start timed out` 提示
|
|
125
|
+
4. 退出 session,再 attach,确认可重复
|
|
126
|
+
5. 同法验证 `view_4b667ad75d` / `view_c038badb30`(另两个 exited + claimPid 存活的 view)
|
|
127
|
+
|
|
128
|
+
## 可测性拆分设计
|
|
129
|
+
|
|
130
|
+
修复集中在 `canReplaceHost` 一个纯函数(`src/core/host-coordination.mjs`),无副作用、无 I/O,天然可单测:
|
|
131
|
+
|
|
132
|
+
- **纯函数边界**:`canReplaceHost({host, runnerObservation, childObservation, launchLeaseActive})` → boolean。输入为纯数据快照,输出只依赖输入,不触碰 fs/进程/socket。
|
|
133
|
+
- **观测与决策分离**:`observeHostForReplace`(`service.mjs:2006`)负责进程观测(`conservativeObservation`),`canReplaceHost` 负责决策。修复只动决策层;观测层移除 `claimObservation` 字段是配套清理(少一次无用的 `isAlive` 调用),不改变观测语义。
|
|
134
|
+
- **测试边界**:
|
|
135
|
+
- `test/host-coordination.test.mjs`:A1-A5 全部在纯函数层覆盖(现有 `canReplaceHost refuses unknown observations` 用例扩展 + 新增用例)
|
|
136
|
+
- `test/host-resolver.test.mjs`:A6 走现有 `resolverService` + `healServiceOverrides` 基建(真实 ensureHostImpl claim + scripted probe),验证 attach 全链路(resolver → ensureHost → startHostUnderLease → canReplaceHost → spawn)
|
|
137
|
+
|
|
138
|
+
## 风险与降级
|
|
139
|
+
|
|
140
|
+
- **行为变化面**:仅放宽"exited/failed host 的替换判定"一个点;starting/alive/stopping 的 host 不经过 `canReplaceHost`;runner/child 存活的 host 仍阻塞。
|
|
141
|
+
- **回归风险**:低。改动为纯函数内一个条件的放宽 + 配套签名清理,A2-A5 保证保守性不破坏。
|
|
142
|
+
- **降级路径**:若 U1 实测发现异常,**revert 本 issue 的 commit 恢复原判定**(claim 角色重新参与判定)。
|
|
143
|
+
|
|
144
|
+
## 环境
|
|
145
|
+
|
|
146
|
+
- Linux x64,node v24.13.0,pi-agent-board main(0.6.1)
|
|
147
|
+
- 2026-09-09 实机排查,`view_2472d82627` 现场取证(host.json / diagnostics.jsonl / ps 进程树)
|
package/package.json
CHANGED
package/runner/pty-runner.mjs
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { createServer } from "node:net";
|
|
13
|
-
import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { appendLine, readJson } from "../src/core/atomic.mjs";
|
|
17
17
|
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
18
18
|
import { finalizeHostCrash } from "../src/core/host-crash.mjs";
|
|
19
19
|
import { ownsEndpoint, shouldYieldRunner } from "../src/core/host-coordination.mjs";
|
|
20
|
+
import { lastVisibleLogLine } from "../src/core/heuristics.mjs";
|
|
20
21
|
import { acquireOwnedViewLock } from "../src/core/locks.mjs";
|
|
21
22
|
import * as P from "../src/core/paths.mjs";
|
|
22
23
|
import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
|
|
@@ -39,6 +40,48 @@ const HEARTBEAT_MS = 1000;
|
|
|
39
40
|
const HOST_ACK_DEDUP_MAX = 1000;
|
|
40
41
|
/** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
|
|
41
42
|
const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
|
|
43
|
+
/** How much of the screen.log tail to scan when attributing an abnormal child
|
|
44
|
+
* exit (issue #90). Tail-only: the log can be 100MB+. */
|
|
45
|
+
const EXIT_LOG_TAIL_BYTES = 8_192;
|
|
46
|
+
|
|
47
|
+
/** Tail-read the last `maxBytes` of a file without loading it whole.
|
|
48
|
+
* @param {string} path
|
|
49
|
+
* @param {number} maxBytes
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function readScreenLogTail(path, maxBytes) {
|
|
53
|
+
let fd;
|
|
54
|
+
try {
|
|
55
|
+
fd = openSync(path, "r");
|
|
56
|
+
const size = fstatSync(fd).size;
|
|
57
|
+
const start = Math.max(0, size - maxBytes);
|
|
58
|
+
const length = size - start;
|
|
59
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
60
|
+
readSync(fd, buffer, 0, length, start);
|
|
61
|
+
return buffer.toString("utf8");
|
|
62
|
+
} finally {
|
|
63
|
+
try { if (fd != null) closeSync(fd); } catch { /* best effort */ }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Best-effort error attribution for an abnormal child exit (issue #90): the
|
|
69
|
+
* child's failure reason (e.g. "Model X not found") exists only in the raw
|
|
70
|
+
* screen log, so surface its last visible line in host.json.error. Never
|
|
71
|
+
* throws — this runs on the exit path.
|
|
72
|
+
* @param {number} exitCode
|
|
73
|
+
* @param {string} screenLogPath
|
|
74
|
+
* @returns {{} | { error: string }}
|
|
75
|
+
*/
|
|
76
|
+
function attributedExitError(exitCode, screenLogPath) {
|
|
77
|
+
if (exitCode === 0) return {};
|
|
78
|
+
try {
|
|
79
|
+
const line = lastVisibleLogLine(readScreenLogTail(screenLogPath, EXIT_LOG_TAIL_BYTES));
|
|
80
|
+
return line ? { error: line } : {};
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
42
85
|
|
|
43
86
|
function main() {
|
|
44
87
|
const configPath = process.argv[2];
|
|
@@ -214,7 +257,9 @@ function legacyMain(config) {
|
|
|
214
257
|
// After a crash the handler already persisted "failed" and broadcast
|
|
215
258
|
// exit; this callback must not overwrite that state.
|
|
216
259
|
if (!crashed) {
|
|
217
|
-
|
|
260
|
+
// Attribute only a NATURAL abnormal exit (issue #90): after a deliberate
|
|
261
|
+
// stop (shutdownStarted) the child was killed by us — no error line.
|
|
262
|
+
update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null, ...(shutdownStarted ? {} : attributedExitError(exitCode, screenLog)) });
|
|
218
263
|
editorEmpty = null;
|
|
219
264
|
broadcast({ type: "editor_state", empty: null });
|
|
220
265
|
broadcast({ type: "exit", exitCode });
|
|
@@ -512,6 +557,13 @@ async function ownedMain(config) {
|
|
|
512
557
|
readyAt: null,
|
|
513
558
|
stopRequestedAt: null,
|
|
514
559
|
stopReason: reason,
|
|
560
|
+
// Attribute abnormal NATURAL child exits only (issue #90): reason
|
|
561
|
+
// "child_exit" with a non-zero code. Stops ("signal") and crashes
|
|
562
|
+
// have their own attribution (stopReason / crash finalize), and a
|
|
563
|
+
// SIGTERM'd healthy child must not capture a junk error line.
|
|
564
|
+
...(reason === "child_exit" && cur.error == null
|
|
565
|
+
? attributedExitError(requestedExitCode ?? exitCode ?? 0, screenLog)
|
|
566
|
+
: {}),
|
|
515
567
|
}));
|
|
516
568
|
}
|
|
517
569
|
// Endpoint cleanup: only the exact inode this instance bound.
|
|
@@ -42,6 +42,15 @@ export function registerAgentBoardCommand(pi: ExtensionAPI, opts: AgentBoardComm
|
|
|
42
42
|
piCommand: opts.piCommand,
|
|
43
43
|
piArgsPrefix: opts.piArgsPrefix,
|
|
44
44
|
defaultCwd: ctx.cwd,
|
|
45
|
+
// Stale-defaultModel launch guard (issue #90): live list, undefined
|
|
46
|
+
// when the registry is unavailable so validation conservatively skips.
|
|
47
|
+
availableModels: () => {
|
|
48
|
+
try {
|
|
49
|
+
return ctx.modelRegistry.getAvailable();
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
45
54
|
});
|
|
46
55
|
|
|
47
56
|
if (!ctx.hasUI) {
|
package/src/commands/bg.ts
CHANGED
|
@@ -40,6 +40,15 @@ async function handleBgCommand(args: string, ctx: ExtensionCommandContext, opts:
|
|
|
40
40
|
piCommand: opts.piCommand,
|
|
41
41
|
piArgsPrefix: opts.piArgsPrefix,
|
|
42
42
|
defaultCwd: ctx.cwd,
|
|
43
|
+
// Stale-defaultModel launch guard (issue #90): live list, undefined
|
|
44
|
+
// when the registry is unavailable so validation conservatively skips.
|
|
45
|
+
availableModels: () => {
|
|
46
|
+
try {
|
|
47
|
+
return ctx.modelRegistry.getAvailable();
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
43
52
|
});
|
|
44
53
|
const model = modelRef(ctx.model as any);
|
|
45
54
|
const adopted = service.adoptSession({
|
package/src/core/heuristics.mjs
CHANGED
|
@@ -259,6 +259,41 @@ export function truncate(s, n) {
|
|
|
259
259
|
return `${stripLoneSurrogates(str.slice(0, end))}…`;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
const OSC_SEQUENCE_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
263
|
+
const CSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
264
|
+
const OTHER_ESCAPE_RE = /\x1b./g;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Last non-empty visible line of a raw terminal log chunk: strips OSC/CSI/ESC
|
|
268
|
+
* escape sequences, resolves per-line carriage-return overwrites (progress
|
|
269
|
+
* bars / boot spinners), and returns the final remaining line truncated to
|
|
270
|
+
* `maxLen`. Used to attribute a failed child's exit reason from screen.log
|
|
271
|
+
* into host.json (issue #90). Returns null for empty/all-invisible input.
|
|
272
|
+
* @param {string|null|undefined} text
|
|
273
|
+
* @param {number} [maxLen]
|
|
274
|
+
* @returns {string|null}
|
|
275
|
+
*/
|
|
276
|
+
export function lastVisibleLogLine(text, maxLen = 200) {
|
|
277
|
+
if (!text) return null;
|
|
278
|
+
const visible = String(text)
|
|
279
|
+
.replace(OSC_SEQUENCE_RE, "")
|
|
280
|
+
.replace(CSI_SEQUENCE_RE, "")
|
|
281
|
+
.replace(OTHER_ESCAPE_RE, "")
|
|
282
|
+
// A \r directly before \n is a line terminator (CRLF), not an overwrite
|
|
283
|
+
// marker — normalize it away so the line content survives.
|
|
284
|
+
.replace(/\r\n/g, "\n");
|
|
285
|
+
const lines = visible.split("\n");
|
|
286
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
287
|
+
// Trailing \r chars are line-terminator junk (PTY ONLCR + the program's own
|
|
288
|
+
// CRLF stack up as \r\r\n) — drop them, then resolve interior \r overwrites.
|
|
289
|
+
const cleaned = lines[i].replace(/\r+$/, "");
|
|
290
|
+
const cr = cleaned.lastIndexOf("\r");
|
|
291
|
+
const line = (cr >= 0 ? cleaned.slice(cr + 1) : cleaned).trim();
|
|
292
|
+
if (line) return truncate(line, maxLen);
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
262
297
|
/**
|
|
263
298
|
* Compact relative age, e.g. "10s", "2m", "3h", "4d".
|
|
264
299
|
* @param {number} fromMs
|
|
@@ -57,25 +57,27 @@ export function processIdentityState(identity, observed, spawnedAt) {
|
|
|
57
57
|
const SAFE_TO_RELEASE = new Set(["not_started", "dead", "foreign"]);
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Whether an exited/failed host can be replaced by a new claim.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* Whether an exited/failed host can be replaced by a new claim. The runner and
|
|
61
|
+
* child roles must be provably gone; any `unknown` observation or an active
|
|
62
|
+
* launch lease blocks replacement. The claim role does NOT participate: claim
|
|
63
|
+
* protection (a launcher mid-transaction between claim and spawn) only matters
|
|
64
|
+
* while the host is `starting`, and this gate only ever sees terminal hosts —
|
|
65
|
+
* a terminal host cannot still be being launched (issue #99: a live claimPid —
|
|
66
|
+
* the dashboard process that wrote the claim — must not block re-attach).
|
|
63
67
|
* @param {{
|
|
64
68
|
* host: HostStatus|null|undefined,
|
|
65
69
|
* runnerObservation: string,
|
|
66
70
|
* childObservation: string,
|
|
67
|
-
* claimObservation: string,
|
|
68
71
|
* launchLeaseActive: boolean,
|
|
69
72
|
* }} input
|
|
70
73
|
* @returns {boolean}
|
|
71
74
|
*/
|
|
72
|
-
export function canReplaceHost({ host, runnerObservation, childObservation,
|
|
75
|
+
export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
|
|
73
76
|
if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
|
|
74
77
|
if (launchLeaseActive) return false;
|
|
75
78
|
return (
|
|
76
79
|
SAFE_TO_RELEASE.has(runnerObservation) &&
|
|
77
|
-
SAFE_TO_RELEASE.has(childObservation)
|
|
78
|
-
SAFE_TO_RELEASE.has(claimObservation)
|
|
80
|
+
SAFE_TO_RELEASE.has(childObservation)
|
|
79
81
|
);
|
|
80
82
|
}
|
|
81
83
|
|
|
@@ -122,6 +124,29 @@ export function classifyProbeResult(result) {
|
|
|
122
124
|
return "starting";
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Whether a legacy (pre-instanceId) host record can be safely finalized as
|
|
129
|
+
* `exited` so the next attach claims a fresh host. Spec §10.1 keeps legacy
|
|
130
|
+
* hosts unrecovered because their identity is unverifiable — but a provably
|
|
131
|
+
* dead runner pid IS a certain identity (a reused pid reads alive, which
|
|
132
|
+
* conservatively lands here as false), and an unreachable endpoint (missing
|
|
133
|
+
* socket file / refused socket) rules out a merely slow host. All four
|
|
134
|
+
* conditions must hold at once (issue #87).
|
|
135
|
+
* @param {{
|
|
136
|
+
* host: HostStatus|null|undefined,
|
|
137
|
+
* hostPid: number|null|undefined,
|
|
138
|
+
* hostPidAlive: boolean,
|
|
139
|
+
* probeClassification: string,
|
|
140
|
+
* }} input
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
|
|
144
|
+
if (!host || host.instanceId != null) return false;
|
|
145
|
+
if (host.state !== "starting" && host.state !== "alive") return false;
|
|
146
|
+
if (hostPid == null || hostPidAlive) return false;
|
|
147
|
+
return probeClassification === "missing" || probeClassification === "stale";
|
|
148
|
+
}
|
|
149
|
+
|
|
125
150
|
/** Host states in which a claim exists and must not be duplicated. */
|
|
126
151
|
const ACTIVE_STATES = new Set(["starting", "alive", "stopping"]);
|
|
127
152
|
|
|
@@ -43,6 +43,23 @@ export function canonicalModelRef(model) {
|
|
|
43
43
|
return model ? `${model.provider}/${model.id}` : "";
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whether a stored model reference resolves to a currently-available model.
|
|
48
|
+
* Same rule as the dashboard launch picker: case-insensitive exact "provider/id".
|
|
49
|
+
* A null/empty reference is trivially available (no constraint), and an
|
|
50
|
+
* empty/unavailable model list must not block launches (the caller cannot judge
|
|
51
|
+
* availability, so the conservative answer is allow — issue #90).
|
|
52
|
+
* @param {string|null|undefined} modelRef
|
|
53
|
+
* @param {LaunchModelLike[]|undefined|null} availableModels
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function modelRefAvailable(modelRef, availableModels) {
|
|
57
|
+
const ref = String(modelRef ?? "").trim().toLowerCase();
|
|
58
|
+
if (!ref) return true;
|
|
59
|
+
if (!availableModels || availableModels.length === 0) return true;
|
|
60
|
+
return availableModels.some((model) => `${model.provider}/${model.id}`.toLowerCase() === ref);
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
/** @param {LaunchModelLike|null|undefined} a @param {LaunchModelLike|null|undefined} b */
|
|
47
64
|
export function sameModel(a, b) {
|
|
48
65
|
return Boolean(a && b && a.provider === b.provider && a.id === b.id);
|
package/src/core/launch.mjs
CHANGED
|
@@ -16,6 +16,34 @@ import { writePid } from "./store.mjs";
|
|
|
16
16
|
/** @typedef {import("./types.mjs").TitleConfig} TitleConfig */
|
|
17
17
|
/** @typedef {import("./types.mjs").AutoStateConfig} AutoStateConfig */
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Spawn a fully detached runner child with async spawn failures made harmless.
|
|
21
|
+
*
|
|
22
|
+
* A spawn that fails to start (e.g. a transient ENOENT on the node binary,
|
|
23
|
+
* issue #86) reports asynchronously via the 'error' event; without a listener
|
|
24
|
+
* the EventEmitter rethrows it as an uncaughtException and takes down the
|
|
25
|
+
* whole host Pi process. Callers already handle the failure gracefully via
|
|
26
|
+
* the pid == null branch (state "failed").
|
|
27
|
+
* @param {string} command
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
* @param {string} cwd
|
|
30
|
+
* @returns {import("node:child_process").ChildProcess}
|
|
31
|
+
*/
|
|
32
|
+
function spawnDetached(command, args, cwd) {
|
|
33
|
+
const child = spawn(command, args, {
|
|
34
|
+
cwd,
|
|
35
|
+
detached: true,
|
|
36
|
+
stdio: "ignore",
|
|
37
|
+
env: process.env,
|
|
38
|
+
// Windows: detached children get their own console window unless
|
|
39
|
+
// suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
child.on("error", () => {});
|
|
43
|
+
child.unref();
|
|
44
|
+
return child;
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
/**
|
|
20
48
|
* @param {string} root
|
|
21
49
|
* @param {RunConfig} config
|
|
@@ -28,16 +56,7 @@ export function launchRun(root, config, opts) {
|
|
|
28
56
|
atomicWriteJson(configPath, config);
|
|
29
57
|
|
|
30
58
|
const node = opts.node ?? resolveNode();
|
|
31
|
-
const child =
|
|
32
|
-
cwd: config.cwd,
|
|
33
|
-
detached: true,
|
|
34
|
-
stdio: "ignore",
|
|
35
|
-
env: process.env,
|
|
36
|
-
// Windows: detached children get their own console window unless
|
|
37
|
-
// suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
|
|
38
|
-
windowsHide: true,
|
|
39
|
-
});
|
|
40
|
-
child.unref();
|
|
59
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
41
60
|
|
|
42
61
|
const pid = child.pid ?? null;
|
|
43
62
|
// Record the *runner/monitor* pid for liveness polling (the worker pid is tracked
|
|
@@ -61,14 +80,7 @@ export function launchHost(root, config, opts) {
|
|
|
61
80
|
atomicWriteJson(configPath, config);
|
|
62
81
|
|
|
63
82
|
const node = opts.node ?? resolveNode();
|
|
64
|
-
const child =
|
|
65
|
-
cwd: config.cwd,
|
|
66
|
-
detached: true,
|
|
67
|
-
stdio: "ignore",
|
|
68
|
-
env: process.env,
|
|
69
|
-
windowsHide: true,
|
|
70
|
-
});
|
|
71
|
-
child.unref();
|
|
83
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
72
84
|
|
|
73
85
|
return { pid: child.pid ?? null, configPath };
|
|
74
86
|
}
|
|
@@ -86,14 +98,7 @@ export function launchTitle(root, config, opts) {
|
|
|
86
98
|
atomicWriteJson(configPath, config);
|
|
87
99
|
|
|
88
100
|
const node = opts.node ?? resolveNode();
|
|
89
|
-
const child =
|
|
90
|
-
cwd: config.cwd,
|
|
91
|
-
detached: true,
|
|
92
|
-
stdio: "ignore",
|
|
93
|
-
env: process.env,
|
|
94
|
-
windowsHide: true,
|
|
95
|
-
});
|
|
96
|
-
child.unref();
|
|
101
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
97
102
|
|
|
98
103
|
return { pid: child.pid ?? null, configPath };
|
|
99
104
|
}
|
|
@@ -111,14 +116,7 @@ export function launchAutoState(root, config, opts) {
|
|
|
111
116
|
atomicWriteJson(configPath, config);
|
|
112
117
|
|
|
113
118
|
const node = opts.node ?? resolveNode();
|
|
114
|
-
const child =
|
|
115
|
-
cwd: config.cwd,
|
|
116
|
-
detached: true,
|
|
117
|
-
stdio: "ignore",
|
|
118
|
-
env: process.env,
|
|
119
|
-
windowsHide: true,
|
|
120
|
-
});
|
|
121
|
-
child.unref();
|
|
119
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
122
120
|
|
|
123
121
|
return { pid: child.pid ?? null, configPath };
|
|
124
122
|
}
|
package/src/index.ts
CHANGED
|
@@ -76,8 +76,17 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
// Footer status: reconcile stale rows and surface how many need attention.
|
|
79
|
+
// Live model list for the stale-defaultModel launch guard (issue #90); the
|
|
80
|
+
// service calls it per validation, and undefined conservatively skips.
|
|
81
|
+
const availableModelsFor = (ctx: ExtensionContext) => () => {
|
|
82
|
+
try {
|
|
83
|
+
return ctx.modelRegistry.getAvailable();
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
79
88
|
const serviceFor = (ctx: ExtensionContext) =>
|
|
80
|
-
createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
|
|
89
|
+
createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd, availableModels: availableModelsFor(ctx) });
|
|
81
90
|
|
|
82
91
|
const updateStatus = (ctx: ExtensionContext) => {
|
|
83
92
|
try {
|
|
@@ -112,7 +121,7 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
|
|
|
112
121
|
}
|
|
113
122
|
updateStatus(ctx);
|
|
114
123
|
if (event.reason === "startup" && !isHostedChild && pi.getFlag("agent-board") === true && ctx.hasUI) {
|
|
115
|
-
const service = createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
|
|
124
|
+
const service = createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd, availableModels: availableModelsFor(ctx) });
|
|
116
125
|
service.reconcile();
|
|
117
126
|
ctx.ui.setWorkingVisible(false);
|
|
118
127
|
ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
|