@mortiseai/stem 0.0.12 → 0.0.14
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 +212 -0
- package/bin/stem.mjs +58 -15
- package/dist/cli.mjs +618 -601
- package/dist/daemon.mjs +379 -369
- package/dist/mcp.mjs +376 -366
- package/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// supervisor 逻辑库(2026-08-12 OOM 治理)— bin/stem.mjs 的看护实现。
|
|
2
|
+
// 独立成库的唯一理由:入口脚本 import 即执行,smoke 无法直接测;这里的函数
|
|
3
|
+
// 全部纯逻辑/可注入,scripts/smoke-supervisor.ts 逐项锁语义。
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
// 配置类错误的专用退出码(stem-entry-cli 侧同值):见 78 不重启。
|
|
10
|
+
export const EXIT_CODE_CONFIG = 78
|
|
11
|
+
export const CRASH_WINDOW_MS = 60_000
|
|
12
|
+
export const CRASH_GIVE_UP_COUNT = 3
|
|
13
|
+
export const RESTART_BACKOFF_MAX_MS = 10_000
|
|
14
|
+
|
|
15
|
+
/** 从 argv 剥离 --resume/-r(supervisor 重启时按通告会话重挂)。 */
|
|
16
|
+
export function stripResumeFlag(argv) {
|
|
17
|
+
const out = []
|
|
18
|
+
let resumeId = null
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i]
|
|
21
|
+
if (a === '--resume' || a === '-r') {
|
|
22
|
+
const next = argv[i + 1]
|
|
23
|
+
if (next !== undefined && !next.startsWith('-')) { resumeId = next; i++ }
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (a.startsWith('--resume=')) { resumeId = a.slice('--resume='.length); continue }
|
|
27
|
+
out.push(a)
|
|
28
|
+
}
|
|
29
|
+
return { baseArgs: out, resumeId }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 崩溃截断的 JSONL 尾部修补:从尾剔除解析失败的行,直到最后一行是合法 JSON。
|
|
33
|
+
* 返回 true = 有修补并已重写。 */
|
|
34
|
+
export function repairJsonlTail(path) {
|
|
35
|
+
let raw
|
|
36
|
+
try { raw = readFileSync(path, 'utf8') } catch { return false }
|
|
37
|
+
if (!raw) return false
|
|
38
|
+
const lines = raw.split('\n')
|
|
39
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
40
|
+
let dropped = 0
|
|
41
|
+
while (lines.length > 0) {
|
|
42
|
+
try { JSON.parse(lines[lines.length - 1]); break } catch { lines.pop(); dropped++ }
|
|
43
|
+
}
|
|
44
|
+
if (dropped === 0) return false
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(path, lines.length > 0 ? lines.join('\n') + '\n' : '')
|
|
47
|
+
return true
|
|
48
|
+
} catch { return false }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 自包含续跑 prompt:恢复的 transcript 无工具轨迹,"继续"两个字会让模型空转
|
|
52
|
+
* (2026-08-07 实测)— 必须指回 todo/计划文档重建进度认知。 */
|
|
53
|
+
export function restartPrompt(isZh) {
|
|
54
|
+
return isZh
|
|
55
|
+
? '[系统] 进程因异常(疑似内存溢出)已自动重启,本会话对话已从磁盘恢复,但工具执行轨迹与中间状态未保留。请先读取当前 todo 列表与相关任务/计划文档核对实际进度(必要时用命令验证已完成的部分),然后从未完成项继续执行;不要重复已完成的工作,也不要只回复确认性文字。'
|
|
56
|
+
: '[system] The process crashed (likely OOM) 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
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 注入续跑 prompt 到队列 sidecar(userPromptQueueStore 格式;resume 分支自动
|
|
60
|
+
* 再水合、经 idle-gate 依次执行)。cleanExit: true 让 resume 端静默续跑而非弹
|
|
61
|
+
* y/n 确认 — 无人值守是看护的前提;连崩风暴由退避与 3 次放弃兜底。既有排队
|
|
62
|
+
* prompt 保留在后(去重防多次崩溃堆叠)。 */
|
|
63
|
+
export function injectRestartPrompt(queuePath, isZh) {
|
|
64
|
+
const prompt = restartPrompt(isZh)
|
|
65
|
+
let existing = []
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readFileSync(queuePath, 'utf8'))
|
|
68
|
+
if (parsed && parsed.v === 1 && Array.isArray(parsed.queue)) {
|
|
69
|
+
existing = parsed.queue.filter(x => typeof x === 'string' && x !== prompt)
|
|
70
|
+
}
|
|
71
|
+
} catch { /* 无文件/坏文件 → 全新队列 */ }
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dirname(queuePath), { recursive: true })
|
|
74
|
+
writeFileSync(
|
|
75
|
+
queuePath,
|
|
76
|
+
JSON.stringify({ v: 1, savedAtMs: Date.now(), queue: [prompt, ...existing], cleanExit: true }, null, 2) + '\n',
|
|
77
|
+
)
|
|
78
|
+
return true
|
|
79
|
+
} catch { return false }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 崩溃堆快照清理:只保留最新一份 Heap.*.heapsnapshot(防 24h 连跑占满磁盘)。 */
|
|
83
|
+
export function cleanupHeapSnapshots(dir) {
|
|
84
|
+
let entries
|
|
85
|
+
try { entries = readdirSync(dir).filter(f => /^Heap\..*\.heapsnapshot$/.test(f)) } catch { return }
|
|
86
|
+
if (entries.length <= 1) return
|
|
87
|
+
const withTime = entries.map(f => {
|
|
88
|
+
try { return { f, t: statSync(join(dir, f)).mtimeMs } } catch { return { f, t: 0 } }
|
|
89
|
+
}).sort((a, b) => b.t - a.t)
|
|
90
|
+
for (const { f } of withTime.slice(1)) {
|
|
91
|
+
try { rmSync(join(dir, f), { force: true }) } catch { /* 占用中等失败静默 */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 崩溃现场的终端复位:OOM abort 不走 ink 的退出钩子,raw mode / alt screen /
|
|
96
|
+
* mouse tracking 可能全部残留 — 下一个子进程起来前主动复位。 */
|
|
97
|
+
export function resetTerminal() {
|
|
98
|
+
try {
|
|
99
|
+
process.stdout.write('\x1b[?1049l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h')
|
|
100
|
+
} catch { /* 输出失败无害 */ }
|
|
101
|
+
try { process.stdin.setRawMode?.(false) } catch { /* 非 TTY / 已复位 */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function defaultSpawnChild(args, env) {
|
|
105
|
+
const child = spawn(process.execPath, args, { stdio: 'inherit', env })
|
|
106
|
+
return {
|
|
107
|
+
pid: child.pid,
|
|
108
|
+
exited: new Promise(resolve => {
|
|
109
|
+
child.on('exit', (code, signal) => resolve({ code, signal }))
|
|
110
|
+
child.on('error', () => resolve({ code: 1, signal: null }))
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 看护主循环。overrides 供 smoke 注入:
|
|
117
|
+
* spawnChild(args, env) → {pid, exited: Promise<{code, signal}>}
|
|
118
|
+
* sleep(ms) / log(text) / now() / cwd
|
|
119
|
+
* 返回最终退出码。
|
|
120
|
+
*/
|
|
121
|
+
export async function runSupervisor(distEntry, argv, options = {}) {
|
|
122
|
+
const {
|
|
123
|
+
isZh = true,
|
|
124
|
+
nodeFlags = [],
|
|
125
|
+
spawnChild = defaultSpawnChild,
|
|
126
|
+
sleep = ms => new Promise(r => setTimeout(r, ms)),
|
|
127
|
+
log = text => { try { process.stderr.write(`\x1b[2;38;2;156;163;175m${text}\x1b[0m\n`) } catch { /* ignore */ } },
|
|
128
|
+
now = Date.now,
|
|
129
|
+
cwd = process.cwd(),
|
|
130
|
+
announceFile = join(mkdtempSync(join(tmpdir(), 'stem-supervisor-')), 'session.json'),
|
|
131
|
+
resetTerminalFn = resetTerminal,
|
|
132
|
+
onCrashCleanupPid = pid => {
|
|
133
|
+
// Windows 下 best-effort 清理子进程树残留(MCP npx/uvx 僵尸);根进程已死时
|
|
134
|
+
// taskkill 可能报错,静默即可。
|
|
135
|
+
if (process.platform === 'win32' && pid) {
|
|
136
|
+
try { spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) } catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
installSignalHandlers = true,
|
|
140
|
+
} = options
|
|
141
|
+
|
|
142
|
+
let { baseArgs, resumeId } = stripResumeFlag(argv)
|
|
143
|
+
let crashTimes = []
|
|
144
|
+
let childAlive = false
|
|
145
|
+
|
|
146
|
+
if (installSignalHandlers) {
|
|
147
|
+
// Windows CTRL_C_EVENT 广播父子都收:父进程吞掉,让子进程自己处理(单击中断 /
|
|
148
|
+
// 双击退出 → exit 0 → 看护正常收口)。子进程不存活时(退避等待期)按用户退出处理。
|
|
149
|
+
process.on('SIGINT', () => { if (!childAlive) process.exit(130) })
|
|
150
|
+
process.on('SIGTERM', () => process.exit(143))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (;;) {
|
|
154
|
+
cleanupHeapSnapshots(cwd)
|
|
155
|
+
const args = [...nodeFlags, distEntry, ...baseArgs]
|
|
156
|
+
if (resumeId) args.push('--resume', resumeId)
|
|
157
|
+
const child = spawnChild(args, {
|
|
158
|
+
...process.env,
|
|
159
|
+
STEM_SUPERVISED: '1',
|
|
160
|
+
STEM_SESSION_ANNOUNCE_FILE: announceFile,
|
|
161
|
+
})
|
|
162
|
+
childAlive = true
|
|
163
|
+
const { code, signal } = await child.exited
|
|
164
|
+
childAlive = false
|
|
165
|
+
|
|
166
|
+
if (code === 0) return 0
|
|
167
|
+
if (code === EXIT_CODE_CONFIG) {
|
|
168
|
+
log(isZh
|
|
169
|
+
? 'stem: 配置错误退出(code 78),看护不重启 — 请修正配置后重新运行。'
|
|
170
|
+
: 'stem: exited with a configuration error (code 78); supervisor will not restart. Fix the configuration and run again.')
|
|
171
|
+
return EXIT_CODE_CONFIG
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 崩溃路径。60 秒窗口内连崩 3 次 → 放弃(配置外的持续性故障,重启无益)。
|
|
175
|
+
const t = now()
|
|
176
|
+
crashTimes = crashTimes.filter(x => t - x < CRASH_WINDOW_MS)
|
|
177
|
+
crashTimes.push(t)
|
|
178
|
+
resetTerminalFn()
|
|
179
|
+
if (crashTimes.length >= CRASH_GIVE_UP_COUNT) {
|
|
180
|
+
log(isZh
|
|
181
|
+
? `stem: 60 秒内连续崩溃 ${crashTimes.length} 次(code=${code ?? '-'}, signal=${signal ?? '-'}),停止自动重启。`
|
|
182
|
+
: `stem: crashed ${crashTimes.length} times within 60s (code=${code ?? '-'}, signal=${signal ?? '-'}); giving up on auto-restart.`)
|
|
183
|
+
return typeof code === 'number' && code !== 0 ? code : 1
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 会话通告(子进程在每个会话切换点写):拿到真实 sessionId 与 sidecar 路径,
|
|
187
|
+
// 修补崩溃截断的 JSONL 尾部 + 注入续跑队列。子进程死在通告前 → 沿用现有 resumeId。
|
|
188
|
+
let announce = null
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(readFileSync(announceFile, 'utf8'))
|
|
191
|
+
if (parsed && parsed.v === 1 && typeof parsed.sessionId === 'string') announce = parsed
|
|
192
|
+
} catch { /* ignore */ }
|
|
193
|
+
if (announce) {
|
|
194
|
+
resumeId = announce.sessionId
|
|
195
|
+
for (const p of Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : []) {
|
|
196
|
+
if (typeof p === 'string' && existsSync(p) && repairJsonlTail(p)) {
|
|
197
|
+
log(isZh ? `stem: 已修补崩溃截断的会话记录 ${p}` : `stem: repaired crash-truncated session log ${p}`)
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (typeof announce.queuePath === 'string') injectRestartPrompt(announce.queuePath, isZh)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
onCrashCleanupPid(child.pid)
|
|
205
|
+
|
|
206
|
+
const delayMs = Math.min(1000 * 2 ** (crashTimes.length - 1), RESTART_BACKOFF_MAX_MS)
|
|
207
|
+
log(isZh
|
|
208
|
+
? `stem: 进程异常退出(code=${code ?? '-'}, signal=${signal ?? '-'}),${Math.round(delayMs / 1000)}s 后自动恢复会话${resumeId ? ` ${resumeId}` : ''}…(STEM_NO_SUPERVISOR=1 可关闭看护)`
|
|
209
|
+
: `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
|
+
await sleep(delayMs)
|
|
211
|
+
}
|
|
212
|
+
}
|
package/bin/stem.mjs
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// 跨平台启动器(macOS / Linux / Windows):npm 全局安装经 cmd-shim / 软链均可调用。
|
|
3
3
|
// 语义对齐旧 bin/stem bash 版:CALLER_DIR 透传、.env 仅补缺不覆盖、STEM_DEV 走 bun 源码。
|
|
4
|
+
//
|
|
5
|
+
// 看护模式(2026-08-12 OOM 治理):交互式 TUI 默认由本进程看护 — 子进程崩溃
|
|
6
|
+
// (OOM FATAL ERROR 在进程内不可拦截:同步分配风暴会锁死事件循环,任何 JS 层
|
|
7
|
+
// 守护都没有执行机会)时自动:修补会话 JSONL 尾部(崩溃可能截断半行,不修则
|
|
8
|
+
// --resume 直接拒载)→ 注入自包含续跑 prompt 到队列 sidecar → --resume 同一
|
|
9
|
+
// 会话重启。正常退出(exit 0)与配置错误(exit 78)不重启;60 秒内连崩 3 次
|
|
10
|
+
// 放弃。STEM_NO_SUPERVISOR=1 退回单次直跑;实现与语义锁定见
|
|
11
|
+
// bin/stem-supervisor-lib.mjs 与 scripts/smoke-supervisor.ts。
|
|
4
12
|
import { existsSync, realpathSync } from 'node:fs'
|
|
5
13
|
import { dirname, join } from 'node:path'
|
|
6
14
|
import { devNull } from 'node:os'
|
|
@@ -20,21 +28,57 @@ if (!skipDotenv && existsSync(envFile)) {
|
|
|
20
28
|
process.loadEnvFile(envFile)
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// NODE_OPTIONS / execArgv 配置优先,不覆盖。
|
|
29
|
-
const heapConfigured =
|
|
31
|
+
const isZh = !(process.env.STEM_LOCALE ?? process.env.LANG ?? 'zh').toLowerCase().startsWith('en')
|
|
32
|
+
|
|
33
|
+
/** 用户显式 heap 配置探测 — 有则不覆盖(用户的 NODE_OPTIONS/execArgv 优先)。 */
|
|
34
|
+
function heapConfigured() {
|
|
35
|
+
return (
|
|
30
36
|
process.execArgv.some(a => a.includes('--max-old-space-size')) ||
|
|
31
37
|
(process.env.NODE_OPTIONS ?? '').includes('--max-old-space-size')
|
|
32
|
-
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 子进程 node flags:heap 上限(未显式配置时)+ OOM 前堆快照(可关)。 */
|
|
42
|
+
function childNodeFlags() {
|
|
43
|
+
const flags = []
|
|
44
|
+
if (!heapConfigured()) flags.push('--max-old-space-size=8192')
|
|
45
|
+
// OOM 前自动堆快照:定位"未知同步分配风暴"元凶的唯一现实手段(手动
|
|
46
|
+
// /heapdump 在同步爆发下来不及)。代价是崩溃时写数 GB 快照;supervisor
|
|
47
|
+
// 每轮 spawn 前清理只留最新一份。STEM_HEAP_SNAPSHOT=0 关闭。
|
|
48
|
+
if (
|
|
49
|
+
process.env.STEM_HEAP_SNAPSHOT !== '0' &&
|
|
50
|
+
!(process.env.NODE_OPTIONS ?? '').includes('--heapsnapshot-near-heap-limit')
|
|
51
|
+
) {
|
|
52
|
+
flags.push('--heapsnapshot-near-heap-limit=1')
|
|
53
|
+
}
|
|
54
|
+
return flags
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 看护适用性:交互式 TUI 才看护;fast-echo / SDK 桥 / 已被看护的子进程不看。 */
|
|
58
|
+
function shouldSupervise(argv) {
|
|
59
|
+
if (process.env.STEM_NO_SUPERVISOR === '1') return false
|
|
60
|
+
if (process.env.STEM_SUPERVISED === '1') return false
|
|
61
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) return false
|
|
62
|
+
const nonInteractive = new Set(['--help', '-h', '--version', '-v', '--print'])
|
|
63
|
+
return !argv.some(a => nonInteractive.has(a) || a === '--sdk-url' || a.startsWith('--sdk-url='))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const distEntry = join(rootDir, 'dist', 'cli.mjs')
|
|
67
|
+
if (process.env.STEM_DEV !== '1' && existsSync(distEntry)) {
|
|
68
|
+
const argv = process.argv.slice(2)
|
|
69
|
+
if (shouldSupervise(argv)) {
|
|
70
|
+
const { runSupervisor } = await import(pathToFileURL(join(rootDir, 'bin', 'stem-supervisor-lib.mjs')).href)
|
|
71
|
+
process.exit(await runSupervisor(distEntry, argv, { isZh, nodeFlags: childNodeFlags() }))
|
|
72
|
+
} else if (heapConfigured()) {
|
|
73
|
+
// 长会话内存水位高(UI 历史 + 渲染缓冲),Node 默认老生代上限 ~4GB 在大并发
|
|
74
|
+
// 子任务场景会 OOM。V8 堆参数只能在进程启动时生效;用户已显式配置时同进程直跑。
|
|
33
75
|
await import(pathToFileURL(distEntry).href)
|
|
34
76
|
} else {
|
|
77
|
+
// 非看护场景(fast-echo / SDK 桥 / STEM_NO_SUPERVISOR)单次 re-exec,
|
|
78
|
+
// 把 --max-old-space-size 带给真正的 CLI 进程。
|
|
35
79
|
const result = spawnSync(
|
|
36
80
|
process.execPath,
|
|
37
|
-
['--max-old-space-size=8192', distEntry, ...
|
|
81
|
+
['--max-old-space-size=8192', distEntry, ...argv],
|
|
38
82
|
{ stdio: 'inherit' },
|
|
39
83
|
)
|
|
40
84
|
process.exit(result.status ?? 1)
|
|
@@ -53,13 +97,12 @@ if (process.env.STEM_DEV !== '1' && existsSync(distEntry)) {
|
|
|
53
97
|
)
|
|
54
98
|
if (result.error && result.error.code === 'ENOENT') {
|
|
55
99
|
// i18n: 启动器双语(CLI 还没起来,无法走 JS catalog)
|
|
56
|
-
const locale = (process.env.STEM_LOCALE ?? process.env.LANG ?? 'zh').toLowerCase()
|
|
57
100
|
console.error(
|
|
58
|
-
|
|
59
|
-
?
|
|
60
|
-
"
|
|
61
|
-
:
|
|
62
|
-
"
|
|
101
|
+
isZh
|
|
102
|
+
? 'stem: 未找到 dist/cli.mjs,且 PATH 中没有 \'bun\'。\n' +
|
|
103
|
+
"请在仓库根目录执行 'bun run build',或安装 bun (https://bun.sh) 以使用 dev 模式。"
|
|
104
|
+
: "stem: dist/cli.mjs not found and 'bun' is not on PATH.\n" +
|
|
105
|
+
"Run 'bun run build' in the repo, or install bun (https://bun.sh) to use dev mode.",
|
|
63
106
|
)
|
|
64
107
|
process.exit(1)
|
|
65
108
|
}
|