@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
|
@@ -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 路径行为不变。
|
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
|
|
@@ -122,6 +122,29 @@ export function classifyProbeResult(result) {
|
|
|
122
122
|
return "starting";
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Whether a legacy (pre-instanceId) host record can be safely finalized as
|
|
127
|
+
* `exited` so the next attach claims a fresh host. Spec §10.1 keeps legacy
|
|
128
|
+
* hosts unrecovered because their identity is unverifiable — but a provably
|
|
129
|
+
* dead runner pid IS a certain identity (a reused pid reads alive, which
|
|
130
|
+
* conservatively lands here as false), and an unreachable endpoint (missing
|
|
131
|
+
* socket file / refused socket) rules out a merely slow host. All four
|
|
132
|
+
* conditions must hold at once (issue #87).
|
|
133
|
+
* @param {{
|
|
134
|
+
* host: HostStatus|null|undefined,
|
|
135
|
+
* hostPid: number|null|undefined,
|
|
136
|
+
* hostPidAlive: boolean,
|
|
137
|
+
* probeClassification: string,
|
|
138
|
+
* }} input
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
|
|
142
|
+
if (!host || host.instanceId != null) return false;
|
|
143
|
+
if (host.state !== "starting" && host.state !== "alive") return false;
|
|
144
|
+
if (hostPid == null || hostPidAlive) return false;
|
|
145
|
+
return probeClassification === "missing" || probeClassification === "stale";
|
|
146
|
+
}
|
|
147
|
+
|
|
125
148
|
/** Host states in which a claim exists and must not be duplicated. */
|
|
126
149
|
const ACTIVE_STATES = new Set(["starting", "alive", "stopping"]);
|
|
127
150
|
|
|
@@ -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() {} }));
|