@mortiseai/stem 0.0.15 → 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.
- package/bin/stem-supervisor-lib.mjs +222 -16
- package/dist/cli.mjs +605 -595
- package/dist/daemon.mjs +365 -354
- package/dist/mcp.mjs +361 -350
- package/package.json +1 -1
|
@@ -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 不重启。
|
|
@@ -48,25 +48,41 @@ export function repairJsonlTail(path) {
|
|
|
48
48
|
} catch { return false }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** 崩溃原因短描述:supervisor 只有退出码/信号这一手证据,文案如实转述,
|
|
52
|
+
* 不再硬编码"疑似内存溢出"(2026-08-13 事故:heap 仅 850MB 时死亡,OOM 猜测
|
|
53
|
+
* 误导了定性;heapsnapshot 也因 8GB 阈值够不着而缺失)。 */
|
|
54
|
+
export function crashCauseText(crashInfo, isZh) {
|
|
55
|
+
const signal = crashInfo && typeof crashInfo.signal === 'string' && crashInfo.signal ? crashInfo.signal : null
|
|
56
|
+
const code = crashInfo && typeof crashInfo.code === 'number' ? crashInfo.code : null
|
|
57
|
+
if (signal) return isZh ? `信号 ${signal}` : `signal ${signal}`
|
|
58
|
+
if (code !== null) return isZh ? `退出码 ${code}` : `exit code ${code}`
|
|
59
|
+
return isZh ? '原因未知' : 'unknown cause'
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
/** 自包含续跑 prompt:恢复的 transcript 无工具轨迹,"继续"两个字会让模型空转
|
|
52
63
|
* (2026-08-07 实测)— 必须指回 todo/计划文档重建进度认知。 */
|
|
53
|
-
export function restartPrompt(isZh) {
|
|
64
|
+
export function restartPrompt(isZh, crashInfo) {
|
|
65
|
+
const cause = crashCauseText(crashInfo, isZh)
|
|
54
66
|
return isZh
|
|
55
|
-
?
|
|
56
|
-
:
|
|
67
|
+
? `[系统] 进程异常退出(${cause})已自动重启,本会话对话已从磁盘恢复,但工具执行轨迹与中间状态未保留。请先读取当前 todo 列表与相关任务/计划文档核对实际进度(必要时用命令验证已完成的部分),然后从未完成项继续执行;不要重复已完成的工作,也不要只回复确认性文字。`
|
|
68
|
+
: `[system] The process exited abnormally (${cause}) and was restarted automatically. The conversation was restored from disk, but tool execution traces and intermediate state were lost. First read the current todo list and any task/plan documents to verify actual progress (validate completed parts with commands if needed), then continue from the unfinished items. Do not redo completed work or reply with acknowledgements only.`
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
// 注入队列的去重前缀:文案带崩溃原因(逐次可变),不能再按全串相等去重。
|
|
72
|
+
const RESTART_PROMPT_PREFIXES = ['[系统] 进程异常退出', '[系统] 进程因异常', '[system] The process']
|
|
73
|
+
|
|
59
74
|
/** 注入续跑 prompt 到队列 sidecar(userPromptQueueStore 格式;resume 分支自动
|
|
60
75
|
* 再水合、经 idle-gate 依次执行)。cleanExit: true 让 resume 端静默续跑而非弹
|
|
61
76
|
* y/n 确认 — 无人值守是看护的前提;连崩风暴由退避与 3 次放弃兜底。既有排队
|
|
62
|
-
* prompt 保留在后(
|
|
63
|
-
export function injectRestartPrompt(queuePath, isZh) {
|
|
64
|
-
const prompt = restartPrompt(isZh)
|
|
77
|
+
* prompt 保留在后(按前缀去重防多次崩溃堆叠)。 */
|
|
78
|
+
export function injectRestartPrompt(queuePath, isZh, crashInfo) {
|
|
79
|
+
const prompt = restartPrompt(isZh, crashInfo)
|
|
65
80
|
let existing = []
|
|
66
81
|
try {
|
|
67
82
|
const parsed = JSON.parse(readFileSync(queuePath, 'utf8'))
|
|
68
83
|
if (parsed && parsed.v === 1 && Array.isArray(parsed.queue)) {
|
|
69
|
-
existing = parsed.queue.filter(x =>
|
|
84
|
+
existing = parsed.queue.filter(x =>
|
|
85
|
+
typeof x === 'string' && !RESTART_PROMPT_PREFIXES.some(p => x.startsWith(p)))
|
|
70
86
|
}
|
|
71
87
|
} catch { /* 无文件/坏文件 → 全新队列 */ }
|
|
72
88
|
try {
|
|
@@ -79,6 +95,56 @@ export function injectRestartPrompt(queuePath, isZh) {
|
|
|
79
95
|
} catch { return false }
|
|
80
96
|
}
|
|
81
97
|
|
|
98
|
+
/** 崩溃标记 sidecar(2026-08-13 事故:崩溃现场零证据,连退出信号都没记)。
|
|
99
|
+
* 与 queue sidecar 同目录,`<sessionId>.crash.json`;crashes 数组保最近 20 条,
|
|
100
|
+
* 下次排查直接看这里而不用翻 supervisor stderr。写失败静默(标记是诊断增强,
|
|
101
|
+
* 不能反过来阻断重启)。 */
|
|
102
|
+
export function writeCrashMarker(queuePath, sessionId, crashInfo, nowMs, extras = {}) {
|
|
103
|
+
if (typeof queuePath !== 'string' || !queuePath) return false
|
|
104
|
+
const markerPath = queuePath.endsWith('.queue.json')
|
|
105
|
+
? queuePath.slice(0, -'.queue.json'.length) + '.crash.json'
|
|
106
|
+
: join(dirname(queuePath), `${sessionId || 'session'}.crash.json`)
|
|
107
|
+
let crashes = []
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(readFileSync(markerPath, 'utf8'))
|
|
110
|
+
if (parsed && parsed.v === 1 && Array.isArray(parsed.crashes)) crashes = parsed.crashes
|
|
111
|
+
} catch { /* 无文件/坏文件 → 全新标记 */ }
|
|
112
|
+
const entry = {
|
|
113
|
+
atMs: nowMs,
|
|
114
|
+
exitCode: crashInfo && typeof crashInfo.code === 'number' ? crashInfo.code : null,
|
|
115
|
+
signal: crashInfo && typeof crashInfo.signal === 'string' ? crashInfo.signal : null,
|
|
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)
|
|
123
|
+
if (crashes.length > 20) crashes = crashes.slice(-20)
|
|
124
|
+
try {
|
|
125
|
+
mkdirSync(dirname(markerPath), { recursive: true })
|
|
126
|
+
writeFileSync(markerPath, JSON.stringify({ v: 1, sessionId: sessionId || null, crashes }, null, 2) + '\n')
|
|
127
|
+
return true
|
|
128
|
+
} catch { return false }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 从 argv 剥离 --permission-mode(重启时以通告里的实时模式为准 — 用户 Shift+Tab
|
|
132
|
+
* 切过的模式不在启动 argv 里,原样透传会把模式打回启动值,2026-08-13 事故实证
|
|
133
|
+
* auto 崩后回落 default,自动续跑第一轮在降级权限下弹卡)。 */
|
|
134
|
+
export function stripPermissionModeFlag(argv) {
|
|
135
|
+
const out = []
|
|
136
|
+
for (let i = 0; i < argv.length; i++) {
|
|
137
|
+
const a = argv[i]
|
|
138
|
+
if (a === '--permission-mode') {
|
|
139
|
+
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) i++
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (a.startsWith('--permission-mode=')) continue
|
|
143
|
+
out.push(a)
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
82
148
|
/** 崩溃堆快照清理:只保留最新一份 Heap.*.heapsnapshot(防 24h 连跑占满磁盘)。 */
|
|
83
149
|
export function cleanupHeapSnapshots(dir) {
|
|
84
150
|
let entries
|
|
@@ -101,12 +167,81 @@ export function resetTerminal() {
|
|
|
101
167
|
try { process.stdin.setRawMode?.(false) } catch { /* 非 TTY / 已复位 */ }
|
|
102
168
|
}
|
|
103
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
|
+
|
|
104
221
|
function defaultSpawnChild(args, env) {
|
|
105
|
-
|
|
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
|
+
})
|
|
106
232
|
return {
|
|
107
233
|
pid: child.pid,
|
|
234
|
+
stderrTail: () => tail.read(),
|
|
108
235
|
exited: new Promise(resolve => {
|
|
109
|
-
|
|
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
|
+
})
|
|
110
245
|
child.on('error', () => resolve({ code: 1, signal: null }))
|
|
111
246
|
}),
|
|
112
247
|
}
|
|
@@ -128,6 +263,10 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
128
263
|
now = Date.now,
|
|
129
264
|
cwd = process.cwd(),
|
|
130
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'),
|
|
131
270
|
resetTerminalFn = resetTerminal,
|
|
132
271
|
onCrashCleanupPid = pid => {
|
|
133
272
|
// Windows 下 best-effort 清理子进程树残留(MCP npx/uvx 僵尸);根进程已死时
|
|
@@ -139,9 +278,29 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
139
278
|
installSignalHandlers = true,
|
|
140
279
|
} = options
|
|
141
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
|
+
|
|
142
298
|
let { baseArgs, resumeId } = stripResumeFlag(argv)
|
|
143
299
|
let crashTimes = []
|
|
144
300
|
let childAlive = false
|
|
301
|
+
// 崩溃前子进程通告的实时 permission mode(Shift+Tab 切换会更新通告)—
|
|
302
|
+
// 重启时经 --permission-mode 复原,argv 中的启动值被剥离让位。
|
|
303
|
+
let permissionModeFromAnnounce = null
|
|
145
304
|
|
|
146
305
|
if (installSignalHandlers) {
|
|
147
306
|
// Windows CTRL_C_EVENT 广播父子都收:父进程吞掉,让子进程自己处理(单击中断 /
|
|
@@ -152,7 +311,9 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
152
311
|
|
|
153
312
|
for (;;) {
|
|
154
313
|
cleanupHeapSnapshots(cwd)
|
|
155
|
-
const
|
|
314
|
+
const effectiveBase = permissionModeFromAnnounce ? stripPermissionModeFlag(baseArgs) : baseArgs
|
|
315
|
+
const args = [...nodeFlags, distEntry, ...effectiveBase]
|
|
316
|
+
if (permissionModeFromAnnounce) args.push('--permission-mode', permissionModeFromAnnounce)
|
|
156
317
|
if (resumeId) args.push('--resume', resumeId)
|
|
157
318
|
const child = spawnChild(args, {
|
|
158
319
|
...process.env,
|
|
@@ -176,8 +337,18 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
176
337
|
crashTimes = crashTimes.filter(x => t - x < CRASH_WINDOW_MS)
|
|
177
338
|
crashTimes.push(t)
|
|
178
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
|
+
}
|
|
179
350
|
if (crashTimes.length >= CRASH_GIVE_UP_COUNT) {
|
|
180
|
-
|
|
351
|
+
logLine(isZh
|
|
181
352
|
? `stem: 60 秒内连续崩溃 ${crashTimes.length} 次(code=${code ?? '-'}, signal=${signal ?? '-'}),停止自动重启。`
|
|
182
353
|
: `stem: crashed ${crashTimes.length} times within 60s (code=${code ?? '-'}, signal=${signal ?? '-'}); giving up on auto-restart.`)
|
|
183
354
|
return typeof code === 'number' && code !== 0 ? code : 1
|
|
@@ -192,21 +363,56 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
192
363
|
} catch { /* ignore */ }
|
|
193
364
|
if (announce) {
|
|
194
365
|
resumeId = announce.sessionId
|
|
366
|
+
if (typeof announce.permissionMode === 'string' && announce.permissionMode) {
|
|
367
|
+
permissionModeFromAnnounce = announce.permissionMode
|
|
368
|
+
}
|
|
195
369
|
for (const p of Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : []) {
|
|
196
370
|
if (typeof p === 'string' && existsSync(p) && repairJsonlTail(p)) {
|
|
197
|
-
|
|
371
|
+
logLine(isZh ? `stem: 已修补崩溃截断的会话记录 ${p}` : `stem: repaired crash-truncated session log ${p}`)
|
|
198
372
|
break
|
|
199
373
|
}
|
|
200
374
|
}
|
|
201
|
-
if (typeof announce.queuePath === 'string')
|
|
375
|
+
if (typeof announce.queuePath === 'string') {
|
|
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
|
+
})
|
|
390
|
+
injectRestartPrompt(announce.queuePath, isZh, { code, signal })
|
|
391
|
+
}
|
|
202
392
|
}
|
|
203
393
|
|
|
204
394
|
onCrashCleanupPid(child.pid)
|
|
205
395
|
|
|
206
396
|
const delayMs = Math.min(1000 * 2 ** (crashTimes.length - 1), RESTART_BACKOFF_MAX_MS)
|
|
207
|
-
|
|
397
|
+
logLine(isZh
|
|
208
398
|
? `stem: 进程异常退出(code=${code ?? '-'}, signal=${signal ?? '-'}),${Math.round(delayMs / 1000)}s 后自动恢复会话${resumeId ? ` ${resumeId}` : ''}…(STEM_NO_SUPERVISOR=1 可关闭看护)`
|
|
209
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)`)
|
|
210
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
|
+
}
|
|
211
417
|
}
|
|
212
418
|
}
|