@mortiseai/stem 0.0.16 → 0.0.18
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/README.md +57 -0
- package/bin/stem-supervisor-lib.mjs +322 -43
- package/bin/stem.mjs +34 -5
- package/dist/cli.mjs +624 -613
- package/dist/daemon.mjs +366 -357
- package/dist/mcp.mjs +362 -353
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -674,6 +674,63 @@ mstem-storage/ # 用户空间根(STEM_STORAGE_DIR)
|
|
|
674
674
|
|
|
675
675
|
---
|
|
676
676
|
|
|
677
|
+
## Windows 使用说明
|
|
678
|
+
|
|
679
|
+
macOS / Linux 用户可以跳过本节。以下是 Windows(尤其**中文系统 + 独立
|
|
680
|
+
PowerShell / cmd 窗口**,即 conhost 而非 Windows Terminal)上遇到过的问题与开关。
|
|
681
|
+
|
|
682
|
+
### 画面串行错乱(欢迎横幅 / 思考中 / 状态栏挤在同一行互相覆盖)
|
|
683
|
+
|
|
684
|
+
原因是「东亚歧义宽度」字符 —— `● · ▁ │ ─ … → — ↑ ◇` 这些状态栏和横幅的主力符号,
|
|
685
|
+
Unicode 规定"上下文不明时按 1 格算"(xterm / iTerm / Windows Terminal 都照办),
|
|
686
|
+
但**中文 Windows 的 conhost 按控制台字体的全角字形画成 2 格**。渲染器是单元格模型,
|
|
687
|
+
模型算的宽度和终端实际推进的列数一旦对不上,光标就逐列漂移。
|
|
688
|
+
|
|
689
|
+
现在会自动判定(裸 conhost + CJK 代码页 → 按 2 格),启动后还会用 DECXCPR 实测一次
|
|
690
|
+
校准。判定结果记在会话日志的 `[termProfile]` / `[termProfile:probe]` 里。
|
|
691
|
+
|
|
692
|
+
判错了可以手动压:
|
|
693
|
+
|
|
694
|
+
| 变量 | 作用 |
|
|
695
|
+
| --- | --- |
|
|
696
|
+
| `STEM_AMBIGUOUS_WIDTH=wide\|narrow` | 强制歧义宽度按 2 格 / 1 格算,优先级高于自动判定与探针 |
|
|
697
|
+
| `STEM_CODE_PAGE=936` | 覆盖代码页探测(否则跑 `chcp` 取) |
|
|
698
|
+
| `STEM_COLUMNS` / `STEM_ROWS` | 覆盖终端列/行数,见下 |
|
|
699
|
+
|
|
700
|
+
### 画面右侧像被截断 / 窗口底部出现水平滚动条
|
|
701
|
+
|
|
702
|
+
conhost 的**屏幕缓冲区**可以比可见**窗口**宽,而 Node 报告的列数取的是缓冲区宽度
|
|
703
|
+
(libuv 用 `dwSize.X`),于是 UI 会往你看不见的列里画。两种解法:
|
|
704
|
+
|
|
705
|
+
1. PowerShell 窗口 → 右键标题栏 → 属性 → 布局 → 把「屏幕缓冲区大小」的宽度改成与
|
|
706
|
+
「窗口大小」宽度一致(推荐,一劳永逸);
|
|
707
|
+
2. 或用 `STEM_COLUMNS=<实际可见列数>` 压住。
|
|
708
|
+
|
|
709
|
+
> 刻意**不认** `COLUMNS` / `LINES`:Git Bash / MSYS 导出的是启动时的静态值,窗口
|
|
710
|
+
> 放大后不更新,拿它当真相源本身就是个 bug。
|
|
711
|
+
|
|
712
|
+
### 中文变成 `���`
|
|
713
|
+
|
|
714
|
+
工具输出(PowerShell / Bash)和 MCP server 的 stderr 现在都按正确编码处理:能控制的
|
|
715
|
+
子进程强制它输出 UTF-8(PowerShell 注入 `[Console]::OutputEncoding`,cmd 前缀
|
|
716
|
+
`chcp 65001`),控制不了的(MCP server)按 OEM 代码页解码。
|
|
717
|
+
|
|
718
|
+
如果仍有乱码,把 `[termProfile]` 那行里的 `codePage` / `oemEncoding` 一并反馈。
|
|
719
|
+
|
|
720
|
+
### MCP server 起不来
|
|
721
|
+
|
|
722
|
+
Windows 上命令解析失败时**不会**报 ENOENT —— cross-spawn 会把它交给 `cmd.exe`,
|
|
723
|
+
于是你只会看到一句本地化的 "不是内部或外部命令"(还是 OEM 编码的)。现在会在
|
|
724
|
+
spawn 前先按 `PATHEXT` 沿 `PATH` 解析一遍,解析不到直接给 `command not found on
|
|
725
|
+
PATH: "uvx"`。常见修法:装上对应工具,或把 `command` 写成 `npx.cmd` 这样带后缀的形式。
|
|
726
|
+
|
|
727
|
+
### 报障时请带上
|
|
728
|
+
|
|
729
|
+
- `logs/<sessionId>/*.txt`(含 `[termProfile]`,一眼能看出终端画像)
|
|
730
|
+
- 复现时加 `STEM_DEBUG_REPAINTS=1`,日志里会多出全屏重画的归因
|
|
731
|
+
- 画面问题可加 `STEM_TTY_RECORD=<路径>`,把原始终端字节流录下来一并发回 —— 我们能在
|
|
732
|
+
本地虚拟终端里按不同口径回放,把"猜"变成"量"
|
|
733
|
+
|
|
677
734
|
## 技术栈
|
|
678
735
|
|
|
679
736
|
| 维度 | 选择 |
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// supervisor 逻辑库(2026-08-12 OOM 治理)— bin/stem.mjs 的看护实现。
|
|
2
2
|
// 独立成库的唯一理由:入口脚本 import 即执行,smoke 无法直接测;这里的函数
|
|
3
3
|
// 全部纯逻辑/可注入,scripts/smoke-supervisor.ts 逐项锁语义。
|
|
4
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
|
-
import { tmpdir } from 'node:os'
|
|
6
|
+
import { homedir, tmpdir } from 'node:os'
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
8
|
|
|
9
9
|
// 配置类错误的专用退出码(stem-entry-cli 侧同值):见 78 不重启。
|
|
@@ -95,25 +95,30 @@ export function injectRestartPrompt(queuePath, isZh, crashInfo) {
|
|
|
95
95
|
} catch { return false }
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/** 崩溃标记
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
: join(dirname(queuePath), `${sessionId || 'session'}.crash.json`)
|
|
98
|
+
/** 崩溃标记(2026-08-13 事故:崩溃现场零证据,连退出信号都没记)。
|
|
99
|
+
* 落 `logs/<sessionId>/crash.json`(2026-08-18 起与该会话的 supervisor.log /
|
|
100
|
+
* debug 日志 / 堆快照同目录归档;此前散在 queue sidecar 目录难找易丢)。
|
|
101
|
+
* crashes 数组保最近 20 条,下次排查直接看这里而不用翻 supervisor stderr。
|
|
102
|
+
* 写失败静默(标记是诊断增强,不能反过来阻断重启)。 */
|
|
103
|
+
export function writeCrashMarker(markerDir, sessionId, crashInfo, nowMs, extras = {}) {
|
|
104
|
+
if (typeof markerDir !== 'string' || !markerDir) return false
|
|
105
|
+
const markerPath = join(markerDir, 'crash.json')
|
|
107
106
|
let crashes = []
|
|
108
107
|
try {
|
|
109
108
|
const parsed = JSON.parse(readFileSync(markerPath, 'utf8'))
|
|
110
109
|
if (parsed && parsed.v === 1 && Array.isArray(parsed.crashes)) crashes = parsed.crashes
|
|
111
110
|
} catch { /* 无文件/坏文件 → 全新标记 */ }
|
|
112
|
-
|
|
111
|
+
const entry = {
|
|
113
112
|
atMs: nowMs,
|
|
114
113
|
exitCode: crashInfo && typeof crashInfo.code === 'number' ? crashInfo.code : null,
|
|
115
114
|
signal: crashInfo && typeof crashInfo.signal === 'string' ? crashInfo.signal : null,
|
|
116
|
-
}
|
|
115
|
+
}
|
|
116
|
+
// 附加取证字段(2026-08-17 第五次崩溃:原生 SIGABRT 的 stderr 转储全部丢失,
|
|
117
|
+
// 只有 {exitCode, signal} 无法定因)— 全部可选、additive,v 保持 1,旧读方无感。
|
|
118
|
+
for (const [k, v] of Object.entries(extras)) {
|
|
119
|
+
if (v !== undefined && v !== null && v !== '') entry[k] = v
|
|
120
|
+
}
|
|
121
|
+
crashes.push(entry)
|
|
117
122
|
if (crashes.length > 20) crashes = crashes.slice(-20)
|
|
118
123
|
try {
|
|
119
124
|
mkdirSync(dirname(markerPath), { recursive: true })
|
|
@@ -139,17 +144,97 @@ export function stripPermissionModeFlag(argv) {
|
|
|
139
144
|
return out
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
147
|
+
/** 从 argv 剥离 --model(重启时以通告里的实时会话模型为准 — 会话内 /model 切过的
|
|
148
|
+
* 模型不在启动 argv 里,原样透传会把模型打回启动值/llms.json default,2026-08-18
|
|
149
|
+
* 事故实证:/model 切到 gpt-5.6-sol 崩溃后自动续跑那一轮换回了兜底模型)。 */
|
|
150
|
+
export function stripModelFlag(argv) {
|
|
151
|
+
const out = []
|
|
152
|
+
for (let i = 0; i < argv.length; i++) {
|
|
153
|
+
const a = argv[i]
|
|
154
|
+
if (a === '--model') {
|
|
155
|
+
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) i++
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (a.startsWith('--model=')) continue
|
|
159
|
+
out.push(a)
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 崩溃堆快照清理:跨给定目录扫描 Heap.*.heapsnapshot,按 mtime 只保最新
|
|
165
|
+
* keep 份(快照 GB 级,防 24h 连跑占满磁盘)。2026-08-18 起快照按会话归档进
|
|
166
|
+
* logs/<sessionId>/,清理从"cwd 内只留 1 份"改为"cwd + logs/ + 各会话目录
|
|
167
|
+
* 全局保最新 3 份"— 老会话的取证快照不再被下一次崩溃立即冲掉。 */
|
|
168
|
+
export function cleanupHeapSnapshots(dirs, keep = 3) {
|
|
169
|
+
const found = []
|
|
170
|
+
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
|
|
171
|
+
let entries
|
|
172
|
+
try { entries = readdirSync(dir).filter(f => /^Heap\..*\.heapsnapshot$/.test(f)) } catch { continue }
|
|
173
|
+
for (const f of entries) {
|
|
174
|
+
const p = join(dir, f)
|
|
175
|
+
try { found.push({ p, t: statSync(p).mtimeMs }) } catch { found.push({ p, t: 0 }) }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (found.length <= keep) return
|
|
179
|
+
found.sort((a, b) => b.t - a.t)
|
|
180
|
+
for (const { p } of found.slice(keep)) {
|
|
181
|
+
try { rmSync(p, { force: true }) } catch { /* 占用中等失败静默 */ }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 遗留平铺会话日志归位:分会话目录改造(2026-08-18)前,debug 日志平铺在
|
|
186
|
+
* logs/ 顶层(`<YYYY-MM-DD_HH-MM>_<sessionId>.txt`)。按文件名内嵌的会话 id
|
|
187
|
+
* 搬进 logs/<sessionId>/ —— 会话目录必须是该会话日志全集(提 bug 只拷一个
|
|
188
|
+
* 目录)。supervisor.log / 无关文件不动;搬失败(占用中)留待下次。 */
|
|
189
|
+
export function relocateLegacySessionLogs(logsDir) {
|
|
144
190
|
let entries
|
|
145
|
-
try { entries = readdirSync(
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
try {
|
|
191
|
+
try { entries = readdirSync(logsDir, { withFileTypes: true }) } catch { return [] }
|
|
192
|
+
const moved = []
|
|
193
|
+
for (const e of entries) {
|
|
194
|
+
if (!e.isFile()) continue
|
|
195
|
+
const m = /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}_([A-Za-z0-9-]+)\.txt$/.exec(e.name)
|
|
196
|
+
if (!m) continue
|
|
197
|
+
try {
|
|
198
|
+
const dest = join(logsDir, m[1])
|
|
199
|
+
mkdirSync(dest, { recursive: true })
|
|
200
|
+
renameSync(join(logsDir, e.name), join(dest, e.name))
|
|
201
|
+
moved.push(join(dest, e.name))
|
|
202
|
+
} catch { /* 占用中/权限失败,下次启动再收 */ }
|
|
203
|
+
}
|
|
204
|
+
return moved
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 堆快照可能出现的目录:cwd(V8 默认 / --diagnostic-dir 建目录失败回落)、
|
|
208
|
+
* logs/(--diagnostic-dir 落点)、logs/ 下各会话归档目录。 */
|
|
209
|
+
export function heapSnapshotDirs(cwd) {
|
|
210
|
+
const logsDir = join(cwd, 'logs')
|
|
211
|
+
const dirs = [cwd, logsDir]
|
|
212
|
+
try {
|
|
213
|
+
for (const e of readdirSync(logsDir, { withFileTypes: true })) {
|
|
214
|
+
if (e.isDirectory()) dirs.push(join(logsDir, e.name))
|
|
215
|
+
}
|
|
216
|
+
} catch { /* logs/ 不存在 */ }
|
|
217
|
+
return dirs
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 崩溃堆快照按会话归档:把散落在 fromDirs(cwd / logs/ 顶层)的
|
|
221
|
+
* Heap.*.heapsnapshot 搬进 logs/<sessionId>/,与该会话的 crash.json /
|
|
222
|
+
* supervisor.log 放一起。搬失败(占用中/跨设备)留在原地由清理兜底。 */
|
|
223
|
+
export function relocateHeapSnapshots(fromDirs, destDir) {
|
|
224
|
+
const moved = []
|
|
225
|
+
for (const dir of fromDirs) {
|
|
226
|
+
if (dir === destDir) continue
|
|
227
|
+
let entries
|
|
228
|
+
try { entries = readdirSync(dir).filter(f => /^Heap\..*\.heapsnapshot$/.test(f)) } catch { continue }
|
|
229
|
+
for (const f of entries) {
|
|
230
|
+
try {
|
|
231
|
+
mkdirSync(destDir, { recursive: true })
|
|
232
|
+
renameSync(join(dir, f), join(destDir, f))
|
|
233
|
+
moved.push(join(destDir, f))
|
|
234
|
+
} catch { /* 留在原地,cleanupHeapSnapshots 兜底 */ }
|
|
235
|
+
}
|
|
152
236
|
}
|
|
237
|
+
return moved
|
|
153
238
|
}
|
|
154
239
|
|
|
155
240
|
/** 崩溃现场的终端复位:OOM abort 不走 ink 的退出钩子,raw mode / alt screen /
|
|
@@ -161,12 +246,79 @@ export function resetTerminal() {
|
|
|
161
246
|
try { process.stdin.setRawMode?.(false) } catch { /* 非 TTY / 已复位 */ }
|
|
162
247
|
}
|
|
163
248
|
|
|
249
|
+
/** stderr 尾部环形缓冲(2026-08-17 第五次崩溃:V8 fatal error 的原生转储直写
|
|
250
|
+
* fd 2 打上 TUI 后丢失,`FATAL ERROR:` 首行是定因的唯一证据)。Buffer 数组累积,
|
|
251
|
+
* 超限从头丢弃;read() 返回尾部 utf8 文本。 */
|
|
252
|
+
export function createStderrTail(maxBytes = 64 * 1024) {
|
|
253
|
+
const chunks = []
|
|
254
|
+
let total = 0
|
|
255
|
+
return {
|
|
256
|
+
push(chunk) {
|
|
257
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
|
|
258
|
+
chunks.push(buf)
|
|
259
|
+
total += buf.length
|
|
260
|
+
while (total > maxBytes && chunks.length > 1) total -= chunks.shift().length
|
|
261
|
+
},
|
|
262
|
+
read() {
|
|
263
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
264
|
+
return text.length > maxBytes ? text.slice(-maxBytes) : text
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 崩溃时刻之后生成的 macOS 原生崩溃报告(ReportCrash 对 SIGABRT/SIGSEGV 必产出,
|
|
270
|
+
* 含完整原生栈与 abort 原因)。只找不搬:报告数百 KB 且含系统信息。 */
|
|
271
|
+
export function findDiagnosticReport(sinceMs, dir) {
|
|
272
|
+
let entries
|
|
273
|
+
try { entries = readdirSync(dir).filter(f => f.endsWith('.ips')) } catch { return null }
|
|
274
|
+
let best = null
|
|
275
|
+
for (const f of entries) {
|
|
276
|
+
try {
|
|
277
|
+
const t = statSync(join(dir, f)).mtimeMs
|
|
278
|
+
if (t >= sinceMs && (!best || t > best.t)) best = { f, t }
|
|
279
|
+
} catch { /* 读取中/权限失败跳过 */ }
|
|
280
|
+
}
|
|
281
|
+
return best ? join(dir, best.f) : null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 把 .ips 报告路径回填到 crash.json 最新一条崩溃条目(报告由 ReportCrash 异步
|
|
285
|
+
* 生成,writeCrashMarker 时通常还不存在,退避 sleep 后二次回填)。失败静默。 */
|
|
286
|
+
export function attachDiagnosticReport(markerDir, reportPath) {
|
|
287
|
+
if (typeof markerDir !== 'string' || !markerDir || !reportPath) return false
|
|
288
|
+
const markerPath = join(markerDir, 'crash.json')
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(readFileSync(markerPath, 'utf8'))
|
|
291
|
+
if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.crashes) || parsed.crashes.length === 0) return false
|
|
292
|
+
parsed.crashes[parsed.crashes.length - 1].diagnosticReport = reportPath
|
|
293
|
+
writeFileSync(markerPath, JSON.stringify(parsed, null, 2) + '\n')
|
|
294
|
+
return true
|
|
295
|
+
} catch { return false }
|
|
296
|
+
}
|
|
297
|
+
|
|
164
298
|
function defaultSpawnChild(args, env) {
|
|
165
|
-
|
|
299
|
+
// stderr 走 pipe 而非 inherit:原生崩溃转储(V8 fatal / abort)绕过 JS 层拦截
|
|
300
|
+
// 直写 fd 2,inherit 时直接打上 TUI 且父进程零捕获(2026-08-17 事故)。pipe 后
|
|
301
|
+
// 实时透传回终端(行为不变)+ 环形缓冲留尾部作取证。TUI 渲染门只看 stdout/stdin
|
|
302
|
+
// 的 isTTY,stderr 非 TTY 的影响仅限着色(exitCleanup 的 hint 着色已改跟 stdout)。
|
|
303
|
+
const tail = createStderrTail()
|
|
304
|
+
const child = spawn(process.execPath, args, { stdio: ['inherit', 'inherit', 'pipe'], env })
|
|
305
|
+
child.stderr?.on('data', chunk => {
|
|
306
|
+
tail.push(chunk)
|
|
307
|
+
try { process.stderr.write(chunk) } catch { /* 终端已关闭等,透传失败无害 */ }
|
|
308
|
+
})
|
|
166
309
|
return {
|
|
167
310
|
pid: child.pid,
|
|
311
|
+
stderrTail: () => tail.read(),
|
|
168
312
|
exited: new Promise(resolve => {
|
|
169
|
-
|
|
313
|
+
// 'close' 优先:exit 触发时 stderr pipe 可能还有未读完的崩溃转储,close 保证
|
|
314
|
+
// flush 完、stderrTail 不缺尾巴。但 close 会被继承了 stderr fd 的孙进程
|
|
315
|
+
// (MCP server 等)拖住 — 子进程死了管道还开着,close 迟迟不来(实测孙进程
|
|
316
|
+
// 活多久拖多久)→ 崩溃检测被无限延迟。所以 exit 后给 2s flush 宽限兜底,
|
|
317
|
+
// 两者先到先赢(resolve 幂等)。
|
|
318
|
+
child.on('close', (code, signal) => resolve({ code, signal }))
|
|
319
|
+
child.on('exit', (code, signal) => {
|
|
320
|
+
setTimeout(() => resolve({ code, signal }), 2000)
|
|
321
|
+
})
|
|
170
322
|
child.on('error', () => resolve({ code: 1, signal: null }))
|
|
171
323
|
}),
|
|
172
324
|
}
|
|
@@ -188,6 +340,13 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
188
340
|
now = Date.now,
|
|
189
341
|
cwd = process.cwd(),
|
|
190
342
|
announceFile = join(mkdtempSync(join(tmpdir(), 'stem-supervisor-')), 'session.json'),
|
|
343
|
+
// supervisor 自身诊断的持久化落点(2026-08-17 事故:崩溃信息只写终端,滚屏即丢)。
|
|
344
|
+
// 缺省 null = 动态解析:会话已知 → logs/<sessionId>/supervisor.log,
|
|
345
|
+
// 未知(死在首次通告前且无 --resume)→ logs/supervisor.log 顶层兜底。
|
|
346
|
+
// 显式传入(smoke 注入)则固定用该路径。
|
|
347
|
+
supervisorLogPath = null,
|
|
348
|
+
// macOS ReportCrash 报告目录(smoke 可注入假目录)。
|
|
349
|
+
diagnosticReportsDir = join(homedir(), 'Library', 'Logs', 'DiagnosticReports'),
|
|
191
350
|
resetTerminalFn = resetTerminal,
|
|
192
351
|
onCrashCleanupPid = pid => {
|
|
193
352
|
// Windows 下 best-effort 清理子进程树残留(MCP npx/uvx 僵尸);根进程已死时
|
|
@@ -199,12 +358,61 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
199
358
|
installSignalHandlers = true,
|
|
200
359
|
} = options
|
|
201
360
|
|
|
361
|
+
// log() 只写终端;logLine() 同时 append 到 supervisor.log(ISO 时间戳)—
|
|
362
|
+
// 崩溃路径一律用 logLine,子进程日志闸门(STEM_DEBUG)关着时这里仍有据可查。
|
|
363
|
+
// 路径每次写时解析:resumeId 随通告更新,崩溃日志落到崩溃会话自己的目录。
|
|
364
|
+
const supervisorLogFile = () => {
|
|
365
|
+
if (supervisorLogPath) return supervisorLogPath
|
|
366
|
+
return resumeId
|
|
367
|
+
? join(cwd, 'logs', resumeId, 'supervisor.log')
|
|
368
|
+
: join(cwd, 'logs', 'supervisor.log')
|
|
369
|
+
}
|
|
370
|
+
const logFileOnly = text => {
|
|
371
|
+
const logPath = supervisorLogFile()
|
|
372
|
+
try {
|
|
373
|
+
mkdirSync(dirname(logPath), { recursive: true })
|
|
374
|
+
appendFileSync(logPath, `${new Date(now()).toISOString()} ${text}\n`)
|
|
375
|
+
} catch { /* 诊断落盘失败不阻断看护 */ }
|
|
376
|
+
}
|
|
377
|
+
const logLine = text => {
|
|
378
|
+
log(text)
|
|
379
|
+
logFileOnly(text)
|
|
380
|
+
}
|
|
381
|
+
// stem 版本一次性读取(dist/cli.mjs → ../package.json),best-effort。
|
|
382
|
+
let stemVersion = null
|
|
383
|
+
try { stemVersion = JSON.parse(readFileSync(join(dirname(distEntry), '..', 'package.json'), 'utf8')).version ?? null } catch { /* smoke 假路径等 */ }
|
|
384
|
+
|
|
202
385
|
let { baseArgs, resumeId } = stripResumeFlag(argv)
|
|
203
386
|
let crashTimes = []
|
|
204
387
|
let childAlive = false
|
|
205
388
|
// 崩溃前子进程通告的实时 permission mode(Shift+Tab 切换会更新通告)—
|
|
206
389
|
// 重启时经 --permission-mode 复原,argv 中的启动值被剥离让位。
|
|
207
390
|
let permissionModeFromAnnounce = null
|
|
391
|
+
// 崩溃前子进程通告的实时会话模型(--model 启动参数 / 会话内 /model 同一个槽)—
|
|
392
|
+
// null = 子进程从未通告过(死在首次 announce 前)→ 启动 argv 原样透传;
|
|
393
|
+
// '' = 通告了"无覆盖"→ 剥掉启动 argv 里的 --model,回落 llms.json default。
|
|
394
|
+
let modelFromAnnounce = null
|
|
395
|
+
|
|
396
|
+
// 会话生命周期行(2026-08-18 用户要求:健康会话的目录里也要有 supervisor.log,
|
|
397
|
+
// 不能"没崩就空无一物")。会话身份来自通告文件:子进程在每个会话切换点写,
|
|
398
|
+
// supervisor 此前只在崩溃后读 → 这里加轻量轮询(2s,unref)+ 退出时兜底检查,
|
|
399
|
+
// 每个会话(含 /clear 切出的)首次现身即在自己目录写"已启动"行;只写文件
|
|
400
|
+
// 不写终端(TUI 占屏)。同 sid 重复通告/崩溃重启不重复写。
|
|
401
|
+
let announcedSid = null
|
|
402
|
+
let currentPid = null
|
|
403
|
+
const checkAnnounceForLifecycle = () => {
|
|
404
|
+
let parsed = null
|
|
405
|
+
try {
|
|
406
|
+
const p = JSON.parse(readFileSync(announceFile, 'utf8'))
|
|
407
|
+
if (p && p.v === 1 && typeof p.sessionId === 'string') parsed = p
|
|
408
|
+
} catch { return }
|
|
409
|
+
if (!parsed || parsed.sessionId === announcedSid) return
|
|
410
|
+
announcedSid = parsed.sessionId
|
|
411
|
+
resumeId = parsed.sessionId
|
|
412
|
+
logFileOnly(isZh
|
|
413
|
+
? `stem: 会话已启动(pid ${currentPid ?? '-'})`
|
|
414
|
+
: `stem: session started (pid ${currentPid ?? '-'})`)
|
|
415
|
+
}
|
|
208
416
|
|
|
209
417
|
if (installSignalHandlers) {
|
|
210
418
|
// Windows CTRL_C_EVENT 广播父子都收:父进程吞掉,让子进程自己处理(单击中断 /
|
|
@@ -214,10 +422,16 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
214
422
|
}
|
|
215
423
|
|
|
216
424
|
for (;;) {
|
|
217
|
-
|
|
218
|
-
|
|
425
|
+
// --diagnostic-dir=logs/ 的落点必须存在:目录缺失时 V8 写快照直接原生断言
|
|
426
|
+
// 崩溃(实测 node 25)。用户中途删掉 logs/ 也要能扛,每轮 spawn 前补建。
|
|
427
|
+
try { mkdirSync(join(cwd, 'logs'), { recursive: true }) } catch { /* 建不了则回落 cwd 默认 */ }
|
|
428
|
+
relocateLegacySessionLogs(join(cwd, 'logs'))
|
|
429
|
+
cleanupHeapSnapshots(heapSnapshotDirs(cwd))
|
|
430
|
+
let effectiveBase = permissionModeFromAnnounce ? stripPermissionModeFlag(baseArgs) : baseArgs
|
|
431
|
+
if (modelFromAnnounce !== null) effectiveBase = stripModelFlag(effectiveBase)
|
|
219
432
|
const args = [...nodeFlags, distEntry, ...effectiveBase]
|
|
220
433
|
if (permissionModeFromAnnounce) args.push('--permission-mode', permissionModeFromAnnounce)
|
|
434
|
+
if (modelFromAnnounce) args.push('--model', modelFromAnnounce)
|
|
221
435
|
if (resumeId) args.push('--resume', resumeId)
|
|
222
436
|
const child = spawnChild(args, {
|
|
223
437
|
...process.env,
|
|
@@ -225,31 +439,33 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
225
439
|
STEM_SESSION_ANNOUNCE_FILE: announceFile,
|
|
226
440
|
})
|
|
227
441
|
childAlive = true
|
|
442
|
+
currentPid = child.pid ?? null
|
|
443
|
+
const lifecycleTimer = setInterval(checkAnnounceForLifecycle, 2000)
|
|
444
|
+
lifecycleTimer.unref?.()
|
|
228
445
|
const { code, signal } = await child.exited
|
|
446
|
+
clearInterval(lifecycleTimer)
|
|
229
447
|
childAlive = false
|
|
448
|
+
// 兜底:极快退出 / smoke 假子进程 / 轮询没赶上,也不漏启动行。
|
|
449
|
+
checkAnnounceForLifecycle()
|
|
230
450
|
|
|
231
|
-
if (code === 0)
|
|
451
|
+
if (code === 0) {
|
|
452
|
+
logFileOnly(isZh ? 'stem: 会话正常退出(code=0)' : 'stem: session exited normally (code=0)')
|
|
453
|
+
return 0
|
|
454
|
+
}
|
|
232
455
|
if (code === EXIT_CODE_CONFIG) {
|
|
233
|
-
|
|
456
|
+
logLine(isZh
|
|
234
457
|
? 'stem: 配置错误退出(code 78),看护不重启 — 请修正配置后重新运行。'
|
|
235
458
|
: 'stem: exited with a configuration error (code 78); supervisor will not restart. Fix the configuration and run again.')
|
|
236
459
|
return EXIT_CODE_CONFIG
|
|
237
460
|
}
|
|
238
461
|
|
|
239
|
-
//
|
|
462
|
+
// 崩溃路径。先读会话通告拿会话身份 — 所有崩溃落盘(supervisor.log /
|
|
463
|
+
// crash.json / 堆快照)都按 logs/<sessionId>/ 分目录归档,身份必须先到手;
|
|
464
|
+
// 子进程死在通告前 → 沿用现有 resumeId(可能为 null → 落 logs/ 顶层兜底)。
|
|
240
465
|
const t = now()
|
|
241
466
|
crashTimes = crashTimes.filter(x => t - x < CRASH_WINDOW_MS)
|
|
242
467
|
crashTimes.push(t)
|
|
243
468
|
resetTerminalFn()
|
|
244
|
-
if (crashTimes.length >= CRASH_GIVE_UP_COUNT) {
|
|
245
|
-
log(isZh
|
|
246
|
-
? `stem: 60 秒内连续崩溃 ${crashTimes.length} 次(code=${code ?? '-'}, signal=${signal ?? '-'}),停止自动重启。`
|
|
247
|
-
: `stem: crashed ${crashTimes.length} times within 60s (code=${code ?? '-'}, signal=${signal ?? '-'}); giving up on auto-restart.`)
|
|
248
|
-
return typeof code === 'number' && code !== 0 ? code : 1
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// 会话通告(子进程在每个会话切换点写):拿到真实 sessionId 与 sidecar 路径,
|
|
252
|
-
// 修补崩溃截断的 JSONL 尾部 + 注入续跑队列。子进程死在通告前 → 沿用现有 resumeId。
|
|
253
469
|
let announce = null
|
|
254
470
|
try {
|
|
255
471
|
const parsed = JSON.parse(readFileSync(announceFile, 'utf8'))
|
|
@@ -260,24 +476,87 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
260
476
|
if (typeof announce.permissionMode === 'string' && announce.permissionMode) {
|
|
261
477
|
permissionModeFromAnnounce = announce.permissionMode
|
|
262
478
|
}
|
|
479
|
+
// 模型:空串是有效状态(无覆盖),所以只判类型不判真值 — 判真值会让
|
|
480
|
+
// /model default 之后的重启把启动 argv 的旧 --model 又带回来。
|
|
481
|
+
if (typeof announce.model === 'string') modelFromAnnounce = announce.model
|
|
482
|
+
}
|
|
483
|
+
// 本次崩溃的会话归档目录(crash.json / 堆快照 / supervisor.log 共用)。
|
|
484
|
+
const sessionDir = resumeId ? join(cwd, 'logs', resumeId) : null
|
|
485
|
+
// 堆快照按会话归档:V8 落在 --diagnostic-dir(logs/)或 cwd(旧默认 /
|
|
486
|
+
// 建目录失败回落),搬进崩溃会话自己的目录,免得被后续清理当无主快照收走。
|
|
487
|
+
if (sessionDir) relocateHeapSnapshots([cwd, join(cwd, 'logs')], sessionDir)
|
|
488
|
+
|
|
489
|
+
// stderr 尾部取证:pipe 捕获的原生崩溃转储(FATAL ERROR / abort 栈)。
|
|
490
|
+
// crash.json 里截 8KB;supervisor.log 无条件记一份 — 即便子进程死在首次
|
|
491
|
+
// announce 前(crash.json 写不了),这里也有据。
|
|
492
|
+
const stderrTail = typeof child.stderrTail === 'function' ? child.stderrTail() : ''
|
|
493
|
+
const stderrTail8k = stderrTail.length > 8 * 1024 ? stderrTail.slice(-8 * 1024) : stderrTail
|
|
494
|
+
if (stderrTail8k.trim()) {
|
|
495
|
+
logLine(isZh
|
|
496
|
+
? `stem: 崩溃 stderr 尾部(code=${code ?? '-'}, signal=${signal ?? '-'}):\n${stderrTail8k}`
|
|
497
|
+
: `stem: crash stderr tail (code=${code ?? '-'}, signal=${signal ?? '-'}):\n${stderrTail8k}`)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// JSONL 修补 + 崩溃标记在放弃判定之前:第 3 崩(将放弃)恰是最需要取证的
|
|
501
|
+
// 一次;修补后用户手动 --resume 也不会被截断半行拒载(旧实现两者都缺)。
|
|
502
|
+
if (announce) {
|
|
263
503
|
for (const p of Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : []) {
|
|
264
504
|
if (typeof p === 'string' && existsSync(p) && repairJsonlTail(p)) {
|
|
265
|
-
|
|
505
|
+
logLine(isZh ? `stem: 已修补崩溃截断的会话记录 ${p}` : `stem: repaired crash-truncated session log ${p}`)
|
|
266
506
|
break
|
|
267
507
|
}
|
|
268
508
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
509
|
+
// .tasks.json / .taskstate.json 的 mtime = 子进程最后一次任务落盘时刻,
|
|
510
|
+
// 用于回答"崩溃前它空闲了多久"(本次事故:空闲 8.5 分钟零遥测)。
|
|
511
|
+
const sidecarMtime = suffix => {
|
|
512
|
+
if (typeof announce.queuePath !== 'string') return undefined
|
|
513
|
+
try { return statSync(announce.queuePath.replace(/\.queue\.json$/, suffix)).mtimeMs } catch { return undefined }
|
|
272
514
|
}
|
|
515
|
+
writeCrashMarker(sessionDir, announce.sessionId, { code, signal }, t, {
|
|
516
|
+
stderrTail: stderrTail8k,
|
|
517
|
+
args,
|
|
518
|
+
nodeVersion: process.version,
|
|
519
|
+
stemVersion,
|
|
520
|
+
announceUpdatedAtMs: typeof announce.updatedAtMs === 'number' ? announce.updatedAtMs : undefined,
|
|
521
|
+
tasksSnapshotMtimeMs: sidecarMtime('.tasks.json'),
|
|
522
|
+
taskStateMtimeMs: sidecarMtime('.taskstate.json'),
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (crashTimes.length >= CRASH_GIVE_UP_COUNT) {
|
|
527
|
+
logLine(isZh
|
|
528
|
+
? `stem: 60 秒内连续崩溃 ${crashTimes.length} 次(code=${code ?? '-'}, signal=${signal ?? '-'}),停止自动重启。`
|
|
529
|
+
: `stem: crashed ${crashTimes.length} times within 60s (code=${code ?? '-'}, signal=${signal ?? '-'}); giving up on auto-restart.`)
|
|
530
|
+
return typeof code === 'number' && code !== 0 ? code : 1
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 续跑注入只在还会重启时做:放弃场景注入了反而让下次手动 resume 自动跑
|
|
534
|
+
// 一条"已重启"文案,与事实不符。
|
|
535
|
+
if (announce && typeof announce.queuePath === 'string') {
|
|
536
|
+
injectRestartPrompt(announce.queuePath, isZh, { code, signal })
|
|
273
537
|
}
|
|
274
538
|
|
|
275
539
|
onCrashCleanupPid(child.pid)
|
|
276
540
|
|
|
277
541
|
const delayMs = Math.min(1000 * 2 ** (crashTimes.length - 1), RESTART_BACKOFF_MAX_MS)
|
|
278
|
-
|
|
542
|
+
logLine(isZh
|
|
279
543
|
? `stem: 进程异常退出(code=${code ?? '-'}, signal=${signal ?? '-'}),${Math.round(delayMs / 1000)}s 后自动恢复会话${resumeId ? ` ${resumeId}` : ''}…(STEM_NO_SUPERVISOR=1 可关闭看护)`
|
|
280
544
|
: `stem: process crashed (code=${code ?? '-'}, signal=${signal ?? '-'}); resuming session${resumeId ? ` ${resumeId}` : ''} in ${Math.round(delayMs / 1000)}s… (set STEM_NO_SUPERVISOR=1 to disable)`)
|
|
281
545
|
await sleep(delayMs)
|
|
546
|
+
|
|
547
|
+
// macOS 信号死亡 → ReportCrash 异步生成 .ips 报告(实测崩溃后 ~5s 才落盘,
|
|
548
|
+
// 退避窗口等不到):崩溃后 8s 延迟扫描,找到就回填 crash.json 最新条目 +
|
|
549
|
+
// 落 supervisor.log。只写文件不写终端(此时新子进程 TUI 已接管屏幕);
|
|
550
|
+
// unref 不阻退出;只记路径不搬内容。
|
|
551
|
+
if (process.platform === 'darwin' && signal) {
|
|
552
|
+
const markerDirForAttach = announce ? sessionDir : null
|
|
553
|
+
const scanTimer = setTimeout(() => {
|
|
554
|
+
const report = findDiagnosticReport(t, diagnosticReportsDir)
|
|
555
|
+
if (!report) return
|
|
556
|
+
logFileOnly(isZh ? `stem: 原生崩溃报告 ${report}` : `stem: native crash report ${report}`)
|
|
557
|
+
if (markerDirForAttach) attachDiagnosticReport(markerDirForAttach, report)
|
|
558
|
+
}, 8000)
|
|
559
|
+
scanTimer.unref?.()
|
|
560
|
+
}
|
|
282
561
|
}
|
|
283
562
|
}
|
package/bin/stem.mjs
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// 会话重启。正常退出(exit 0)与配置错误(exit 78)不重启;60 秒内连崩 3 次
|
|
10
10
|
// 放弃。STEM_NO_SUPERVISOR=1 退回单次直跑;实现与语义锁定见
|
|
11
11
|
// bin/stem-supervisor-lib.mjs 与 scripts/smoke-supervisor.ts。
|
|
12
|
-
import { existsSync, realpathSync } from 'node:fs'
|
|
12
|
+
import { existsSync, mkdirSync, realpathSync } from 'node:fs'
|
|
13
13
|
import { dirname, join } from 'node:path'
|
|
14
|
-
import { devNull } from 'node:os'
|
|
14
|
+
import { devNull, totalmem } from 'node:os'
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
16
16
|
import { spawnSync } from 'node:child_process'
|
|
17
17
|
|
|
@@ -38,18 +38,47 @@ function heapConfigured() {
|
|
|
38
38
|
)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 堆上限按机器物理内存推导(2026-08-18 第六次事故)。
|
|
43
|
+
*
|
|
44
|
+
* 此前固定 8192 —— 在提交不上 8GB 的机器上 V8 永远够不到自己的上限:
|
|
45
|
+
* near-heap-limit 回调(含 --heapsnapshot-near-heap-limit)不会执行,一切
|
|
46
|
+
* "距上限还有多远"的判断全是空转,进程改由原生分配失败打死,而 V8 视角
|
|
47
|
+
* 直到最后一刻都"还健康"(事故日志:heap 1.5GB / RSS 1.8GB 时静默死亡,
|
|
48
|
+
* 所有守护零触发)。取物理内存 60%,夹在 [2048, 8192];读不到 totalmem
|
|
49
|
+
* 时退回 4096(比 8192 保守,宁可早撞 V8 上限也别让它够不着)。
|
|
50
|
+
*/
|
|
51
|
+
function heapLimitMB() {
|
|
52
|
+
let total = 0
|
|
53
|
+
try { total = totalmem() } catch { total = 0 }
|
|
54
|
+
if (!Number.isFinite(total) || total <= 0) return 4096
|
|
55
|
+
return Math.max(2048, Math.min(8192, Math.floor((total / (1024 * 1024)) * 0.6)))
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
/** 子进程 node flags:heap 上限(未显式配置时)+ OOM 前堆快照(可关)。 */
|
|
42
59
|
function childNodeFlags() {
|
|
43
60
|
const flags = []
|
|
44
|
-
if (!heapConfigured()) flags.push(
|
|
61
|
+
if (!heapConfigured()) flags.push(`--max-old-space-size=${heapLimitMB()}`)
|
|
45
62
|
// OOM 前自动堆快照:定位"未知同步分配风暴"元凶的唯一现实手段(手动
|
|
46
63
|
// /heapdump 在同步爆发下来不及)。代价是崩溃时写数 GB 快照;supervisor
|
|
47
|
-
//
|
|
64
|
+
// 崩溃后按会话归档进 logs/<sessionId>/,每轮 spawn 前跨目录清理保最新
|
|
65
|
+
// 3 份。STEM_HEAP_SNAPSHOT=0 关闭。
|
|
48
66
|
if (
|
|
49
67
|
process.env.STEM_HEAP_SNAPSHOT !== '0' &&
|
|
50
68
|
!(process.env.NODE_OPTIONS ?? '').includes('--heapsnapshot-near-heap-limit')
|
|
51
69
|
) {
|
|
52
70
|
flags.push('--heapsnapshot-near-heap-limit=1')
|
|
71
|
+
// 快照定向到 <cwd>/logs/(V8 默认写 cwd,散落且被误当项目文件);supervisor
|
|
72
|
+
// 崩溃后再按会话归档进 logs/<sessionId>/。注意 --diagnostic-dir 指向的目录
|
|
73
|
+
// 必须已存在 —— 实测(node 25)目录缺失时 V8 写快照直接原生断言崩溃,
|
|
74
|
+
// 所以先建目录,建失败就不加 flag 回落 cwd 默认。
|
|
75
|
+
if (!(process.env.NODE_OPTIONS ?? '').includes('--diagnostic-dir')) {
|
|
76
|
+
try {
|
|
77
|
+
const diagDir = join(process.cwd(), 'logs')
|
|
78
|
+
mkdirSync(diagDir, { recursive: true })
|
|
79
|
+
flags.push(`--diagnostic-dir=${diagDir}`)
|
|
80
|
+
} catch { /* 建目录失败 → 维持 V8 默认(cwd),supervisor 归档兜底 */ }
|
|
81
|
+
}
|
|
53
82
|
}
|
|
54
83
|
return flags
|
|
55
84
|
}
|
|
@@ -78,7 +107,7 @@ if (process.env.STEM_DEV !== '1' && existsSync(distEntry)) {
|
|
|
78
107
|
// 把 --max-old-space-size 带给真正的 CLI 进程。
|
|
79
108
|
const result = spawnSync(
|
|
80
109
|
process.execPath,
|
|
81
|
-
[
|
|
110
|
+
[`--max-old-space-size=${heapLimitMB()}`, distEntry, ...argv],
|
|
82
111
|
{ stdio: 'inherit' },
|
|
83
112
|
)
|
|
84
113
|
process.exit(result.status ?? 1)
|