@mortiseai/stem 0.0.14 → 0.0.16
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 +80 -9
- package/dist/cli.mjs +536 -534
- package/dist/daemon.mjs +407 -404
- package/dist/mcp.mjs +405 -402
- package/package.json +1 -1
|
@@ -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,50 @@ 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) {
|
|
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
|
+
crashes.push({
|
|
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
|
+
if (crashes.length > 20) crashes = crashes.slice(-20)
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(dirname(markerPath), { recursive: true })
|
|
120
|
+
writeFileSync(markerPath, JSON.stringify({ v: 1, sessionId: sessionId || null, crashes }, null, 2) + '\n')
|
|
121
|
+
return true
|
|
122
|
+
} catch { return false }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 从 argv 剥离 --permission-mode(重启时以通告里的实时模式为准 — 用户 Shift+Tab
|
|
126
|
+
* 切过的模式不在启动 argv 里,原样透传会把模式打回启动值,2026-08-13 事故实证
|
|
127
|
+
* auto 崩后回落 default,自动续跑第一轮在降级权限下弹卡)。 */
|
|
128
|
+
export function stripPermissionModeFlag(argv) {
|
|
129
|
+
const out = []
|
|
130
|
+
for (let i = 0; i < argv.length; i++) {
|
|
131
|
+
const a = argv[i]
|
|
132
|
+
if (a === '--permission-mode') {
|
|
133
|
+
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) i++
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (a.startsWith('--permission-mode=')) continue
|
|
137
|
+
out.push(a)
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
82
142
|
/** 崩溃堆快照清理:只保留最新一份 Heap.*.heapsnapshot(防 24h 连跑占满磁盘)。 */
|
|
83
143
|
export function cleanupHeapSnapshots(dir) {
|
|
84
144
|
let entries
|
|
@@ -142,6 +202,9 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
142
202
|
let { baseArgs, resumeId } = stripResumeFlag(argv)
|
|
143
203
|
let crashTimes = []
|
|
144
204
|
let childAlive = false
|
|
205
|
+
// 崩溃前子进程通告的实时 permission mode(Shift+Tab 切换会更新通告)—
|
|
206
|
+
// 重启时经 --permission-mode 复原,argv 中的启动值被剥离让位。
|
|
207
|
+
let permissionModeFromAnnounce = null
|
|
145
208
|
|
|
146
209
|
if (installSignalHandlers) {
|
|
147
210
|
// Windows CTRL_C_EVENT 广播父子都收:父进程吞掉,让子进程自己处理(单击中断 /
|
|
@@ -152,7 +215,9 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
152
215
|
|
|
153
216
|
for (;;) {
|
|
154
217
|
cleanupHeapSnapshots(cwd)
|
|
155
|
-
const
|
|
218
|
+
const effectiveBase = permissionModeFromAnnounce ? stripPermissionModeFlag(baseArgs) : baseArgs
|
|
219
|
+
const args = [...nodeFlags, distEntry, ...effectiveBase]
|
|
220
|
+
if (permissionModeFromAnnounce) args.push('--permission-mode', permissionModeFromAnnounce)
|
|
156
221
|
if (resumeId) args.push('--resume', resumeId)
|
|
157
222
|
const child = spawnChild(args, {
|
|
158
223
|
...process.env,
|
|
@@ -192,13 +257,19 @@ export async function runSupervisor(distEntry, argv, options = {}) {
|
|
|
192
257
|
} catch { /* ignore */ }
|
|
193
258
|
if (announce) {
|
|
194
259
|
resumeId = announce.sessionId
|
|
260
|
+
if (typeof announce.permissionMode === 'string' && announce.permissionMode) {
|
|
261
|
+
permissionModeFromAnnounce = announce.permissionMode
|
|
262
|
+
}
|
|
195
263
|
for (const p of Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : []) {
|
|
196
264
|
if (typeof p === 'string' && existsSync(p) && repairJsonlTail(p)) {
|
|
197
265
|
log(isZh ? `stem: 已修补崩溃截断的会话记录 ${p}` : `stem: repaired crash-truncated session log ${p}`)
|
|
198
266
|
break
|
|
199
267
|
}
|
|
200
268
|
}
|
|
201
|
-
if (typeof announce.queuePath === 'string')
|
|
269
|
+
if (typeof announce.queuePath === 'string') {
|
|
270
|
+
writeCrashMarker(announce.queuePath, announce.sessionId, { code, signal }, t)
|
|
271
|
+
injectRestartPrompt(announce.queuePath, isZh, { code, signal })
|
|
272
|
+
}
|
|
202
273
|
}
|
|
203
274
|
|
|
204
275
|
onCrashCleanupPid(child.pid)
|