@mortiseai/stem 0.0.16 → 0.0.17

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.
@@ -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, 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 不重启。
@@ -99,7 +99,7 @@ export function injectRestartPrompt(queuePath, isZh, crashInfo) {
99
99
  * 与 queue sidecar 同目录,`<sessionId>.crash.json`;crashes 数组保最近 20 条,
100
100
  * 下次排查直接看这里而不用翻 supervisor stderr。写失败静默(标记是诊断增强,
101
101
  * 不能反过来阻断重启)。 */
102
- export function writeCrashMarker(queuePath, sessionId, crashInfo, nowMs) {
102
+ export function writeCrashMarker(queuePath, sessionId, crashInfo, nowMs, extras = {}) {
103
103
  if (typeof queuePath !== 'string' || !queuePath) return false
104
104
  const markerPath = queuePath.endsWith('.queue.json')
105
105
  ? queuePath.slice(0, -'.queue.json'.length) + '.crash.json'
@@ -109,11 +109,17 @@ export function writeCrashMarker(queuePath, sessionId, crashInfo, nowMs) {
109
109
  const parsed = JSON.parse(readFileSync(markerPath, 'utf8'))
110
110
  if (parsed && parsed.v === 1 && Array.isArray(parsed.crashes)) crashes = parsed.crashes
111
111
  } catch { /* 无文件/坏文件 → 全新标记 */ }
112
- crashes.push({
112
+ const entry = {
113
113
  atMs: nowMs,
114
114
  exitCode: crashInfo && typeof crashInfo.code === 'number' ? crashInfo.code : null,
115
115
  signal: crashInfo && typeof crashInfo.signal === 'string' ? crashInfo.signal : null,
116
- })
116
+ }
117
+ // 附加取证字段(2026-08-17 第五次崩溃:原生 SIGABRT 的 stderr 转储全部丢失,
118
+ // 只有 {exitCode, signal} 无法定因)— 全部可选、additive,v 保持 1,旧读方无感。
119
+ for (const [k, v] of Object.entries(extras)) {
120
+ if (v !== undefined && v !== null && v !== '') entry[k] = v
121
+ }
122
+ crashes.push(entry)
117
123
  if (crashes.length > 20) crashes = crashes.slice(-20)
118
124
  try {
119
125
  mkdirSync(dirname(markerPath), { recursive: true })
@@ -161,12 +167,81 @@ export function resetTerminal() {
161
167
  try { process.stdin.setRawMode?.(false) } catch { /* 非 TTY / 已复位 */ }
162
168
  }
163
169
 
170
+ /** stderr 尾部环形缓冲(2026-08-17 第五次崩溃:V8 fatal error 的原生转储直写
171
+ * fd 2 打上 TUI 后丢失,`FATAL ERROR:` 首行是定因的唯一证据)。Buffer 数组累积,
172
+ * 超限从头丢弃;read() 返回尾部 utf8 文本。 */
173
+ export function createStderrTail(maxBytes = 64 * 1024) {
174
+ const chunks = []
175
+ let total = 0
176
+ return {
177
+ push(chunk) {
178
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
179
+ chunks.push(buf)
180
+ total += buf.length
181
+ while (total > maxBytes && chunks.length > 1) total -= chunks.shift().length
182
+ },
183
+ read() {
184
+ const text = Buffer.concat(chunks).toString('utf8')
185
+ return text.length > maxBytes ? text.slice(-maxBytes) : text
186
+ },
187
+ }
188
+ }
189
+
190
+ /** 崩溃时刻之后生成的 macOS 原生崩溃报告(ReportCrash 对 SIGABRT/SIGSEGV 必产出,
191
+ * 含完整原生栈与 abort 原因)。只找不搬:报告数百 KB 且含系统信息。 */
192
+ export function findDiagnosticReport(sinceMs, dir) {
193
+ let entries
194
+ try { entries = readdirSync(dir).filter(f => f.endsWith('.ips')) } catch { return null }
195
+ let best = null
196
+ for (const f of entries) {
197
+ try {
198
+ const t = statSync(join(dir, f)).mtimeMs
199
+ if (t >= sinceMs && (!best || t > best.t)) best = { f, t }
200
+ } catch { /* 读取中/权限失败跳过 */ }
201
+ }
202
+ return best ? join(dir, best.f) : null
203
+ }
204
+
205
+ /** 把 .ips 报告路径回填到 crash.json 最新一条崩溃条目(报告由 ReportCrash 异步
206
+ * 生成,writeCrashMarker 时通常还不存在,退避 sleep 后二次回填)。失败静默。 */
207
+ export function attachDiagnosticReport(queuePath, sessionId, reportPath) {
208
+ if (typeof queuePath !== 'string' || !queuePath || !reportPath) return false
209
+ const markerPath = queuePath.endsWith('.queue.json')
210
+ ? queuePath.slice(0, -'.queue.json'.length) + '.crash.json'
211
+ : join(dirname(queuePath), `${sessionId || 'session'}.crash.json`)
212
+ try {
213
+ const parsed = JSON.parse(readFileSync(markerPath, 'utf8'))
214
+ if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.crashes) || parsed.crashes.length === 0) return false
215
+ parsed.crashes[parsed.crashes.length - 1].diagnosticReport = reportPath
216
+ writeFileSync(markerPath, JSON.stringify(parsed, null, 2) + '\n')
217
+ return true
218
+ } catch { return false }
219
+ }
220
+
164
221
  function defaultSpawnChild(args, env) {
165
- const child = spawn(process.execPath, args, { stdio: 'inherit', env })
222
+ // stderr pipe 而非 inherit:原生崩溃转储(V8 fatal / abort)绕过 JS 层拦截
223
+ // 直写 fd 2,inherit 时直接打上 TUI 且父进程零捕获(2026-08-17 事故)。pipe 后
224
+ // 实时透传回终端(行为不变)+ 环形缓冲留尾部作取证。TUI 渲染门只看 stdout/stdin
225
+ // 的 isTTY,stderr 非 TTY 的影响仅限着色(exitCleanup 的 hint 着色已改跟 stdout)。
226
+ const tail = createStderrTail()
227
+ const child = spawn(process.execPath, args, { stdio: ['inherit', 'inherit', 'pipe'], env })
228
+ child.stderr?.on('data', chunk => {
229
+ tail.push(chunk)
230
+ try { process.stderr.write(chunk) } catch { /* 终端已关闭等,透传失败无害 */ }
231
+ })
166
232
  return {
167
233
  pid: child.pid,
234
+ stderrTail: () => tail.read(),
168
235
  exited: new Promise(resolve => {
169
- child.on('exit', (code, signal) => resolve({ code, signal }))
236
+ // 'close' 优先:exit 触发时 stderr pipe 可能还有未读完的崩溃转储,close 保证
237
+ // flush 完、stderrTail 不缺尾巴。但 close 会被继承了 stderr fd 的孙进程
238
+ // (MCP server 等)拖住 — 子进程死了管道还开着,close 迟迟不来(实测孙进程
239
+ // 活多久拖多久)→ 崩溃检测被无限延迟。所以 exit 后给 2s flush 宽限兜底,
240
+ // 两者先到先赢(resolve 幂等)。
241
+ child.on('close', (code, signal) => resolve({ code, signal }))
242
+ child.on('exit', (code, signal) => {
243
+ setTimeout(() => resolve({ code, signal }), 2000)
244
+ })
170
245
  child.on('error', () => resolve({ code: 1, signal: null }))
171
246
  }),
172
247
  }
@@ -188,6 +263,10 @@ export async function runSupervisor(distEntry, argv, options = {}) {
188
263
  now = Date.now,
189
264
  cwd = process.cwd(),
190
265
  announceFile = join(mkdtempSync(join(tmpdir(), 'stem-supervisor-')), 'session.json'),
266
+ // supervisor 自身诊断的持久化落点(2026-08-17 事故:崩溃信息只写终端,滚屏即丢)。
267
+ supervisorLogPath = join(cwd, 'logs', 'supervisor.log'),
268
+ // macOS ReportCrash 报告目录(smoke 可注入假目录)。
269
+ diagnosticReportsDir = join(homedir(), 'Library', 'Logs', 'DiagnosticReports'),
191
270
  resetTerminalFn = resetTerminal,
192
271
  onCrashCleanupPid = pid => {
193
272
  // Windows 下 best-effort 清理子进程树残留(MCP npx/uvx 僵尸);根进程已死时
@@ -199,6 +278,23 @@ export async function runSupervisor(distEntry, argv, options = {}) {
199
278
  installSignalHandlers = true,
200
279
  } = options
201
280
 
281
+ // log() 只写终端;logLine() 同时 append 到 supervisor.log(ISO 时间戳)—
282
+ // 崩溃路径一律用 logLine,子进程日志闸门(STEM_DEBUG)关着时这里仍有据可查。
283
+ const logFileOnly = text => {
284
+ if (!supervisorLogPath) return
285
+ try {
286
+ mkdirSync(dirname(supervisorLogPath), { recursive: true })
287
+ appendFileSync(supervisorLogPath, `${new Date(now()).toISOString()} ${text}\n`)
288
+ } catch { /* 诊断落盘失败不阻断看护 */ }
289
+ }
290
+ const logLine = text => {
291
+ log(text)
292
+ logFileOnly(text)
293
+ }
294
+ // stem 版本一次性读取(dist/cli.mjs → ../package.json),best-effort。
295
+ let stemVersion = null
296
+ try { stemVersion = JSON.parse(readFileSync(join(dirname(distEntry), '..', 'package.json'), 'utf8')).version ?? null } catch { /* smoke 假路径等 */ }
297
+
202
298
  let { baseArgs, resumeId } = stripResumeFlag(argv)
203
299
  let crashTimes = []
204
300
  let childAlive = false
@@ -241,8 +337,18 @@ export async function runSupervisor(distEntry, argv, options = {}) {
241
337
  crashTimes = crashTimes.filter(x => t - x < CRASH_WINDOW_MS)
242
338
  crashTimes.push(t)
243
339
  resetTerminalFn()
340
+ // stderr 尾部取证:pipe 捕获的原生崩溃转储(FATAL ERROR / abort 栈)。
341
+ // crash.json 里截 8KB;supervisor.log 无条件记一份 — 即便子进程死在首次
342
+ // announce 前(crash.json 写不了),这里也有据。
343
+ const stderrTail = typeof child.stderrTail === 'function' ? child.stderrTail() : ''
344
+ const stderrTail8k = stderrTail.length > 8 * 1024 ? stderrTail.slice(-8 * 1024) : stderrTail
345
+ if (stderrTail8k.trim()) {
346
+ logLine(isZh
347
+ ? `stem: 崩溃 stderr 尾部(code=${code ?? '-'}, signal=${signal ?? '-'}):\n${stderrTail8k}`
348
+ : `stem: crash stderr tail (code=${code ?? '-'}, signal=${signal ?? '-'}):\n${stderrTail8k}`)
349
+ }
244
350
  if (crashTimes.length >= CRASH_GIVE_UP_COUNT) {
245
- log(isZh
351
+ logLine(isZh
246
352
  ? `stem: 60 秒内连续崩溃 ${crashTimes.length} 次(code=${code ?? '-'}, signal=${signal ?? '-'}),停止自动重启。`
247
353
  : `stem: crashed ${crashTimes.length} times within 60s (code=${code ?? '-'}, signal=${signal ?? '-'}); giving up on auto-restart.`)
248
354
  return typeof code === 'number' && code !== 0 ? code : 1
@@ -262,12 +368,25 @@ export async function runSupervisor(distEntry, argv, options = {}) {
262
368
  }
263
369
  for (const p of Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : []) {
264
370
  if (typeof p === 'string' && existsSync(p) && repairJsonlTail(p)) {
265
- log(isZh ? `stem: 已修补崩溃截断的会话记录 ${p}` : `stem: repaired crash-truncated session log ${p}`)
371
+ logLine(isZh ? `stem: 已修补崩溃截断的会话记录 ${p}` : `stem: repaired crash-truncated session log ${p}`)
266
372
  break
267
373
  }
268
374
  }
269
375
  if (typeof announce.queuePath === 'string') {
270
- writeCrashMarker(announce.queuePath, announce.sessionId, { code, signal }, t)
376
+ // .tasks.json / .taskstate.json mtime = 子进程最后一次任务落盘时刻,
377
+ // 用于回答"崩溃前它空闲了多久"(本次事故:空闲 8.5 分钟零遥测)。
378
+ const sidecarMtime = suffix => {
379
+ try { return statSync(announce.queuePath.replace(/\.queue\.json$/, suffix)).mtimeMs } catch { return undefined }
380
+ }
381
+ writeCrashMarker(announce.queuePath, announce.sessionId, { code, signal }, t, {
382
+ stderrTail: stderrTail8k,
383
+ args,
384
+ nodeVersion: process.version,
385
+ stemVersion,
386
+ announceUpdatedAtMs: typeof announce.updatedAtMs === 'number' ? announce.updatedAtMs : undefined,
387
+ tasksSnapshotMtimeMs: sidecarMtime('.tasks.json'),
388
+ taskStateMtimeMs: sidecarMtime('.taskstate.json'),
389
+ })
271
390
  injectRestartPrompt(announce.queuePath, isZh, { code, signal })
272
391
  }
273
392
  }
@@ -275,9 +394,25 @@ export async function runSupervisor(distEntry, argv, options = {}) {
275
394
  onCrashCleanupPid(child.pid)
276
395
 
277
396
  const delayMs = Math.min(1000 * 2 ** (crashTimes.length - 1), RESTART_BACKOFF_MAX_MS)
278
- log(isZh
397
+ logLine(isZh
279
398
  ? `stem: 进程异常退出(code=${code ?? '-'}, signal=${signal ?? '-'}),${Math.round(delayMs / 1000)}s 后自动恢复会话${resumeId ? ` ${resumeId}` : ''}…(STEM_NO_SUPERVISOR=1 可关闭看护)`
280
399
  : `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
400
  await sleep(delayMs)
401
+
402
+ // macOS 信号死亡 → ReportCrash 异步生成 .ips 报告(实测崩溃后 ~5s 才落盘,
403
+ // 退避窗口等不到):崩溃后 8s 延迟扫描,找到就回填 crash.json 最新条目 +
404
+ // 落 supervisor.log。只写文件不写终端(此时新子进程 TUI 已接管屏幕);
405
+ // unref 不阻退出;只记路径不搬内容。
406
+ if (process.platform === 'darwin' && signal) {
407
+ const queuePathForAttach = announce && typeof announce.queuePath === 'string' ? announce.queuePath : null
408
+ const sessionForAttach = announce ? announce.sessionId : null
409
+ const scanTimer = setTimeout(() => {
410
+ const report = findDiagnosticReport(t, diagnosticReportsDir)
411
+ if (!report) return
412
+ logFileOnly(isZh ? `stem: 原生崩溃报告 ${report}` : `stem: native crash report ${report}`)
413
+ if (queuePathForAttach) attachDiagnosticReport(queuePathForAttach, sessionForAttach, report)
414
+ }, 8000)
415
+ scanTimer.unref?.()
416
+ }
282
417
  }
283
418
  }