@migor/cli 0.9.2 → 0.9.3
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 +122 -1
- package/dist/AGENTS.md +2 -2
- package/dist/dev-child.js +84 -0
- package/dist/dev-logic.js +46 -0
- package/dist/dev-machine.js +489 -0
- package/dist/dev-runner.js +8 -1
- package/dist/dev-watch.js +223 -0
- package/dist/dev.js +377 -626
- package/dist/inspector-page.html +94 -10
- package/dist/inspector-routes.js +530 -0
- package/dist/inspector.js +44 -421
- package/dist/markdown.js +20 -1
- package/dist/native-pick.js +168 -0
- package/dist/panel-logic.js +32 -0
- package/dist/templates/package.json +2 -2
- package/dist/templates/src/app.ts +3 -0
- package/dist/templates/src/dev.config.ts +5 -0
- package/dist/templates/src/registry.ts +5 -0
- package/dist/templates/src/session-store.ts +11 -2
- package/dist/templates/src/tools/read-file/index.ts +50 -3
- package/package.json +1 -1
package/dist/dev.js
CHANGED
|
@@ -6,24 +6,42 @@
|
|
|
6
6
|
* ```
|
|
7
7
|
* dev.ts(CLI,本文件) ← 唯一的进程所有者
|
|
8
8
|
* ├─ inspector 服务(面板 + POST /run) ← 面板的读写入口
|
|
9
|
-
* ├─ fs.watch(src/**) + fs.watch(项目根, 只认 .env) ← 「代码变了要重启」的**唯一**触发源
|
|
9
|
+
* ├─ dev-watch.ts:fs.watch(src/**) + fs.watch(项目根, 只认 .env) ← 「代码变了要重启」的**唯一**触发源
|
|
10
10
|
* └─ 子进程:node <tsx> dist/dev-runner.js ← 常驻;import 用户的 src/app.ts 并驱动它
|
|
11
11
|
* └─ 用户代码 → trace 经 sink POST /ingest 回面板
|
|
12
12
|
* ```
|
|
13
13
|
*
|
|
14
14
|
* ⚠️ 子进程是 `node <tsx/cli>`,**不是** `npx tsx` —— 后者会吞掉 IPC 通道,理由见
|
|
15
|
-
* `resolveTsxCli()`(那不是一条风格偏好,是一次实测出来的静默故障)。
|
|
15
|
+
* `dev-child.ts` 的 `resolveTsxCli()`(那不是一条风格偏好,是一次实测出来的静默故障)。
|
|
16
16
|
*
|
|
17
17
|
* 与旧实现的三个区别,都记在 docs/plans/2026-09-22-dev-debug-loop.md:
|
|
18
|
-
* 1. **watch
|
|
18
|
+
* 1. **watch 收编进 dev 环**。旧实现把「文件变 → 重跑」交给 `tsx watch`,而「面板 → 重启」
|
|
19
19
|
* 要归 dev.ts ⇒ 两个触发源就有竞态(保存的瞬间恰好点了运行 = 双双 spawn、端口/SSE 串台)。
|
|
20
20
|
* 现在只有一条路径、一个所有者;顺带解掉「`.md` 不在 tsx 的 import 图里所以不被 watch」。
|
|
21
|
+
* (C 阶段起 watch 件住在 `dev-watch.ts`,本文件只在启动时接线。)
|
|
21
22
|
* 2. **子进程跑 runner 而不是用户的 main.ts**。四个控件(能力选择 / 多轮 / 工作目录 /
|
|
22
23
|
* prompt)全是 `createApp` / `app.run` 的选项,而**调用者**能设它们 —— CLI 成了调用者,
|
|
23
24
|
* 于是用户工程里一个 dev 文件都不需要。
|
|
24
25
|
* 3. **trace sink 在 runner 里注册**(不再用 `NODE_OPTIONS=--import` 的 preload):
|
|
25
26
|
* CLI 代码本来就在子进程里了,那套注入的存在理由消失了。
|
|
26
27
|
*
|
|
28
|
+
* ## 本文件只剩「薄接线」(B 阶段,方案 docs/plans/2026-09-23-cli-structure.md §3 B;
|
|
29
|
+
* ## C 阶段把 watch 件与子进程原语切到 dev-watch.ts / dev-child.ts,见 §3 C)
|
|
30
|
+
*
|
|
31
|
+
* 曾经这里有一台隐式状态机(15 个 `let`、40 处写入点、15 个入口;F1/F4/G1/G2 都是它的
|
|
32
|
+
* 直接后果)。现在**所有「什么情况下该做什么」的判定都在 `dev-machine.ts` 的 `update()`
|
|
33
|
+
* 里**:每个入口把现实翻译成事件(`DevEventIn`)派发给机器,机器返回新状态与效果清单
|
|
34
|
+
* (`DevEffect[]`),本文件只负责**执行**效果 —— spawn / kill / IPC / HTTP / fs / 计时器。
|
|
35
|
+
*
|
|
36
|
+
* 接线层的纪律:
|
|
37
|
+
* - **进程身份归属在这里判**(机器看不到句柄):迟到的消息只可能来自已经不在台上的
|
|
38
|
+
* 进程,一律不派发给机器;唯一例外是过时进程的 exit 仍要把它**自己那次 spawn 的
|
|
39
|
+
* Promise** 收掉(否则启动期被杀的旧进程会把 `await` 它的人永远挂住)。
|
|
40
|
+
* - `restart` 效果自带「串行链 + 起跑时广播 runner-restart」(旧语义:广播发生在链任务
|
|
41
|
+
* 真正起跑时,且 closing 后整条链跳过 —— 所以它不是一条 emit 效果)。
|
|
42
|
+
* - `reject` / `send-ipc` / `arm-abort-timer` / `pick-folder` / `exit-now` 只出现在
|
|
43
|
+
* 专用流程里,由各 handler 就地解释(要能中断效果序列、把 reject 翻译成 HttpError)。
|
|
44
|
+
*
|
|
27
45
|
* ## 什么会重启 runner
|
|
28
46
|
*
|
|
29
47
|
* | 变化 | 动作 | 为什么 |
|
|
@@ -31,7 +49,7 @@
|
|
|
31
49
|
* | 能力选择(`toolSources`) | **重启进程** | 它是 `createApp` 的输入;而框架没有 `AgentApp.close()`,进程内反复重建会攒孤儿 MCP 子进程 ⇒ 进程边界是唯一的回收口(D8 第三轮补充) |
|
|
32
50
|
* | 代码 / 文本资产变更 | **重启进程** | Node 的 import 按 URL 缓存,常驻进程收不到源码改动 |
|
|
33
51
|
* | 工作目录 / prompt / 多轮 / 会话 id | **不重启** | 目录是 per-run 输入,runner 内部按目录重建 app(毫秒级);prompt / 多轮 / 会话 id 本来就是 per-run |
|
|
34
|
-
* | 面板点「中止」 | **先不重启**(优雅 abort) | 框架的 `signal` 是协作式的 ⇒ 中止后 run 以 `stopReason='aborted'` 正常返回、**trace 保得住**;但工具若不读 `signal` 就没人理 ⇒ 5 s 后升级为重启进程兜底(`ABORT_GRACE_MS`),否则
|
|
52
|
+
* | 面板点「中止」 | **先不重启**(优雅 abort) | 框架的 `signal` 是协作式的 ⇒ 中止后 run 以 `stopReason='aborted'` 正常返回、**trace 保得住**;但工具若不读 `signal` 就没人理 ⇒ 5 s 后升级为重启进程兜底(`ABORT_GRACE_MS`),否则 run 相位永远不落、面板锁死 |
|
|
35
53
|
* | 面板点「清空对话」 | **不重启** | 只是换 sessionId(+ 落盘),下一次 run 换个 id 而已 |
|
|
36
54
|
*
|
|
37
55
|
* 面板是增强项,任何一步失败都只告警、不阻断 dev:inspector 起不来时**降级**成
|
|
@@ -41,65 +59,22 @@
|
|
|
41
59
|
*/
|
|
42
60
|
import { spawn } from 'node:child_process';
|
|
43
61
|
import { randomBytes } from 'node:crypto';
|
|
44
|
-
import { existsSync, mkdirSync,
|
|
45
|
-
import {
|
|
46
|
-
import { dirname, join, relative, sep } from 'node:path';
|
|
62
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
63
|
+
import { dirname, join, relative } from 'node:path';
|
|
47
64
|
import { fileURLToPath } from 'node:url';
|
|
65
|
+
import { KILL_GRACE_MS, killTree, resolveTsxCli } from './dev-child.js';
|
|
66
|
+
import { createInitialState, READY_TIMEOUT_MS, resolveWorkdir, runGateFlags, startupExitMessage, update, } from './dev-machine.js';
|
|
67
|
+
import { APP_ENTRY_REL, DEV_SESSION_ID, SESSION_ID_REL, SESSION_REL, } from './dev-protocol.js';
|
|
68
|
+
import { watchRootEnvFiles, watchTree } from './dev-watch.js';
|
|
48
69
|
import { HttpError, startInspector } from './inspector.js';
|
|
49
|
-
import {
|
|
50
|
-
|
|
70
|
+
import { killActivePickers, pickFolderNative } from './native-pick.js';
|
|
71
|
+
// nextSessionId 已随状态机迁到 dev-machine(纯件);shouldWatch / watchTree /
|
|
72
|
+
// watchRootEnvFiles 已随 C 阶段迁到 dev-watch —— 本文件继续 re-export 它们的出口:
|
|
73
|
+
// packages/cli/test/panel-logic.test.mjs 从 dist/dev.js 取这些纯件,出口不能断。
|
|
74
|
+
export { nextSessionId } from './dev-machine.js';
|
|
75
|
+
export { shouldWatch, watchRootEnvFiles, watchTree } from './dev-watch.js';
|
|
51
76
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
52
77
|
const RUNNER = join(HERE, 'dev-runner.js');
|
|
53
|
-
/**
|
|
54
|
-
* 解析 tsx 的 CLI 入口(绝对路径)。
|
|
55
|
-
*
|
|
56
|
-
* ## 为什么不走 `npx tsx`(实测,不是风格偏好)
|
|
57
|
-
*
|
|
58
|
-
* `npx` 是个**包装器**:它自己再 spawn 一层,而**不把 fd 3 的 IPC 通道转发给孙进程**。
|
|
59
|
-
* 后果不是「启动失败」而是**静默**:runner 里 `process.send` 变成 `undefined`,而
|
|
60
|
-
* `send()` 写的是 `process.send?.()`(可选链)—— 于是所有协议消息被无声丢弃:
|
|
61
|
-
* 面板永远等不到 `ready`,`POST /run` 也永远回不来。更坏的是没有 IPC 通道之后事件循环
|
|
62
|
-
* 无事可做,进程会以 **code=0 干净退出**,看起来像「用户代码自己跑完了」。
|
|
63
|
-
*
|
|
64
|
-
* 同一个 hello 脚本的三方对照:
|
|
65
|
-
*
|
|
66
|
-
* | 启动方式 | `typeof process.send` | IPC |
|
|
67
|
-
* |---|---|---|
|
|
68
|
-
* | `node whoami.mjs` | `function` | ✅ 收到 |
|
|
69
|
-
* | `npx tsx whoami.mjs` | `undefined` | ❌ 无(还多出 npx 的 spinner 噪音) |
|
|
70
|
-
* | `node <tsx>/dist/cli.mjs whoami.mjs` | `function` | ✅ 收到 |
|
|
71
|
-
*
|
|
72
|
-
* ## 为什么不 spawn `node_modules/.bin/tsx`
|
|
73
|
-
*
|
|
74
|
-
* Windows 上它是 `.cmd` shim,会绕回 `npm-bin.ts` 专门要避开的那个 CVE-2024-27980 坑。
|
|
75
|
-
* 直接 `node <tsx/dist/cli.mjs>` 跨平台一致,且是**直接子进程** ⇒ IPC 天然可用。
|
|
76
|
-
*
|
|
77
|
-
* 解析顺序:**用户工程优先**(tsx 是脚手架声明的 devDependency,尊重他们的版本 pin),
|
|
78
|
-
* 再退到 CLI 自己的解析位置(monorepo / 全局安装)。两处都没有 ⇒ `null`,调用方响亮报错。
|
|
79
|
-
*/
|
|
80
|
-
export function resolveTsxCli(root, fromCli) {
|
|
81
|
-
for (const base of [join(root, 'package.json'), fromCli]) {
|
|
82
|
-
try {
|
|
83
|
-
return createRequire(base).resolve('tsx/cli');
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
/* 试下一个解析位置 */
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
/** 子进程就绪的等待上限:装配正常是毫秒级,MCP 重的工程也要不了这么久 */
|
|
92
|
-
const READY_TIMEOUT_MS = 60_000;
|
|
93
|
-
/** 子进程优雅退出的等待上限,超时补 SIGKILL(kill 必须连整棵树 —— 见 killTree) */
|
|
94
|
-
const KILL_GRACE_MS = 3_000;
|
|
95
|
-
/** 文件事件去抖:编辑器保存常常连发好几个事件(写临时文件 + rename) */
|
|
96
|
-
const WATCH_DEBOUNCE_MS = 150;
|
|
97
|
-
/**
|
|
98
|
-
* 「中止」发出后等它优雅收尾的宽限:超时就重启进程兜底(见 `abortRun`)。
|
|
99
|
-
* 取 5s —— 优雅路径要在**回合边界**才生效(在飞的模型请求先被取消),
|
|
100
|
-
* 给足一个回合;再长就等于按钮按了没反应。
|
|
101
|
-
*/
|
|
102
|
-
const ABORT_GRACE_MS = 5_000;
|
|
103
78
|
/**
|
|
104
79
|
* 收尾的**硬上限**:超过它就硬退。
|
|
105
80
|
*
|
|
@@ -107,254 +82,6 @@ const ABORT_GRACE_MS = 5_000;
|
|
|
107
82
|
* 而不是替 SIGKILL 兜底计时器的班(那个兜底只在宽限期到点时才会发出来)。
|
|
108
83
|
*/
|
|
109
84
|
const SHUTDOWN_DEADLINE_MS = KILL_GRACE_MS + 2_000;
|
|
110
|
-
/**
|
|
111
|
-
* 会触发重启的文件类型(**允许清单**,不是排除清单)。
|
|
112
|
-
*
|
|
113
|
-
* 用允许清单而不是「除了 node_modules 都算」:`.DS_Store` / 编辑器 swap / 日志会
|
|
114
|
-
* 让常驻进程反复重启,而那种重启看起来像「agent 自己在抖」。
|
|
115
|
-
* `.md` **必须在**里面 —— 文本资产(`@Prompt` 拉的 `.md`、子 agent 的 `system.md`)
|
|
116
|
-
* 不在 tsx 的 import 图里,旧实现因此对它们**静默无感**(D4 的 G3b)。
|
|
117
|
-
*/
|
|
118
|
-
const WATCH_EXT = new Set(['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md']);
|
|
119
|
-
/**
|
|
120
|
-
* 按文件名(而非扩展名)命中的环境变量文件。
|
|
121
|
-
*
|
|
122
|
-
* ⚠️ 它们**不在** `watchTree` 的根(`src/`)下 ⇒ 由 `watchRootEnvFiles` 单独盯。
|
|
123
|
-
* 早先只有 `shouldWatch` 认识这两个名字、却没有任何一个 watch 够得着它们,
|
|
124
|
-
* 于是「改 `.env` 会重启」是一句**没人执行**的承诺(真跑抓出来的,见 `watchRootEnvFiles`)。
|
|
125
|
-
*/
|
|
126
|
-
const WATCH_NAMES = new Set(['.env', '.env.local']);
|
|
127
|
-
/**
|
|
128
|
-
* 不看的目录:产物、依赖、VCS、dev 自己的状态。
|
|
129
|
-
*
|
|
130
|
-
* ⚠️ 这道闸是**第二层**。第一层是**监视根**:`devServer` 只 `watchTree(<projectRoot>/src)`
|
|
131
|
-
* —— `.agentia/`(会话历史 + 当前会话 id)与 `dist/` 都在项目根,**根本不在范围内**。
|
|
132
|
-
* 两层都要有:只靠根的话,哪天把根改成项目根(一个很自然的想法),`.agentia/session.json`
|
|
133
|
-
* 就会立刻变成「每次多轮 run 重启一次子进程」的自噬循环。
|
|
134
|
-
*/
|
|
135
|
-
const WATCH_SKIP = new Set(['node_modules', 'dist', '.git', '.agentia', 'coverage']);
|
|
136
|
-
/**
|
|
137
|
-
* 这个文件该不该触发重启?**导出是为了单测** —— 它是「改 `.md` 会不会重启」这条
|
|
138
|
-
* 承诺的唯一判据,而那条承诺正是旧实现静默失效的地方(G3b)。
|
|
139
|
-
*/
|
|
140
|
-
export function shouldWatch(abs) {
|
|
141
|
-
const base = abs.slice(abs.lastIndexOf(sep) + 1);
|
|
142
|
-
if (WATCH_NAMES.has(base))
|
|
143
|
-
return true;
|
|
144
|
-
const dot = base.lastIndexOf('.');
|
|
145
|
-
return dot > 0 && WATCH_EXT.has(base.slice(dot));
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* 杀掉**整棵树**。
|
|
149
|
-
*
|
|
150
|
-
* `npx tsx` 自己还会 spawn 子进程,只 kill 直接子进程会留下孤儿(照搬 v0.7.0
|
|
151
|
-
* 「`close()` 保证子进程已终止」的纪律)。POSIX 下靠 `detached: true` 把子进程
|
|
152
|
-
* 变成进程组组长,再对**负 pid** 发信号;win32 没有进程组,用 `taskkill /T`。
|
|
153
|
-
*/
|
|
154
|
-
function killTree(child, signal) {
|
|
155
|
-
const pid = child.pid;
|
|
156
|
-
if (pid === undefined)
|
|
157
|
-
return;
|
|
158
|
-
if (process.platform === 'win32') {
|
|
159
|
-
try {
|
|
160
|
-
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
/* 已经退出了 */
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
try {
|
|
168
|
-
process.kill(-pid, signal); // 负 pid = 整个进程组
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
try {
|
|
172
|
-
child.kill(signal);
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
/* 已经退出了 */
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* 这个**目录**要不要进 watch 范围?
|
|
181
|
-
*
|
|
182
|
-
* ⚠️ 判据必须由 `addDir` 自己执行,不能只留在「初始递归」那一个调用点 ——
|
|
183
|
-
* 动态新增目录(watcher 回调里发现新目录时调 `addDir`)走的是**另一条路**,
|
|
184
|
-
* 只在一处判就等于「启动时就存在的 `dist/` 不看、启动后才出现的 `dist/` 看」。
|
|
185
|
-
*
|
|
186
|
-
* 真实影响面由**监视根**兜着(`devServer` 只看 `<projectRoot>/src`,见 `WATCH_SKIP`),
|
|
187
|
-
* 所以它今天多半打不到 `.agentia` —— 但那是「碰巧打不到」,不是「判据成立」。
|
|
188
|
-
*
|
|
189
|
-
* 判据只看**目录名**(不看路径段):项目根本身叫 `dist` / `node_modules` 是合法的
|
|
190
|
-
* (见 `shouldWatch` 的同一条教训),所以 root 由调用方显式豁免。
|
|
191
|
-
*/
|
|
192
|
-
function shouldDescend(dir) {
|
|
193
|
-
const name = dir.slice(dir.lastIndexOf(sep) + 1);
|
|
194
|
-
return !WATCH_SKIP.has(name) && !name.startsWith('.');
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* 文件事件去抖通知器:同一批事件只回调**第一条**。
|
|
198
|
-
*
|
|
199
|
-
* 为什么抽出来共用:**两个** watch(递归目录树 `watchTree` / 项目根的 env 文件
|
|
200
|
-
* `watchRootEnvFiles`)都要同一套去抖与关闭语义。各写一份就会漂 —— 比如只有一处清理
|
|
201
|
-
* `timer`,关闭后仍会回调一次;而那种漂的后果是「停机时又重启了一次子进程」。
|
|
202
|
-
*/
|
|
203
|
-
function makeNotifier(onFile) {
|
|
204
|
-
let closed = false;
|
|
205
|
-
let timer = null;
|
|
206
|
-
const pending = new Set();
|
|
207
|
-
const flush = () => {
|
|
208
|
-
timer = null;
|
|
209
|
-
if (closed || pending.size === 0)
|
|
210
|
-
return;
|
|
211
|
-
const first = [...pending][0];
|
|
212
|
-
pending.clear();
|
|
213
|
-
onFile(first);
|
|
214
|
-
};
|
|
215
|
-
return {
|
|
216
|
-
note: (abs) => {
|
|
217
|
-
if (closed)
|
|
218
|
-
return;
|
|
219
|
-
pending.add(abs);
|
|
220
|
-
if (timer === null)
|
|
221
|
-
timer = setTimeout(flush, WATCH_DEBOUNCE_MS);
|
|
222
|
-
},
|
|
223
|
-
dispose: () => {
|
|
224
|
-
closed = true;
|
|
225
|
-
if (timer !== null)
|
|
226
|
-
clearTimeout(timer);
|
|
227
|
-
},
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* 递归 watch 一个目录树(`fs.watch` 的 recursive 在 Linux 上到 Node 20 才稳,
|
|
232
|
-
* 这里自己递归 + 新目录动态加入,跨平台同一条代码路径)。
|
|
233
|
-
*
|
|
234
|
-
* 跳过目录(`WATCH_SKIP` / 点开头)的判据由 `addDir` **内部**执行 —— 初始递归与
|
|
235
|
-
* 动态新增两条路共用它,见 `shouldDescend`。
|
|
236
|
-
*
|
|
237
|
-
* ⚠️ 它只看**一棵**树:`.env` / `.env.local` 在项目根、不在 `src/` 下,所以那两份由
|
|
238
|
-
* `watchRootEnvFiles` 单独盯(见它的说明)。
|
|
239
|
-
*
|
|
240
|
-
* **导出是为了单测**:它是「改文本资产不用重启」那条承诺的落点。
|
|
241
|
-
*/
|
|
242
|
-
export function watchTree(root, onFile) {
|
|
243
|
-
const watchers = new Map();
|
|
244
|
-
let closed = false;
|
|
245
|
-
const notifier = makeNotifier(onFile);
|
|
246
|
-
const note = (abs) => {
|
|
247
|
-
if (closed || !shouldWatch(abs))
|
|
248
|
-
return;
|
|
249
|
-
notifier.note(abs);
|
|
250
|
-
};
|
|
251
|
-
/** @param isRoot 项目根自己 —— 它可能就叫 `dist` / `.foo`,但必须看(唯一豁免处) */
|
|
252
|
-
const addDir = (dir, isRoot = false) => {
|
|
253
|
-
if (closed || watchers.has(dir))
|
|
254
|
-
return;
|
|
255
|
-
// 跳过目录的判据在**这里**(唯一一处),不在调用点 —— 见 shouldDescend 的说明
|
|
256
|
-
if (!isRoot && !shouldDescend(dir))
|
|
257
|
-
return;
|
|
258
|
-
let entries;
|
|
259
|
-
try {
|
|
260
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
return; // 目录刚被删掉
|
|
264
|
-
}
|
|
265
|
-
let w;
|
|
266
|
-
try {
|
|
267
|
-
w = watch(dir, { persistent: true }, (_event, name) => {
|
|
268
|
-
if (name === null)
|
|
269
|
-
return;
|
|
270
|
-
const abs = join(dir, String(name));
|
|
271
|
-
note(abs);
|
|
272
|
-
// 新目录要加进 watch 范围(否则新加的能力目录永远不触发重启)。
|
|
273
|
-
// 跳过判据由 addDir 内部执行 ⇒ 这里**不要**再判一次(两处判就会漂)。
|
|
274
|
-
try {
|
|
275
|
-
if (statSync(abs).isDirectory())
|
|
276
|
-
addDir(abs);
|
|
277
|
-
}
|
|
278
|
-
catch {
|
|
279
|
-
/* 被删了 */
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
return; // 权限 / 已被删
|
|
285
|
-
}
|
|
286
|
-
w.on('error', () => { });
|
|
287
|
-
watchers.set(dir, w);
|
|
288
|
-
for (const e of entries) {
|
|
289
|
-
if (e.isDirectory())
|
|
290
|
-
addDir(join(dir, e.name));
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
addDir(root, true);
|
|
294
|
-
return () => {
|
|
295
|
-
closed = true;
|
|
296
|
-
notifier.dispose();
|
|
297
|
-
for (const w of watchers.values())
|
|
298
|
-
w.close();
|
|
299
|
-
watchers.clear();
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* 只盯**项目根**下按文件名命中的那几份文件(`.env` / `.env.local`)。
|
|
304
|
-
*
|
|
305
|
-
* **为什么单开一个、而不是把 `watchTree` 的根抬到项目根**:`.env` 在项目根,而**其他**
|
|
306
|
-
* 该看的东西都在 `src/` —— 抬根会把 `README.md` / `docs/` / `examples/` 全收进来,
|
|
307
|
-
* 「改代码要重启」就变成「改任何文档也重启」。所以这里刻意**不递归**,且**只认名字**
|
|
308
|
-
* (不认扩展名:项目根的 `package.json` 同样不该由这里管)。
|
|
309
|
-
*
|
|
310
|
-
* ⚠️ 它存在的理由:`WATCH_NAMES` 曾经是一条**够不着**的判据 —— 允许清单里有 `.env`、
|
|
311
|
-
* `usage-guide` 也承诺「看 `.env`」,但 `watchTree` 的根是 `<projectRoot>/src`,
|
|
312
|
-
* 而 `.env` 在项目根 ⇒ 改 `.env` **静默无感**(与 G3b 同类:文档承诺了、代码够不着)。
|
|
313
|
-
* **这条只有真跑能守**:根是**调用点**决定的,`watchTree` 自己无从知道该看哪儿
|
|
314
|
-
* (`scripts/e2e-dev.ts` 第 9-bis 步钉着它)。
|
|
315
|
-
*/
|
|
316
|
-
export function watchRootEnvFiles(root, onFile) {
|
|
317
|
-
const notifier = makeNotifier(onFile);
|
|
318
|
-
let w;
|
|
319
|
-
try {
|
|
320
|
-
w = watch(root, { persistent: true }, (_event, name) => {
|
|
321
|
-
if (name === null)
|
|
322
|
-
return;
|
|
323
|
-
const base = String(name);
|
|
324
|
-
if (!WATCH_NAMES.has(base))
|
|
325
|
-
return;
|
|
326
|
-
notifier.note(join(root, base));
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
return () => { }; // 项目根不可 watch(权限)—— 静默降级成「只看 src/」,与旧行为一致
|
|
331
|
-
}
|
|
332
|
-
w.on('error', () => { });
|
|
333
|
-
return () => {
|
|
334
|
-
w.close();
|
|
335
|
-
notifier.dispose();
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* 「清空对话」换到的下一个 id:`dev` → `dev-2` → `dev-3` …(导出是为了单测,同 `shouldWatch`)。
|
|
340
|
-
*
|
|
341
|
-
* 为什么是**换 id** 而不是删会话文件:`session.json` 是 `SessionStore` 的账,面板对它
|
|
342
|
-
* **只读**(写它会造出「面板显示的对话」与「模型真正看到的对话」不一致,见 `readSession`)。
|
|
343
|
-
* 而「当前用哪个 id」是 dev 环的界面状态 ⇒ 换 id 就够,且**不动别人的账**
|
|
344
|
-
* (旧对话还在盘上,run 列表里那些 run 也都还指得到)。
|
|
345
|
-
*
|
|
346
|
-
* 认不出的形状(用户手工改了 id 文件)⇒ 回到 `-2`,不抛:一个怪 id 不该让面板坏掉。
|
|
347
|
-
*/
|
|
348
|
-
export function nextSessionId(current, base = DEV_SESSION_ID) {
|
|
349
|
-
// base 是**参数**(导出为单测用)⇒ 进正则前先转义。现在只有默认值走得到,
|
|
350
|
-
// 但「一个参数迟早被传脏值」这类坑不值得留着(`.x` 会变成「任意字符 + x」)
|
|
351
|
-
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
352
|
-
const m = new RegExp(`^${esc}(?:-(\\d+))?$`).exec(current);
|
|
353
|
-
if (m === null)
|
|
354
|
-
return `${base}-2`;
|
|
355
|
-
const n = m[1] === undefined ? 1 : Number(m[1]);
|
|
356
|
-
return `${base}-${n + 1}`;
|
|
357
|
-
}
|
|
358
85
|
/**
|
|
359
86
|
* @param argv 透传给 dev 环的参数:`agentia dev -- "问题"` 时 argv[0] 是**首次 run 的 prompt**。
|
|
360
87
|
* 脚手架把 `npm run dev` 指到本命令,而工程 README 文档化的用法正是
|
|
@@ -382,37 +109,22 @@ export function devServer(argv = []) {
|
|
|
382
109
|
process.exitCode = 1;
|
|
383
110
|
return 1;
|
|
384
111
|
}
|
|
385
|
-
|
|
112
|
+
// ---------- 接线层持有的句柄(不进机器状态:不可序列化,机器只管「相位 + 数据」) ----------
|
|
386
113
|
let inspector = null;
|
|
387
114
|
let stopWatch = null;
|
|
388
115
|
/** 项目根那份 `.env` / `.env.local` 的 watch —— 与 `stopWatch` 是**两个**,两者都要收 */
|
|
389
116
|
let stopEnvWatch = null;
|
|
390
|
-
|
|
391
|
-
let
|
|
392
|
-
/**
|
|
393
|
-
|
|
394
|
-
*
|
|
395
|
-
* 为什么必须有它:`running` 只能在 run **真的发进通道之后**置位(它是「runner 里有个
|
|
396
|
-
* run 在飞」的口径,面板据此禁用按钮、`abortRun` 据此判「有没有可中止的东西」)。
|
|
397
|
-
* 而受理到发出之间有一次 await ⇒ 只看 `running` 的闸在「改了能力选择 ⇒ 双击运行」
|
|
398
|
-
* 这条路径上会**放两个请求进来**(两个 `runOnce` 并发、`currentAbort` 被覆盖、
|
|
399
|
-
* `pendingNote` 挂到别人的 traceId 上)。§6 待定 5 承诺的是**默认拒绝**。
|
|
400
|
-
*
|
|
401
|
-
* 刻意**不**并进 `running`(那会让面板在还没 run 时就把「中止」按钮点亮,而
|
|
402
|
-
* `abortRun` 此时只能回 409 —— 一个自相矛盾的面板)。
|
|
403
|
-
*/
|
|
404
|
-
let launching = false;
|
|
405
|
-
let lastError = null;
|
|
406
|
-
/** 在飞 run 期间来的文件变更:等 run 收尾再重启(不在飞的 run 不该被掐掉) */
|
|
407
|
-
let pendingRestart = null;
|
|
408
|
-
let pendingNote = null;
|
|
117
|
+
/** 当代子进程句柄(机器状态的 `child` 相位对应的实物;身份归属判定靠它) */
|
|
118
|
+
let currentProc = null;
|
|
119
|
+
/** 中止在飞 run 的兜底计时器(机器侧对应 `run: 'aborting'` 相位 + arm/disarm 效果) */
|
|
120
|
+
let abortTimer = null;
|
|
409
121
|
/** 重启串行化:两次重启不能交叠(否则会留下没人管的子进程) */
|
|
410
122
|
let chain = Promise.resolve();
|
|
411
123
|
/** dev token(D0):每个 dev 会话一个,进程退出即失效 */
|
|
412
124
|
const token = randomBytes(16).toString('hex');
|
|
413
125
|
// ---------- 会话 id(面板的「清空对话」) ----------
|
|
414
126
|
/**
|
|
415
|
-
* 当前会话 id
|
|
127
|
+
* 当前会话 id 的初值:从 `.agentia/dev-session-id` 读,读不到就用 `DEV_SESSION_ID`。
|
|
416
128
|
*
|
|
417
129
|
* 为什么要落盘:只放内存的话,用户「清空对话」之后重启 `npm run dev`,
|
|
418
130
|
* 刚清掉的那段对话会**自己回来** —— 静默不一致。
|
|
@@ -429,7 +141,6 @@ export function devServer(argv = []) {
|
|
|
429
141
|
return DEV_SESSION_ID;
|
|
430
142
|
}
|
|
431
143
|
};
|
|
432
|
-
let sessionId = readSessionId();
|
|
433
144
|
const writeSessionId = (id) => {
|
|
434
145
|
try {
|
|
435
146
|
mkdirSync(join(projectRoot, '.agentia'), { recursive: true });
|
|
@@ -440,23 +151,104 @@ export function devServer(argv = []) {
|
|
|
440
151
|
console.warn(`[agentia] 会话 id 落盘失败(${e.message}):本次「清空对话」在重启 dev 后会失效`);
|
|
441
152
|
}
|
|
442
153
|
};
|
|
443
|
-
|
|
444
|
-
let
|
|
154
|
+
// ---------- 状态机 ----------
|
|
155
|
+
// 全部可变状态收敛在这一个对象里(15 个 let → DevMachineState 的显式字段);
|
|
156
|
+
// 迁移只经 update(),写入点只有 dispatch 与各 handler 里的 `state = r.state`。
|
|
157
|
+
let state = createInitialState({ projectRoot, sessionId: readSessionId() });
|
|
445
158
|
const clearAbortTimer = () => {
|
|
446
159
|
if (abortTimer !== null) {
|
|
447
160
|
clearTimeout(abortTimer);
|
|
448
161
|
abortTimer = null;
|
|
449
162
|
}
|
|
450
163
|
};
|
|
451
|
-
|
|
452
|
-
const
|
|
164
|
+
/** 机器效果里只允许专用流程解释的几种(走到通用执行器就是接线错了,响亮失败) */
|
|
165
|
+
const SPECIAL_ONLY = new Set([
|
|
166
|
+
'reject',
|
|
167
|
+
'send-ipc',
|
|
168
|
+
'arm-abort-timer',
|
|
169
|
+
'pick-folder',
|
|
170
|
+
'exit-now',
|
|
171
|
+
]);
|
|
172
|
+
/**
|
|
173
|
+
* 通用效果执行器:同步效果立即按序执行;异步效果(restart / spawn-child / stop-child /
|
|
174
|
+
* close-inspector)按序 await,返回的 Promise 由调用方决定等不等。
|
|
175
|
+
* 异步函数在第一个 await 之前是同步执行的 ⇒ 纯同步效果序列(如 emit)保持同步语义。
|
|
176
|
+
*/
|
|
177
|
+
const runEffects = async (effects) => {
|
|
178
|
+
for (const e of effects) {
|
|
179
|
+
switch (e.kind) {
|
|
180
|
+
case 'emit':
|
|
181
|
+
inspector?.emitDev(e.event);
|
|
182
|
+
break;
|
|
183
|
+
case 'note-run':
|
|
184
|
+
inspector?.noteRun(e.traceId, e.note);
|
|
185
|
+
break;
|
|
186
|
+
case 'log':
|
|
187
|
+
if (e.level === 'error')
|
|
188
|
+
console.error(e.message);
|
|
189
|
+
else if (e.level === 'warn')
|
|
190
|
+
console.warn(e.message);
|
|
191
|
+
else
|
|
192
|
+
console.log(e.message);
|
|
193
|
+
break;
|
|
194
|
+
case 'persist-session-id':
|
|
195
|
+
writeSessionId(e.id);
|
|
196
|
+
break;
|
|
197
|
+
case 'disarm-abort-timer':
|
|
198
|
+
clearAbortTimer();
|
|
199
|
+
break;
|
|
200
|
+
case 'stop-watchers':
|
|
201
|
+
stopWatch?.();
|
|
202
|
+
stopEnvWatch?.();
|
|
203
|
+
break;
|
|
204
|
+
case 'kill-pickers':
|
|
205
|
+
killActivePickers();
|
|
206
|
+
break;
|
|
207
|
+
case 'kill-child':
|
|
208
|
+
if (currentProc)
|
|
209
|
+
killTree(currentProc, 'SIGKILL');
|
|
210
|
+
break;
|
|
211
|
+
case 'restart':
|
|
212
|
+
await restartExec(e.reason, e.toolSources);
|
|
213
|
+
break;
|
|
214
|
+
case 'spawn-child':
|
|
215
|
+
// 首次拉起:失败归 boot-failed(机器:lastError + 终端 error + 唯一一帧 runner-error)
|
|
216
|
+
await spawnExec(e.toolSources).catch((err) => {
|
|
217
|
+
void dispatch({ type: 'boot-failed', message: err.message });
|
|
218
|
+
});
|
|
219
|
+
break;
|
|
220
|
+
case 'stop-child':
|
|
221
|
+
await stopChildExec();
|
|
222
|
+
break;
|
|
223
|
+
case 'close-inspector':
|
|
224
|
+
if (inspector)
|
|
225
|
+
await inspector.close();
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
throw new Error(`效果「${String(e.kind)}」不该走通用执行器(SPECIAL_ONLY:${[...SPECIAL_ONLY].join('/')})`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/** 派发一个事件:状态迁移**同步**生效,效果随后执行(返回效果的 Promise) */
|
|
233
|
+
const dispatch = (ev) => {
|
|
234
|
+
const r = update(state, ev);
|
|
235
|
+
state = r.state;
|
|
236
|
+
return runEffects(r.effects);
|
|
237
|
+
};
|
|
238
|
+
// ---------- 子进程(spawn / stop / restart 三个执行器) ----------
|
|
239
|
+
/**
|
|
240
|
+
* 拉起一代 runner 并等到它 ready(或失败)。状态迁移全部由派发事件完成;
|
|
241
|
+
* 这里只管:spawn、挂监听、归属判定、就绪超时。
|
|
242
|
+
*/
|
|
243
|
+
const spawnExec = async (toolSources) => {
|
|
453
244
|
// 每次 spawn 都重解析一次:tsx 是启动期才知道存不存在的东西(用户可能刚 `npm i -D tsx`),
|
|
454
245
|
// 缓存住会让「装好了但还报找不到」这种话变成一句谎。
|
|
455
246
|
const tsxCli = resolveTsxCli(projectRoot, import.meta.url);
|
|
456
247
|
if (tsxCli === null) {
|
|
457
|
-
|
|
458
|
-
'装一个即可:npm i -D tsx(脚手架生成的工程已经声明了它;手写工程把它加进 devDependencies)')
|
|
248
|
+
throw new Error('找不到 tsx —— dev 环要用它跑 TypeScript 源码(runner 会 import 你的 src/app.ts)。\n' +
|
|
249
|
+
'装一个即可:npm i -D tsx(脚手架生成的工程已经声明了它;手写工程把它加进 devDependencies)');
|
|
459
250
|
}
|
|
251
|
+
void dispatch({ type: 'child-spawned', toolSources });
|
|
460
252
|
const env = {
|
|
461
253
|
...process.env,
|
|
462
254
|
// ⚠️ cwd 必须仍是**项目根**:loadEnvFile() 按 cwd 找 .env、discover 的相对路径也按 cwd。
|
|
@@ -466,8 +258,9 @@ export function devServer(argv = []) {
|
|
|
466
258
|
AGENTIA_INSPECT_TOKEN: token,
|
|
467
259
|
...(toolSources === null ? {} : { AGENTIA_DEV_TOOL_SOURCES: JSON.stringify(toolSources) }),
|
|
468
260
|
};
|
|
469
|
-
// `process.execPath` + tsx 的 CLI 入口,而不是 `npx tsx`(理由见
|
|
470
|
-
//
|
|
261
|
+
// `process.execPath` + tsx 的 CLI 入口,而不是 `npx tsx`(理由见 dev-child.ts 的
|
|
262
|
+
// resolveTsxCli):直接子进程 ⇒ fd 3 的 IPC 通道可用;也顺带丢掉 npx 那一层启动开销
|
|
263
|
+
// 与 spinner 噪音。
|
|
471
264
|
const proc = spawn(process.execPath, [tsxCli, RUNNER], {
|
|
472
265
|
cwd: projectRoot,
|
|
473
266
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
@@ -475,141 +268,110 @@ export function devServer(argv = []) {
|
|
|
475
268
|
detached: process.platform !== 'win32',
|
|
476
269
|
env,
|
|
477
270
|
});
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
defaultWorkdir: projectRoot,
|
|
483
|
-
// 就绪前只能给缺省值(真正生效的那份由 `ready` 消息报上来)
|
|
484
|
-
budget: { ...DEFAULT_BUDGET },
|
|
485
|
-
warning: null,
|
|
486
|
-
toolSources,
|
|
487
|
-
};
|
|
488
|
-
// 立刻挂上(不是等到 ready):ready 之前崩掉的进程也得能被 stopChild 收掉,
|
|
489
|
-
// 否则每失败一次就留一个孤儿 npx/tsx 进程树。
|
|
490
|
-
child = handle;
|
|
491
|
-
return new Promise((resolve, reject) => {
|
|
271
|
+
// 立刻挂上(不是等到 ready):ready 之前崩掉的进程也得能被 stopChildExec 收掉,
|
|
272
|
+
// 否则每失败一次就留一个孤儿 tsx 进程树。
|
|
273
|
+
currentProc = proc;
|
|
274
|
+
await new Promise((resolve, reject) => {
|
|
492
275
|
let settled = false;
|
|
493
276
|
const timer = setTimeout(() => {
|
|
494
|
-
|
|
495
|
-
|
|
277
|
+
if (settled)
|
|
278
|
+
return;
|
|
279
|
+
settled = true;
|
|
280
|
+
if (currentProc === proc) {
|
|
281
|
+
// 机器:lastError = 超时文案 + kill-child 效果(就地执行 ⇒ killTree 这个孩子)
|
|
282
|
+
void dispatch({ type: 'child-ready-timeout' });
|
|
283
|
+
currentProc = null;
|
|
284
|
+
reject(new Error(state.lastError));
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// 过时进程(已被摘掉):只收掉自己的 Promise,不碰机器状态
|
|
288
|
+
reject(new Error(`dev runner 启动超时(${READY_TIMEOUT_MS} ms 内没有就绪信号)`));
|
|
289
|
+
}
|
|
496
290
|
}, READY_TIMEOUT_MS);
|
|
497
|
-
|
|
498
|
-
* 启动失败 ⇒ **把 child 摘掉**,不能把一个死 handle 留在那儿。
|
|
499
|
-
*
|
|
500
|
-
* 为什么这是硬要求:`restart` 的失败路径靠 `if (!child)` 判「起不来」(⇒ 回 500、
|
|
501
|
-
* `running` 不置位),而 spawn 出来的进程如果只发了 `'error'`(ENOENT / EACCES 那类),
|
|
502
|
-
* 它**不会**再发 `'exit'`、`exitCode` 恒为 `null` ⇒ 留着的话下一次 `POST /run` 会
|
|
503
|
-
* 往一条已断的通道 `send()`(抛 `ERR_IPC_CHANNEL_CLOSED`),且 `running` 卡在 `true`
|
|
504
|
-
* ⇒ 面板之后每次运行都 409。摘掉它,`if (!child)` 就能正确报「runner 起不来」。
|
|
505
|
-
*/
|
|
506
|
-
const fail = (message) => {
|
|
291
|
+
proc.on('error', (err) => {
|
|
507
292
|
if (settled)
|
|
508
293
|
return;
|
|
509
294
|
settled = true;
|
|
510
295
|
clearTimeout(timer);
|
|
511
|
-
if (
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
296
|
+
if (currentProc === proc) {
|
|
297
|
+
// spawn 失败(ENOENT/EACCES):不会再有 exit。摘掉当代(机器的迁移)+ 拒 Promise
|
|
298
|
+
void dispatch({ type: 'child-spawn-failed', message: err.message });
|
|
299
|
+
currentProc = null;
|
|
300
|
+
reject(new Error(state.lastError));
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
reject(err);
|
|
304
|
+
}
|
|
518
305
|
});
|
|
519
306
|
proc.on('message', (raw) => {
|
|
520
307
|
// 归属判定用**进程身份**而不是代号:迟到的消息只可能来自已经不在台上的那个进程
|
|
521
|
-
if (
|
|
308
|
+
if (currentProc !== proc)
|
|
522
309
|
return;
|
|
523
310
|
const msg = raw;
|
|
524
311
|
if (msg.type === 'ready') {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// 没有这一条它就一直空着 —— 详见 dev-protocol 的 DevEvent 注释。
|
|
534
|
-
emit({ kind: 'runner-ready' });
|
|
312
|
+
void dispatch({
|
|
313
|
+
type: 'child-ready',
|
|
314
|
+
capabilities: msg.capabilities,
|
|
315
|
+
multiTurn: msg.multiTurn,
|
|
316
|
+
defaultWorkdir: msg.defaultWorkdir,
|
|
317
|
+
budget: msg.budget,
|
|
318
|
+
warning: msg.warning,
|
|
319
|
+
});
|
|
535
320
|
if (!settled) {
|
|
536
321
|
settled = true;
|
|
537
322
|
clearTimeout(timer);
|
|
538
|
-
resolve(
|
|
323
|
+
resolve();
|
|
539
324
|
}
|
|
540
325
|
return;
|
|
541
326
|
}
|
|
542
327
|
if (msg.type === 'run-start') {
|
|
543
|
-
|
|
328
|
+
void dispatch({ type: 'ipc-run-start' });
|
|
544
329
|
return;
|
|
545
330
|
}
|
|
546
331
|
if (msg.type === 'run-done') {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (msg.traceId !== null && pendingNote !== null) {
|
|
550
|
-
inspector?.noteRun(msg.traceId, pendingNote);
|
|
551
|
-
}
|
|
552
|
-
pendingNote = null;
|
|
553
|
-
// 中止的 run 也带 error(`abortedResult()` 刻意如此)⇒ `ok` 是 false。
|
|
554
|
-
// 但它**不是**「环坏了」,不该在告警条上留一条永远不消的红字(判别只看 stopReason)。
|
|
555
|
-
if (!msg.ok && msg.stopReason !== 'aborted')
|
|
556
|
-
lastError = msg.error;
|
|
557
|
-
emit({
|
|
558
|
-
kind: 'run-done',
|
|
332
|
+
void dispatch({
|
|
333
|
+
type: 'ipc-run-done',
|
|
559
334
|
traceId: msg.traceId,
|
|
560
335
|
ok: msg.ok,
|
|
561
336
|
stopReason: msg.stopReason,
|
|
562
337
|
error: msg.error,
|
|
563
338
|
finalText: msg.finalText,
|
|
564
339
|
});
|
|
565
|
-
void afterRun();
|
|
566
340
|
return;
|
|
567
341
|
}
|
|
568
342
|
if (msg.type === 'run-error') {
|
|
569
|
-
|
|
570
|
-
clearAbortTimer();
|
|
571
|
-
pendingNote = null;
|
|
572
|
-
lastError = msg.message;
|
|
573
|
-
emit({ kind: 'runner-error', message: msg.message });
|
|
574
|
-
void afterRun();
|
|
343
|
+
void dispatch({ type: 'ipc-run-error', message: msg.message });
|
|
575
344
|
}
|
|
576
345
|
});
|
|
577
346
|
proc.on('exit', (code, sig) => {
|
|
578
|
-
const isCurrent = child?.child === proc;
|
|
579
|
-
if (isCurrent)
|
|
580
|
-
child = null;
|
|
581
347
|
const how = `code=${code ?? 'null'}${sig ? `, signal=${sig}` : ''}`;
|
|
348
|
+
const isCurrent = currentProc === proc;
|
|
349
|
+
if (isCurrent) {
|
|
350
|
+
currentProc = null;
|
|
351
|
+
// 机器按相位分流:starting ⇒ 启动失败归因(**不 emit**,F4:发射权在等 spawn 的
|
|
352
|
+
// 那一侧);ready ⇒ 意外退出 + emit runner-error;closing ⇒ 静默摘除
|
|
353
|
+
void dispatch({ type: 'child-exit', how });
|
|
354
|
+
}
|
|
582
355
|
if (!settled) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
356
|
+
settled = true;
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
// 当代进程:机器刚把原因写进 lastError(exitReason,G1 判据),Promise 用它拒;
|
|
359
|
+
// 过时进程:不碰机器状态(lastError 与新一代共享,写给上一代 = 张冠李戴),
|
|
360
|
+
// 但它自己这次 spawn 的 Promise 必须收掉,否则启动期被杀的旧进程会挂住等待方。
|
|
361
|
+
reject(new Error(isCurrent ? (state.lastError ?? startupExitMessage(how)) : startupExitMessage(how)));
|
|
589
362
|
}
|
|
590
|
-
if (!isCurrent || closing)
|
|
591
|
-
return;
|
|
592
|
-
running = false;
|
|
593
|
-
clearAbortTimer();
|
|
594
|
-
lastError = lastError ?? `dev runner 意外退出(${how})`;
|
|
595
|
-
emit({ kind: 'runner-error', message: lastError });
|
|
596
363
|
});
|
|
597
364
|
});
|
|
598
365
|
};
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
if (!
|
|
366
|
+
const stopChildExec = async () => {
|
|
367
|
+
const proc = currentProc;
|
|
368
|
+
currentProc = null;
|
|
369
|
+
if (!proc)
|
|
603
370
|
return;
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
// 都 409 —— 面板**照样锁死**,只是从「卡在 run 上」变成「卡在 running 标志上」。
|
|
609
|
-
// (唯一会在 run 在飞时杀子进程的路径就是这条兜底:文件变更那条走 pendingRestart 延后。)
|
|
610
|
-
running = false;
|
|
611
|
-
clearAbortTimer();
|
|
612
|
-
const proc = handle.child;
|
|
371
|
+
// 子进程要没了 ⇒ 不可能再有在飞 run:机器的 child-stopped 迁移把 run 相位落下来
|
|
372
|
+
// (旧代码这里漏过 running=false,症状是「中止超时 ⇒ 重启兜底」之后面板永久 409)。
|
|
373
|
+
// 派完之后这个进程的 exit 到来时已不是当代 ⇒ 不会再触发归因(与旧 isCurrent 早退一致)。
|
|
374
|
+
void dispatch({ type: 'child-stopped' });
|
|
613
375
|
if (proc.exitCode !== null || proc.signalCode !== null)
|
|
614
376
|
return;
|
|
615
377
|
await new Promise((done) => {
|
|
@@ -624,180 +386,162 @@ export function devServer(argv = []) {
|
|
|
624
386
|
killTree(proc, 'SIGTERM');
|
|
625
387
|
});
|
|
626
388
|
};
|
|
627
|
-
/** 重启 = 停旧的 +
|
|
628
|
-
|
|
389
|
+
/** 重启 = 停旧的 + 起新的;串行化,且把「重启」这件事广播给面板(不静默)。
|
|
390
|
+
* 广播在**链任务真正起跑时**发(与旧 restart 一致):排在后面的重启不提前广播,
|
|
391
|
+
* closing 之后整条链跳过。 */
|
|
392
|
+
const restartExec = (reason, toolSources) => {
|
|
629
393
|
chain = chain.then(async () => {
|
|
630
|
-
if (closing)
|
|
394
|
+
if (state.closing)
|
|
631
395
|
return;
|
|
632
|
-
|
|
633
|
-
await
|
|
396
|
+
inspector?.emitDev({ kind: 'runner-restart', reason });
|
|
397
|
+
await stopChildExec();
|
|
634
398
|
try {
|
|
635
|
-
await
|
|
399
|
+
await spawnExec(toolSources);
|
|
636
400
|
}
|
|
637
401
|
catch (e) {
|
|
638
|
-
lastError
|
|
639
|
-
|
|
402
|
+
// 失败归 restart-failed(机器:lastError + 唯一一帧 runner-error)
|
|
403
|
+
void dispatch({ type: 'restart-failed', message: e.message });
|
|
640
404
|
}
|
|
641
405
|
});
|
|
642
406
|
return chain;
|
|
643
407
|
};
|
|
644
|
-
|
|
645
|
-
const afterRun = () => {
|
|
646
|
-
if (pendingRestart === null || closing)
|
|
647
|
-
return Promise.resolve();
|
|
648
|
-
const reason = pendingRestart;
|
|
649
|
-
pendingRestart = null;
|
|
650
|
-
return restart(reason, child?.toolSources ?? null);
|
|
651
|
-
};
|
|
652
|
-
// ---------- 面板侧 ----------
|
|
653
|
-
const emit = (ev) => inspector?.emitDev(ev);
|
|
408
|
+
// ---------- 面板侧(hooks:把 HTTP 翻译成事件,把效果翻译成响应) ----------
|
|
654
409
|
const devState = () => ({
|
|
655
410
|
available: true,
|
|
656
411
|
projectRoot,
|
|
657
412
|
home: process.env.HOME ?? '',
|
|
658
|
-
capabilities:
|
|
659
|
-
multiTurn:
|
|
660
|
-
warning:
|
|
661
|
-
defaultWorkdir:
|
|
413
|
+
capabilities: state.capabilities,
|
|
414
|
+
multiTurn: state.multiTurn,
|
|
415
|
+
warning: state.warning,
|
|
416
|
+
defaultWorkdir: state.defaultWorkdir,
|
|
662
417
|
// 生效值由 runner 报(就绪前是缺省值)—— 面板显示与实际生效必须是同一份
|
|
663
|
-
budget:
|
|
664
|
-
sessionId,
|
|
665
|
-
running,
|
|
666
|
-
lastError,
|
|
418
|
+
budget: state.budget,
|
|
419
|
+
sessionId: state.sessionId,
|
|
420
|
+
running: runGateFlags(state).running,
|
|
421
|
+
lastError: state.lastError,
|
|
667
422
|
});
|
|
423
|
+
/** 专用流程共用:从效果清单里取出 reject 并抛成 HttpError(机器的「拒绝」出面) */
|
|
424
|
+
const throwIfRejected = (effects) => {
|
|
425
|
+
const rej = effects.find((e) => e.kind === 'reject');
|
|
426
|
+
if (rej && rej.kind === 'reject')
|
|
427
|
+
throw new HttpError(rej.status, rej.message);
|
|
428
|
+
};
|
|
668
429
|
const submitRun = async (req) => {
|
|
669
|
-
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
throw new HttpError(400, `工作目录不存在或不是文件夹:${workdir}`);
|
|
685
|
-
}
|
|
686
|
-
const defaults = multiTurnDefault(toolSources ?? caps, child?.multiTurn ?? []);
|
|
687
|
-
const multiTurn = req.multiTurn ?? defaults.value;
|
|
430
|
+
const workdir = resolveWorkdir(req, state.defaultWorkdir);
|
|
431
|
+
// 查盘是副作用 ⇒ 接线层做,机器只拿事实判 400。响亮失败:目录写错时**不能**退回
|
|
432
|
+
// 项目根 —— 那会让 agent 对着错的目录乱写(D5)。
|
|
433
|
+
const workdirExists = existsSync(workdir) && statSync(workdir).isDirectory();
|
|
434
|
+
const r = update(state, { type: 'ui-run-requested', req, workdir, workdirExists });
|
|
435
|
+
state = r.state;
|
|
436
|
+
throwIfRejected(r.effects);
|
|
437
|
+
/** 发出前任何一步失败:机器复位 + 补延后重启(旧 finally 的职责),再以 500 出面 */
|
|
438
|
+
const failLaunch = (message) => {
|
|
439
|
+
const r2 = update(state, { type: 'run-launch-failed', message });
|
|
440
|
+
state = r2.state;
|
|
441
|
+
void runEffects(r2.effects.filter((e) => e.kind !== 'reject'));
|
|
442
|
+
throwIfRejected(r2.effects);
|
|
443
|
+
throw new Error(message); // 不可达(run-launch-failed 必带 reject),只喂类型
|
|
444
|
+
};
|
|
688
445
|
let restarted = false;
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
prev.join('\u0000') === toolSources.join('\u0000'));
|
|
694
|
-
// 占位:从这里到「run 发出」之间不能让第二个请求通过上面的闸(见 launching 的说明)。
|
|
695
|
-
// 校验已经做完(工作目录在上一行之前判过)⇒ 占位期间不会有 400 把标志留在 true 上。
|
|
696
|
-
launching = true;
|
|
697
|
-
try {
|
|
698
|
-
if (!child || !same) {
|
|
699
|
-
// 换能力选择 ⇒ 重启进程(D8:理由是回收口,不是成本)。不重启的话旧 app 的
|
|
700
|
-
// MCP 子进程没人收编 —— 框架没有 AgentApp.close()。
|
|
701
|
-
restarted = true;
|
|
702
|
-
await restart('能力选择变化(toolSources)', toolSources ?? null);
|
|
703
|
-
if (!child)
|
|
704
|
-
throw new HttpError(500, lastError ?? 'runner 起不来');
|
|
446
|
+
for (const e of r.effects) {
|
|
447
|
+
if (e.kind === 'disarm-abort-timer') {
|
|
448
|
+
// 上一轮中止留下的兜底计时器不该跨到这一轮来(否则会掐掉一次正常的 run)
|
|
449
|
+
clearAbortTimer();
|
|
705
450
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
};
|
|
712
|
-
running = true; // 乐观置位:连点两次在 runner ack 之前就该被拒
|
|
713
|
-
// 会话 id 由**父进程**给:它是面板级状态(「清空对话」换的就是它),
|
|
714
|
-
// 且必须跨 runner 重启稳定 —— 改代码会重启 runner,但不该丢掉会话。
|
|
715
|
-
// (面板**不能**指定它,见 `RunRequest` 里那条说明。)
|
|
716
|
-
let sent = false;
|
|
717
|
-
try {
|
|
718
|
-
sent = child.child.send({
|
|
719
|
-
type: 'run',
|
|
720
|
-
request: {
|
|
721
|
-
prompt: req.prompt,
|
|
722
|
-
workdir,
|
|
723
|
-
multiTurn,
|
|
724
|
-
sessionId,
|
|
725
|
-
toolSources: toolSources ?? null,
|
|
726
|
-
},
|
|
727
|
-
});
|
|
451
|
+
else if (e.kind === 'restart') {
|
|
452
|
+
restarted = true;
|
|
453
|
+
await restartExec(e.reason, e.toolSources);
|
|
454
|
+
if (state.child !== 'ready')
|
|
455
|
+
failLaunch(state.lastError ?? 'runner 起不来');
|
|
728
456
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
457
|
+
else if (e.kind === 'send-ipc') {
|
|
458
|
+
const proc = currentProc;
|
|
459
|
+
if (!proc)
|
|
460
|
+
failLaunch(state.lastError ?? 'runner 起不来');
|
|
461
|
+
let sent = false;
|
|
462
|
+
try {
|
|
463
|
+
sent = proc.send(e.message);
|
|
464
|
+
}
|
|
465
|
+
catch (e2) {
|
|
466
|
+
// 通道刚断开时 `send()` **会抛**(`ERR_IPC_CHANNEL_CLOSED`),不只是返回 false。
|
|
467
|
+
// 不翻译成失败事件的话机器相位会停在 launching ⇒ 面板之后每次 POST /run 都 409。
|
|
468
|
+
failLaunch(`runner 通道已断(${e2.message}),稍后再试`);
|
|
469
|
+
}
|
|
470
|
+
if (!sent)
|
|
471
|
+
failLaunch('runner 通道已断(进程可能刚退出),稍后再试');
|
|
734
472
|
}
|
|
735
|
-
if (!sent)
|
|
736
|
-
throw new HttpError(500, 'runner 通道已断(进程可能刚退出),稍后再试');
|
|
737
|
-
emit({
|
|
738
|
-
kind: 'run-start',
|
|
739
|
-
prompt: req.prompt,
|
|
740
|
-
workdir,
|
|
741
|
-
toolSources: toolSources ?? null,
|
|
742
|
-
multiTurn,
|
|
743
|
-
});
|
|
744
|
-
return { accepted: true, restarted };
|
|
745
|
-
}
|
|
746
|
-
catch (e) {
|
|
747
|
-
// 任何一步失败都必须把两处状态复位:留下的 `running = true` 会让面板**永久** 409,
|
|
748
|
-
// 而留下的 `pendingNote` 会挂到下一轮 run 的 traceId 上(张冠李戴)。
|
|
749
|
-
running = false;
|
|
750
|
-
pendingNote = null;
|
|
751
|
-
throw e;
|
|
752
|
-
}
|
|
753
|
-
finally {
|
|
754
|
-
launching = false;
|
|
755
473
|
}
|
|
474
|
+
void dispatch({ type: 'run-launched' });
|
|
475
|
+
return { accepted: true, restarted };
|
|
756
476
|
};
|
|
757
477
|
/**
|
|
758
478
|
* 中止在飞 run(§6 待定 5 的「显式 kill 按钮」)。
|
|
759
479
|
*
|
|
760
|
-
*
|
|
480
|
+
* 两条路,按顺序(状态迁移在机器里,这里只管计时器与等待):
|
|
761
481
|
* 1. **优雅**:给 runner 发 `run-abort`,它 abort 掉 `app.run` 的 `signal` ⇒ 引擎在回合边界
|
|
762
482
|
* 以 `stopReason='aborted'` **正常返回** ⇒ **trace 照常落盘**(框架契约:不抛异常)。
|
|
763
483
|
* 2. **兜底**:`signal` 是**协作式**的 —— 工具若不读 `ToolRunContext.signal`(本仓已知
|
|
764
484
|
* 「MCP 在途中止 ⇒ Promise 永不 settle」那类),abort 就没人理。那就重启进程收编它
|
|
765
|
-
* (代价是这次 run 的 trace
|
|
766
|
-
* 之后每次 `POST /run` 都 409 —— 那才是真正没法用的状态)。
|
|
485
|
+
* (代价是这次 run 的 trace 丢掉,但**面板不至于永久锁死**)。
|
|
767
486
|
*/
|
|
768
487
|
const abortRun = async () => {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
488
|
+
const r = update(state, { type: 'ui-abort-requested' });
|
|
489
|
+
state = r.state;
|
|
490
|
+
throwIfRejected(r.effects);
|
|
491
|
+
const arm = r.effects.find((e) => e.kind === 'arm-abort-timer');
|
|
492
|
+
if (arm?.kind !== 'arm-abort-timer') {
|
|
493
|
+
// 幂等:中止已发出(机器相位已是 aborting),不挂第二个计时器
|
|
775
494
|
return { accepted: true, escalated: false };
|
|
776
|
-
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
495
|
+
}
|
|
496
|
+
const sendEff = r.effects.find((e) => e.kind === 'send-ipc');
|
|
497
|
+
const proc = currentProc;
|
|
498
|
+
let sent = false;
|
|
499
|
+
try {
|
|
500
|
+
sent = proc && sendEff && sendEff.kind === 'send-ipc' ? proc.send(sendEff.message) : false;
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
// 相位退回 running(中止没生效,run 还在飞);错误原样上抛(HTTP 层映射 500)
|
|
504
|
+
void dispatch({ type: 'abort-send-failed', message: e.message });
|
|
505
|
+
throw e;
|
|
506
|
+
}
|
|
507
|
+
if (!sent) {
|
|
508
|
+
const message = 'runner 通道已断(进程可能刚退出),稍后再试';
|
|
509
|
+
void dispatch({ type: 'abort-send-failed', message });
|
|
510
|
+
throw new HttpError(500, message);
|
|
511
|
+
}
|
|
780
512
|
const escalated = await new Promise((resolve) => {
|
|
781
513
|
let tick = null;
|
|
782
|
-
|
|
783
|
-
|
|
514
|
+
let deadline = null;
|
|
515
|
+
let done = false;
|
|
516
|
+
const finish = (value) => {
|
|
517
|
+
if (done)
|
|
518
|
+
return;
|
|
519
|
+
done = true;
|
|
784
520
|
if (tick !== null)
|
|
785
521
|
clearInterval(tick);
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
522
|
+
if (deadline !== null)
|
|
523
|
+
clearTimeout(deadline);
|
|
524
|
+
clearAbortTimer();
|
|
525
|
+
resolve(value);
|
|
526
|
+
};
|
|
527
|
+
abortTimer = setTimeout(() => {
|
|
528
|
+
abortTimer = null;
|
|
529
|
+
// 还在跑 ⇒ signal 没人理 ⇒ 必须升级(机器:lastError + restart 效果),否则面板锁死
|
|
530
|
+
void dispatch({ type: 'abort-grace-expired' });
|
|
531
|
+
finish(true);
|
|
532
|
+
}, arm.ms);
|
|
533
|
+
/**
|
|
534
|
+
* 轮询的**自己的**上限。没有它就有一种挂死:run 正常收尾时 run-done 会先
|
|
535
|
+
* disarm(清掉上面的升级计时器)再把相位落到 idle,而轮询是 50 ms 一拍 ——
|
|
536
|
+
* 若这 50 ms 内又起了一个新 run,轮询永远等不到 idle,而升级计时器已经没了
|
|
537
|
+
* ⇒ 这个 Promise **无人 resolve**,`POST /run/abort` 挂住不还。
|
|
538
|
+
* 上限比升级刻度略长:正常升级总是先到。
|
|
539
|
+
*/
|
|
540
|
+
deadline = setTimeout(() => finish(false), arm.ms + 500);
|
|
541
|
+
// 正常收尾(run-done / run-error)会把相位落下来,轮询据此 resolve(false)
|
|
794
542
|
tick = setInterval(() => {
|
|
795
|
-
if (!running)
|
|
796
|
-
|
|
797
|
-
clearInterval(tick);
|
|
798
|
-
clearAbortTimer();
|
|
799
|
-
resolve(false);
|
|
800
|
-
}
|
|
543
|
+
if (!runGateFlags(state).running)
|
|
544
|
+
finish(false);
|
|
801
545
|
}, 50);
|
|
802
546
|
tick.unref();
|
|
803
547
|
});
|
|
@@ -811,12 +555,46 @@ export function devServer(argv = []) {
|
|
|
811
555
|
* 而新的 run 用新 id ⇒ 模型看到的上下文是空的。
|
|
812
556
|
*/
|
|
813
557
|
const clearSession = async () => {
|
|
814
|
-
|
|
815
|
-
|
|
558
|
+
const r = update(state, { type: 'ui-clear-session' });
|
|
559
|
+
state = r.state;
|
|
560
|
+
throwIfRejected(r.effects);
|
|
561
|
+
for (const e of r.effects) {
|
|
562
|
+
if (e.kind === 'persist-session-id')
|
|
563
|
+
writeSessionId(e.id);
|
|
564
|
+
}
|
|
565
|
+
return { sessionId: state.sessionId };
|
|
566
|
+
};
|
|
567
|
+
/**
|
|
568
|
+
* 原生文件夹选择框(面板工作目录控件的「用系统选择器…」按钮)。
|
|
569
|
+
*
|
|
570
|
+
* **串行化**在机器里(picking 相位):同时只许一个在飞,第二个请求 409。
|
|
571
|
+
* 没有面板侧超时(用户可能慢慢选);进程退出时由 `killActivePickers()` 收编。
|
|
572
|
+
*/
|
|
573
|
+
const pickFolder = async () => {
|
|
574
|
+
const r = update(state, { type: 'ui-pick-requested' });
|
|
575
|
+
state = r.state;
|
|
576
|
+
throwIfRejected(r.effects);
|
|
577
|
+
try {
|
|
578
|
+
const picked = await pickFolderNative();
|
|
579
|
+
void dispatch({ type: 'ui-pick-resolved' });
|
|
580
|
+
return picked;
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
// 平台不支持 / 命令缺失(native-pick 的报错已指向降级路径「浏览…」)⇒ 501
|
|
584
|
+
const r2 = update(state, { type: 'ui-pick-failed', message: e.message });
|
|
585
|
+
state = r2.state;
|
|
586
|
+
throwIfRejected(r2.effects);
|
|
587
|
+
throw e; // 不可达(ui-pick-failed 必带 reject),只喂类型
|
|
816
588
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
589
|
+
};
|
|
590
|
+
/**
|
|
591
|
+
* 客户端断开(刷新 / 关标签页)时收掉在飞的选择框(G2 的出口):机器给出
|
|
592
|
+
* kill-pickers 效果 ⇒ kill 子进程 ⇒ pickFolderNative 那侧走「视同取消」
|
|
593
|
+
* (resolve null)⇒ ui-pick-resolved 把 picking 落下来。没有这条出口:
|
|
594
|
+
* 选择框挂在桌面上没人看、picking 永远不落、之后每次点「系统选择…」都 409。
|
|
595
|
+
*/
|
|
596
|
+
const cancelPick = () => {
|
|
597
|
+
void dispatch({ type: 'ui-pick-cancelled' });
|
|
820
598
|
};
|
|
821
599
|
const readSession = async () => {
|
|
822
600
|
// 面板**只读**会话文件:它记的是「我发了什么」,而模型看到的是「store 里有什么」,
|
|
@@ -833,7 +611,7 @@ export function devServer(argv = []) {
|
|
|
833
611
|
const { readFile } = await import('node:fs/promises');
|
|
834
612
|
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
835
613
|
// 读**当前** id(不是常量):清空对话之后面板必须立刻显示空,而不是旧对话
|
|
836
|
-
const messages = parsed.sessions?.[sessionId];
|
|
614
|
+
const messages = parsed.sessions?.[state.sessionId];
|
|
837
615
|
return { messages: Array.isArray(messages) ? messages : [] };
|
|
838
616
|
}
|
|
839
617
|
catch (e) {
|
|
@@ -842,60 +620,41 @@ export function devServer(argv = []) {
|
|
|
842
620
|
};
|
|
843
621
|
// ---------- 收尾 ----------
|
|
844
622
|
/**
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
* 返回 Promise 而不是 fire-and-forget:信号处理器要等它真的做完才 `exit` —— 理由见
|
|
848
|
-
* `shutdownAndExit`(这是个实测出来的坑,不是风格)。
|
|
849
|
-
*/
|
|
850
|
-
const shutdown = async () => {
|
|
851
|
-
if (closing)
|
|
852
|
-
return;
|
|
853
|
-
closing = true;
|
|
854
|
-
stopWatch?.();
|
|
855
|
-
stopEnvWatch?.();
|
|
856
|
-
try {
|
|
857
|
-
await stopChild();
|
|
858
|
-
}
|
|
859
|
-
finally {
|
|
860
|
-
if (inspector)
|
|
861
|
-
await inspector.close();
|
|
862
|
-
}
|
|
863
|
-
};
|
|
864
|
-
let shuttingDown = false;
|
|
865
|
-
/**
|
|
866
|
-
* 收尾后退出(SIGINT / SIGTERM 共用同一条出口)。
|
|
623
|
+
* 收尾后退出(SIGINT / SIGTERM 共用同一条出口)。迁移规则在机器里
|
|
624
|
+
* (第一次 ⇒ closing + 收尾效果序列;第二次 ⇒ exit-now 立即硬退)。
|
|
867
625
|
*
|
|
868
626
|
* ## 为什么不能沿用「固定 50 ms 后 `process.exit(0)`」(实测,不是风格偏好)
|
|
869
627
|
*
|
|
870
|
-
* `
|
|
628
|
+
* `stopChildExec()` 给子进程的宽限期是 `KILL_GRACE_MS`(3 s)—— 它 SIGTERM 之后等 3 s
|
|
871
629
|
* 才补 SIGKILL。而固定 50 ms 就 exit 会让父进程在这 3 s 之前消失:
|
|
872
630
|
* - 那段宽限连同 **SIGKILL 兜底**一起没了(计时器随父进程消失);
|
|
873
|
-
* - `process.on('exit')` 那条兜底此刻也是空操作 ——
|
|
874
|
-
* 置成了 null,而它的归属判定是 `child?.child === proc`。
|
|
631
|
+
* - `process.on('exit')` 那条兜底此刻也是空操作 —— 句柄早已被摘掉。
|
|
875
632
|
* ⇒ 一个**不响应 SIGTERM** 的 runner(连同它自己 spawn 的 MCP 子进程)活成孤儿。
|
|
876
|
-
* SIGTERM 路径没有这个问题(它不主动退出、等事件循环自然空),两条路径口径必须一致。
|
|
877
633
|
*
|
|
878
634
|
* 上限仍然要有:面板若还有挂着的连接,收尾可能卡住 ⇒ `SHUTDOWN_DEADLINE_MS` 到点硬退;
|
|
879
|
-
* 再按一次 Ctrl+C
|
|
635
|
+
* 再按一次 Ctrl+C 立即硬退(机器的 exit-now)。
|
|
880
636
|
*/
|
|
881
637
|
const shutdownAndExit = (code) => {
|
|
882
|
-
|
|
638
|
+
const r = update(state, { type: 'shutdown' });
|
|
639
|
+
state = r.state;
|
|
640
|
+
if (r.effects.some((e) => e.kind === 'exit-now')) {
|
|
883
641
|
process.exit(code);
|
|
884
642
|
}
|
|
885
|
-
shuttingDown = true;
|
|
886
643
|
const deadline = setTimeout(() => process.exit(code), SHUTDOWN_DEADLINE_MS);
|
|
887
644
|
deadline.unref();
|
|
888
|
-
void
|
|
645
|
+
void runEffects(r.effects).finally(() => {
|
|
889
646
|
clearTimeout(deadline);
|
|
890
647
|
process.exit(code);
|
|
891
648
|
});
|
|
892
649
|
};
|
|
893
650
|
process.on('SIGINT', () => shutdownAndExit(0));
|
|
894
651
|
process.on('SIGTERM', () => shutdownAndExit(0));
|
|
895
|
-
//
|
|
652
|
+
// 最后一道兜底:任何路径走到进程退出都不该留下子进程树(含上面那条硬退路径)——
|
|
653
|
+
// 含还挂着的原生文件夹选择框(同步 kill,与 killTree 同一条纪律)
|
|
896
654
|
process.on('exit', () => {
|
|
897
|
-
|
|
898
|
-
|
|
655
|
+
killActivePickers();
|
|
656
|
+
if (currentProc)
|
|
657
|
+
killTree(currentProc, 'SIGKILL');
|
|
899
658
|
});
|
|
900
659
|
// ---------- 启动 ----------
|
|
901
660
|
/**
|
|
@@ -916,14 +675,15 @@ export function devServer(argv = []) {
|
|
|
916
675
|
session: readSession,
|
|
917
676
|
abort: abortRun,
|
|
918
677
|
clearSession,
|
|
678
|
+
pickFolder,
|
|
679
|
+
cancelPick,
|
|
919
680
|
/**
|
|
920
|
-
* ① 实时右栏:runner 逐笔 POST
|
|
921
|
-
*
|
|
922
|
-
* 父进程在这一层**不做任何解释**(不折回、不缓存、不裁剪):折回是面板的活
|
|
923
|
-
* (它有 `panel-logic.applyTraceEvent` 与单测),而父进程插一手就多一处会漂的口径。
|
|
924
|
-
* 这里只保证「按收到的顺序、同步转发」—— SSE 写入本身是有序的。
|
|
681
|
+
* ① 实时右栏:runner 逐笔 POST 上来的增量记账事件**原样广播**给面板
|
|
682
|
+
* (机器只有一条 emit 效果,刻意零解释 —— 折回是面板的活)。
|
|
925
683
|
*/
|
|
926
|
-
traceEvent: (e) =>
|
|
684
|
+
traceEvent: (e) => {
|
|
685
|
+
void dispatch({ type: 'trace-event', event: e });
|
|
686
|
+
},
|
|
927
687
|
},
|
|
928
688
|
});
|
|
929
689
|
console.log(`Inspector: http://127.0.0.1:${inspector.port}/?t=${token}\n` +
|
|
@@ -938,23 +698,14 @@ export function devServer(argv = []) {
|
|
|
938
698
|
};
|
|
939
699
|
void (async () => {
|
|
940
700
|
await startPanel();
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
944
|
-
catch (e) {
|
|
945
|
-
lastError = e.message;
|
|
946
|
-
console.error(`[agentia] ${lastError}`);
|
|
947
|
-
}
|
|
701
|
+
// 拉起第一代 runner(boot ⇒ spawn-child 效果;失败归 boot-failed:终端 error +
|
|
702
|
+
// 唯一一帧 runner-error —— 见机器里那条迁移的注释)
|
|
703
|
+
await dispatch({ type: 'boot' });
|
|
948
704
|
// 「文件变了 ⇒ 重启」的**唯一**出口:两处 watch 都走它,理由文案因此只有一种写法。
|
|
949
705
|
const onFileChange = (abs) => {
|
|
950
|
-
|
|
951
|
-
if (running) {
|
|
952
|
-
pendingRestart = `文件变更:${rel}`;
|
|
953
|
-
return;
|
|
954
|
-
}
|
|
955
|
-
void restart(`文件变更:${rel}`, child?.toolSources ?? null);
|
|
706
|
+
void dispatch({ type: 'file-changed', rel: relative(projectRoot, abs) });
|
|
956
707
|
};
|
|
957
|
-
// 代码与文本资产都在 `src/` 下(`.md` 必须在允许清单里 —— 见 WATCH_EXT)
|
|
708
|
+
// 代码与文本资产都在 `src/` 下(`.md` 必须在允许清单里 —— 见 dev-watch.ts 的 WATCH_EXT)
|
|
958
709
|
stopWatch = watchTree(join(projectRoot, 'src'), onFileChange);
|
|
959
710
|
// `.env` / `.env.local` 在**项目根**、不在 `src/` 下 ⇒ 单独盯。
|
|
960
711
|
// 少了这一句,「改 .env 会重启」就是一句没人执行的承诺(真跑抓出来的)。
|