mocode-ai 1.6.9 → 1.7.0

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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * 工作区命令组:/diff
3
+ *
4
+ * `/diff` = 最近一轮 agent turn 的快照(before) vs 工作区当前内容(after),
5
+ * 逐文件渲染 unified diff 到内容区;`/diff <N>` 选第 N 轮(1-based,与 /rollback
6
+ * 轮次口径一致);`/diff all` = 全部轮次的累计改动(最早的 before vs 现在)。
7
+ *
8
+ * 数据源:rollback 快照(每轮文件修改前捕获,不依赖 git)——好处是「没有 git 的目录」
9
+ * 也能看 agent 改了什么,且口径与 /rollback 完全一致(所见即可撤)。
10
+ * 限制:run_command 等不透明工具的工作区扫描快照在文件超 CAPTURE_FILE_LIMIT 时只留
11
+ * 指纹(contentUnavailable),这些文件显示「快照不可用」而非 diff。
12
+ *
13
+ * diff 算法:前后缀等行消除 + 单 hunk 输出,不引依赖(与 agent/index.ts lineDelta
14
+ * 同思路)。文件较少、行数中等的会话内 diff 足够快。
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import * as layout from '../../ui/layout.js';
18
+ import { ui } from '../../ui/theme.js';
19
+ import { getActiveRollbackStore } from '../../rollback/index.js';
20
+ import { unhandled, next } from './types.js';
21
+ const MAX_DIFF_LINES = 400; // 单文件输出上限:防止把万行生成文件刷满内容区
22
+ const MAX_FILES = 60; // 单次命令最多渲染文件数(超出提示收窄)
23
+ const CONTEXT = 3; // hunk 上下文行数
24
+ /** 快照 before 内容 → 文本(null → 无内容/不可用;base64 解码失败按 null)。 */
25
+ function snapshotText(snapshot) {
26
+ if (snapshot.before === null)
27
+ return null;
28
+ if (snapshot.encoding === 'base64') {
29
+ try {
30
+ return Buffer.from(snapshot.before, 'base64').toString('utf8');
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ return snapshot.before; // v1 快照:UTF-8 原文
37
+ }
38
+ /** 是否可当文本 diff:kind=file 且非 contentUnavailable(目录/软链/超预算跳过)。 */
39
+ function diffable(snapshot) {
40
+ return snapshot.kind === 'file' && snapshot.contentUnavailable !== true;
41
+ }
42
+ /** 按行求公共前缀/后缀长度(与 lineDelta 同思路,但这里要保留行号做 hunk)。 */
43
+ function hunkBoundaries(before, after) {
44
+ let head = 0;
45
+ while (head < before.length && head < after.length && before[head] === after[head])
46
+ head++;
47
+ let tail = 0;
48
+ while (tail < before.length - head &&
49
+ tail < after.length - head &&
50
+ before[before.length - 1 - tail] === after[after.length - 1 - tail])
51
+ tail++;
52
+ return { head, tail };
53
+ }
54
+ /**
55
+ * 行级 unified diff 单文件段(纯文本,颜色渲染层加)。
56
+ * 策略:前后缀等行消除后,中间区整段输出为「先删后加」的单 hunk。对单文件一次
57
+ * 编辑(agent 编辑的绝大多数形态:改一处/几处)等价于标准 diff;全文重写时输出
58
+ * 体积与正确 diff 同量级,只是 hunk 划分更粗。无变化返回 ''(指纹差异但文本相同)。
59
+ */
60
+ export function unifiedFileDiff(pathRel, beforeText, afterText) {
61
+ const before = beforeText === null ? [] : beforeText.split('\n');
62
+ const after = afterText === null ? [] : afterText.split('\n');
63
+ const { head, tail } = hunkBoundaries(before, after);
64
+ const midBefore = before.slice(head, before.length - tail);
65
+ const midAfter = after.slice(head, after.length - tail);
66
+ if (midBefore.length === 0 && midAfter.length === 0)
67
+ return '';
68
+ const contextStart = Math.max(0, head - CONTEXT);
69
+ const tailStartBefore = before.length - tail;
70
+ const tailStartAfter = after.length - tail;
71
+ const contextEndBefore = Math.min(before.length, tailStartBefore + CONTEXT);
72
+ const contextEndAfter = Math.min(after.length, tailStartAfter + CONTEXT);
73
+ const lines = [];
74
+ lines.push(`--- a/${pathRel}`);
75
+ lines.push(`+++ b/${pathRel}`);
76
+ lines.push(`@@ -${contextStart + 1},${contextEndBefore - contextStart} +${contextStart + 1},${contextEndAfter - contextStart} @@`);
77
+ for (let i = contextStart; i < head; i++)
78
+ lines.push(` ${before[i]}`);
79
+ for (const line of midBefore)
80
+ lines.push(`-${line}`);
81
+ for (const line of midAfter)
82
+ lines.push(`+${line}`);
83
+ for (let i = tailStartBefore; i < contextEndBefore; i++)
84
+ lines.push(` ${before[i]}`);
85
+ return lines.join('\n');
86
+ }
87
+ /** 渲染一条 diff 行:加绿/删红/hunk 头青/上下文 dim。 */
88
+ function writeDiffLine(line) {
89
+ if (line.startsWith('+'))
90
+ layout.contentWrite(`${ui.green}${line}${ui.reset}\n`);
91
+ else if (line.startsWith('-'))
92
+ layout.contentWrite(`${ui.red}${line}${ui.reset}\n`);
93
+ else if (line.startsWith('@@'))
94
+ layout.contentWrite(`${ui.cyan}${line}${ui.reset}\n`);
95
+ else
96
+ layout.contentWrite(`${ui.dim}${line}${ui.reset}\n`);
97
+ }
98
+ /** 读当前文件(不存在→null=「删除」;读失败→null)。 */
99
+ function readCurrent(full) {
100
+ try {
101
+ return readFileSync(full, 'utf8');
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ export const diffCommands = [
108
+ (ctx) => {
109
+ if (ctx.line !== '/diff' && !ctx.line.startsWith('/diff '))
110
+ return unhandled();
111
+ const arg = ctx.line === '/diff' ? '' : ctx.line.slice('/diff '.length).trim();
112
+ const store = getActiveRollbackStore();
113
+ const turns = store.listTurns();
114
+ if (turns.length === 0) {
115
+ layout.contentWrite(`${ui.dim}(本会话尚无文件改动记录——rollback 快照为空)${ui.reset}\n`);
116
+ return next();
117
+ }
118
+ // 轮次选择:/diff = 最近一轮;/diff N = 第 N 轮(1-based,与 /rollback 同口径);/diff all = 全部累计。
119
+ let label;
120
+ let minTurnIdExclusive; // 只统计 turnId > 此值的快照
121
+ if (arg === '') {
122
+ const last = turns[turns.length - 1];
123
+ minTurnIdExclusive = last.turnId - 1;
124
+ label = `第 ${turns.length} 轮(最近)`;
125
+ }
126
+ else if (arg === 'all') {
127
+ minTurnIdExclusive = 0;
128
+ label = '全部轮次累计';
129
+ }
130
+ else {
131
+ const n = Number(arg);
132
+ if (!Number.isInteger(n) || n < 1 || n > turns.length) {
133
+ layout.contentWrite(`${ui.yellow}(轮次无效:本会话共 ${turns.length} 轮,可用 /diff 1..${turns.length} 或 /diff all)${ui.reset}\n`);
134
+ return next();
135
+ }
136
+ minTurnIdExclusive = (turns[n - 1].turnId ?? 0) - 1;
137
+ label = `第 ${n} 轮`;
138
+ }
139
+ const byPath = new Map();
140
+ for (const snapshot of store.snapshotsSince(minTurnIdExclusive)) {
141
+ const existing = byPath.get(snapshot.path);
142
+ if (!existing || snapshot.turnId < existing.snapshot.turnId) {
143
+ byPath.set(snapshot.path, {
144
+ snapshot,
145
+ unavailable: (existing?.unavailable ?? false) || !diffable(snapshot),
146
+ });
147
+ }
148
+ else if (!diffable(snapshot)) {
149
+ existing.unavailable = true;
150
+ }
151
+ }
152
+ if (byPath.size === 0) {
153
+ layout.contentWrite(`${ui.dim}(${label}:无文件改动记录)${ui.reset}\n`);
154
+ return next();
155
+ }
156
+ const root = process.cwd();
157
+ const files = [...byPath.entries()].sort(([a], [b]) => a.localeCompare(b));
158
+ const shown = files.slice(0, MAX_FILES);
159
+ layout.contentWrite(`${ui.bold}${ui.accent}● ${label} 文件改动${ui.reset} ${ui.dim}${shown.length}/${files.length} 文件${ui.reset}\n`);
160
+ for (const [rel, entry] of shown) {
161
+ if (entry.unavailable) {
162
+ layout.contentWrite(`${ui.yellow} ⚠ ${rel}(快照不可用:超出捕获预算或非文本,无法展示 diff)${ui.reset}\n`);
163
+ continue;
164
+ }
165
+ const beforeText = snapshotText(entry.snapshot);
166
+ const afterText = readCurrent(`${root}${rel.startsWith('/') || rel.startsWith('\\') ? '' : '/'}${rel}`);
167
+ const diffText = unifiedFileDiff(rel, beforeText, afterText);
168
+ if (!diffText) {
169
+ // 快照记录了变更但当前内容已与 before 相同(后续轮改回/用户手改回):
170
+ // 仍列文件名,让用户知道它被动过,只是无净变化。
171
+ layout.contentWrite(`${ui.dim} = ${rel}(已与轮前状态一致,无净变化)${ui.reset}\n`);
172
+ continue;
173
+ }
174
+ layout.contentWrite(`\n${ui.bold}${rel}${ui.reset}\n`);
175
+ const lines = diffText.split('\n');
176
+ const truncated = lines.length > MAX_DIFF_LINES;
177
+ for (const line of lines.slice(0, MAX_DIFF_LINES))
178
+ writeDiffLine(line);
179
+ if (truncated) {
180
+ layout.contentWrite(`${ui.dim} …(diff 超过 ${MAX_DIFF_LINES} 行已截断,如需完整内容请直接 read_file)${ui.reset}\n`);
181
+ }
182
+ }
183
+ if (files.length > MAX_FILES) {
184
+ layout.contentWrite(`${ui.dim} …(${files.length - MAX_FILES} 个文件未展示;改动面过大,建议 /diff <N> 收窄到单轮)${ui.reset}\n`);
185
+ }
186
+ layout.contentWrite('\n');
187
+ return next();
188
+ },
189
+ ];
@@ -62,7 +62,7 @@ export const jobsCommands = [
62
62
  return next();
63
63
  },
64
64
  // /jobs kill [id前缀]
65
- (ctx) => {
65
+ async (ctx) => {
66
66
  if (ctx.cmd !== '/jobs')
67
67
  return unhandled();
68
68
  const parts = ctx.line.split(/\s+/);
@@ -77,7 +77,7 @@ export const jobsCommands = [
77
77
  layout.contentWrite(`${ui.dim}任务 ${job.id} 已是 ${job.status},无需 kill${ui.reset}\n`);
78
78
  return next();
79
79
  }
80
- const ok = killBackgroundJob(job);
80
+ const ok = await killBackgroundJob(job);
81
81
  layout.contentWrite(ok
82
82
  ? `${ui.yellow}已 kill 任务 ${job.id}${ui.reset}\n`
83
83
  : `${ui.red}kill 失败(进程可能已退出;PID=${job.pid ?? '?'})${ui.reset}\n`);
@@ -17,6 +17,7 @@ import { effortCommands } from './effort.js';
17
17
  import { routerCommands } from './router.js';
18
18
  import { toolGroupCommands } from './tool-group.js';
19
19
  import { jobsCommands } from './jobs.js';
20
+ import { diffCommands } from './diff.js';
20
21
  import { scheduleCommands } from './schedule.js';
21
22
  import { botsCommands } from './bots.js';
22
23
  /**
@@ -44,6 +45,7 @@ export const commandHandlers = [
44
45
  ...routerCommands,
45
46
  ...toolGroupCommands,
46
47
  ...jobsCommands,
48
+ ...diffCommands,
47
49
  ...scheduleCommands,
48
50
  ...botsCommands,
49
51
  ];
@@ -29,6 +29,7 @@ export function buildSlashCommands() {
29
29
  { name: '/resume', desc: d('commands.sessionResume') },
30
30
  { name: '/sessions', desc: d('commands.sessionBrowse') },
31
31
  { name: '/rollback', desc: d('commands.sessionRollback') },
32
+ { name: '/diff', desc: '查看 agent 文件改动 diff(/diff [N]|all,快照 vs 工作区)' },
32
33
  { name: '/ssearch <关键词>', value: '/ssearch ', submit: false, desc: '跨会话搜索归档会话' },
33
34
  { name: '/jobs', desc: '后台任务列表(/jobs log/kill <id前缀>)' },
34
35
  { name: '/schedules', desc: '查看定时计划与调度守护状态' },
@@ -173,10 +173,17 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
173
173
  const runtime = defaultRuntime;
174
174
  await runtime.start();
175
175
  const runtimeContext = runtime.context;
176
- // MCP 在工具表和 LLM schema 创建前连接;失败的单个 server 只给提示,不阻断 REPL。
177
- const mcpReport = await initializeAllMcp();
178
- registerToolsExtension('mcp', getMcpTools());
179
- refreshChatTools();
176
+ // MCP 异步并行连接(不阻塞首帧):旧序「await initializeAllMcp() → banner」在
177
+ // npx/stdio 类 server 上把 REPL 首屏卡住数秒(黑屏等待)。现在 banner/欢迎块先画,
178
+ // MCP 在后台连;完成后注册工具 + refreshChatTools + 在内容区补连接结果行。
179
+ // runTurn 前 mcpReady barrier 兜底:用户在连接完成前就发消息时等它就绪(见 runTurn)。
180
+ const mcpReady = (async () => {
181
+ const report = await initializeAllMcp();
182
+ registerToolsExtension('mcp', getMcpTools());
183
+ refreshChatTools();
184
+ return report;
185
+ })();
186
+ void mcpReady.catch(() => undefined); // 连接失败不产生 unhandledRejection;报告行由 then 写
180
187
  // --resume:读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
181
188
  let currentSessionId = sessionId;
182
189
  if (!currentSessionId)
@@ -270,12 +277,16 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
270
277
  else {
271
278
  layout.writeBanner(bannerLines(banner()));
272
279
  }
273
- if (mcpReport.connected.length > 0) {
274
- layout.contentWrite(`${ui.dim} ↳ 已连接 MCP: ${mcpReport.connected.join(', ')} (${getMcpTools().length} 个工具;外部工具每次均需授权)${ui.reset}\n`);
275
- }
276
- for (const warning of mcpReport.warnings) {
277
- layout.contentWrite(`${ui.yellow} ⚠ ${warning}${ui.reset}\n`);
278
- }
280
+ // MCP 连接结果在就绪后补写(后台并行连接,不再阻塞上方首帧);用户已滚动时仍
281
+ // 落在内容区尾部,与启动期其它提示行为一致。
282
+ void mcpReady.then((mcpReport) => {
283
+ if (mcpReport.connected.length > 0) {
284
+ layout.contentWrite(`${ui.dim} ↳ 已连接 MCP: ${mcpReport.connected.join(', ')} (${getMcpTools().length} 个工具;外部工具每次均需授权)${ui.reset}\n`);
285
+ }
286
+ for (const warning of mcpReport.warnings) {
287
+ layout.contentWrite(`${ui.yellow} ⚠ ${warning}${ui.reset}\n`);
288
+ }
289
+ });
279
290
  if (!isModelConfigured()) {
280
291
  // 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
281
292
  layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
@@ -456,6 +467,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
456
467
  let routingDone = toolPolicy != null;
457
468
  try {
458
469
  signal = startRunningListener(placeholder);
470
+ // MCP 后台连接兜底:启动后在连接完成前就发消息的极端窗口,先等就绪再路由,
471
+ // 保证路由看到的工具表含 MCP 工具(否则本 turn 工具簇不含 mcp__,模型路由失真)。
472
+ await mcpReady.catch(() => undefined);
459
473
  // 入口设定本轮初始模式(合成执行轮传 false→auto;用户轮传当前 mode)。
460
474
  // setAgentMode 触发 listener 重写 history[0];模式只由用户面切换,runAgent 每步读取当前值。
461
475
  setAgentMode(planMode ? 'plan' : 'auto');
@@ -533,6 +547,11 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
533
547
  refreshStatusBase(history);
534
548
  layout.drawStatusBar();
535
549
  }, toolPolicy, initialToolRoute, runtime);
550
+ // 撞限 salvage 摘要要在 TUI 落地:runAgent 只把 finalText 带回不渲染,主线撞限的
551
+ // 收尾摘要若不打印,这笔收尾请求的 token 就白花(用户只看到「达到最大步数」一行)。
552
+ if (result.terminationReason === 'max_steps' && result.finalText) {
553
+ layout.contentWrite(result.finalText.endsWith('\n') ? result.finalText : `${result.finalText}\n`);
554
+ }
536
555
  if (toolPolicy)
537
556
  lastToolGroups = toolPolicy.groupNames;
538
557
  // 本轮 token 累计(底栏模式 chip 右边显示)。undefined = 后端不开 include_usage。
@@ -54,6 +54,54 @@ const SCAN_CONCURRENCY = 16;
54
54
  const YIELD_EVERY = 64;
55
55
  const YIELD_BYTES = 1024 * 1024;
56
56
  const FRESH_WINDOW_MS = 2000;
57
+ /**
58
+ * contentCache 的常驻字节上限(base64 后的长度和);0 = 不限。
59
+ *
60
+ * contentCache 是「上次扫描读到的内容」的跨扫描复用:命中则省一次 readFile,
61
+ * 这就是第 2 次及以后的 workspace 扫描只要 ~35ms 而首次要 500ms+ 的原因
62
+ * (实测 2026-10-05,2000 entries / 42.7MB base64)。它原先只增不减(唯一清理点是
63
+ * resetState),即一次扫描就把整个工作区文本永久常留内存。
64
+ *
65
+ * **默认保持不限(0)**:实测把上限压到 16MB 会把命中率打崩 —— 缓存从 664 条掉到 54 条,
66
+ * 扫描耗时从 ~67ms 反弹到 ~1000ms(涨 15倍)。原因是遍历顺序下累积的大文件会挤掉后续
67
+ * 所有条目,淘汰机制天然对「后访问的小文件」不友好。42.7MB 换每条命令 67ms 是划算的,
68
+ * 不值得为省内存牺牲 15 倍扫描速度。
69
+ *
70
+ * 保留这个旋门是为了「大工作区内存吃紧」的场景(比如 node_modules 被误纳入扫描),
71
+ * 可通过 MOCODE_ROLLBACK_CACHE_MB 收紧;不设则沿用无上限的既有行为。
72
+ */
73
+ const CONTENT_CACHE_MAX_BYTES = (() => {
74
+ const mb = Number(process.env.MOCODE_ROLLBACK_CACHE_MB);
75
+ return Number.isFinite(mb) && mb > 0 ? Math.floor(mb) * 1024 * 1024 : 0;
76
+ })();
77
+ /**
78
+ * 把扫描产出的缓存裁到 CONTENT_CACHE_MAX_BYTES 以内,按插入序淘汰最旧条目。
79
+ *
80
+ * 仅在 CONTENT_CACHE_MAX_BYTES > 0(即用户显式设了 MOCODE_ROLLBACK_CACHE_MB)时生效;
81
+ * 默认无上限,保持既有行为。淘汰只影响性能,不影响正确性:被淘汰的条目下次扫描时
82
+ * stamp 不匹配 → 重新 readFile 而已(snapshot 记录的是**本次扫描当场读到**的内容,
83
+ * 见 scanWorkspace 的 `cached.stamp === stamp` 校验)。
84
+ */
85
+ function trimContentCache(cache) {
86
+ if (CONTENT_CACHE_MAX_BYTES <= 0)
87
+ return cache;
88
+ let total = 0;
89
+ for (const entry of cache.values())
90
+ total += entry.data.length;
91
+ if (total <= CONTENT_CACHE_MAX_BYTES)
92
+ return cache;
93
+ // Map 保插入序 → 最旧的在最前。逐个删到回到预算内,单趟 O(n) 足够。
94
+ for (const key of cache.keys()) {
95
+ if (total <= CONTENT_CACHE_MAX_BYTES)
96
+ break;
97
+ const entry = cache.get(key);
98
+ if (!entry)
99
+ continue;
100
+ total -= entry.data.length;
101
+ cache.delete(key);
102
+ }
103
+ return cache;
104
+ }
57
105
  /**
58
106
  * 一个完整、实例隔离的回滚事务存储。每个 runtime 可持有独立实例,使轮次、序号、
59
107
  * 快照、mutation generation 和扫描缓存互不干扰。
@@ -336,7 +384,7 @@ export class RollbackStore {
336
384
  }
337
385
  };
338
386
  await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
339
- this.contentCache = nextCache;
387
+ this.contentCache = trimContentCache(nextCache);
340
388
  return entries;
341
389
  }
342
390
  async beginWorkspaceMutation() {
@@ -392,6 +440,13 @@ export class RollbackStore {
392
440
  listTurns() {
393
441
  return this.turns.slice();
394
442
  }
443
+ /**
444
+ * 只读侧门(供 /diff 渲染):返回 turnId > sinceTurnId 的全部快照,浅拷贝数组。
445
+ * 快照内容含 before 原文,调用方不得 mutate 返回值;用于展示,不影响回滚语义。
446
+ */
447
+ snapshotsSince(sinceTurnId) {
448
+ return this.snapshots.filter((snapshot) => snapshot.turnId > sinceTurnId);
449
+ }
395
450
  findCutoffIndex(n, history) {
396
451
  let seen = 0;
397
452
  for (let i = 0; i < history.length; i++) {
@@ -127,7 +127,29 @@ async function probeReady(url) {
127
127
  clearTimeout(timer);
128
128
  }
129
129
  }
130
- function killTree(pid) {
130
+ /**
131
+ * 异步树杀(运行期路径):spawnSync('taskkill') 会同步阻塞事件循环(扫树期间流式渲染/
132
+ * 并行工具停摆)。发出 kill 后等 taskkill 退出,不牺牲「进程已终止」语义。
133
+ * 退出路径(stopAllDevServersSync)必须同步,用 killTreeSync 保留原行为。
134
+ */
135
+ async function killTree(pid) {
136
+ if (!IS_WINDOWS) {
137
+ try {
138
+ process.kill(pid, 'SIGTERM');
139
+ }
140
+ catch {
141
+ // 已退出或无权限:best-effort。
142
+ }
143
+ return;
144
+ }
145
+ await new Promise((resolve) => {
146
+ const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
147
+ killer.on('close', () => resolve());
148
+ killer.on('error', () => resolve());
149
+ });
150
+ }
151
+ /** 同步树杀:仅供进程退出钩子(stopAllDevServersSync)使用,运行期请走异步版。 */
152
+ function killTreeSync(pid) {
131
153
  try {
132
154
  if (IS_WINDOWS) {
133
155
  spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
@@ -266,13 +288,13 @@ export async function startDevServer(opts) {
266
288
  catch (error) {
267
289
  // 启动失败 / 被中断:不留孤儿进程。
268
290
  record.stopRequested = true;
269
- killTree(record.pid);
291
+ await killTree(record.pid);
270
292
  finalizeExit(record, record.child.exitCode, record.child.signalCode);
271
293
  throw error;
272
294
  }
273
295
  if (wantsReadySignal && !ready) {
274
296
  record.stopRequested = true;
275
- killTree(record.pid);
297
+ await killTree(record.pid);
276
298
  finalizeExit(record, record.child.exitCode, record.child.signalCode);
277
299
  throw new DevServerManagerError('TIMEOUT', `Dev server did not become ready within ${timeoutMs}ms and was stopped. Recent output:\n${record.recentLog.trim() || '(no output)'}`);
278
300
  }
@@ -328,7 +350,7 @@ export async function stopDevServer(id) {
328
350
  return { ...snapshot(record), alreadyStopped: true };
329
351
  }
330
352
  record.stopRequested = true;
331
- killTree(record.pid);
353
+ await killTree(record.pid);
332
354
  const deadline = Date.now() + STOP_GRACE_MS;
333
355
  while (Date.now() < deadline && isAlive(record.pid)) {
334
356
  await sleep(50);
@@ -350,7 +372,7 @@ export function stopAllDevServersSync() {
350
372
  if (record.state !== 'running')
351
373
  continue;
352
374
  record.stopRequested = true;
353
- killTree(record.pid);
375
+ killTreeSync(record.pid);
354
376
  finalizeExit(record, record.child.exitCode, record.child.signalCode);
355
377
  }
356
378
  }
@@ -83,7 +83,7 @@ export async function runScheduleCli(rawArgs) {
83
83
  return 0;
84
84
  }
85
85
  case 'stop':
86
- process.stdout.write(stopDaemon() ? 'Scheduler stopped\n' : 'Scheduler was not running\n');
86
+ process.stdout.write((await stopDaemon()) ? 'Scheduler stopped\n' : 'Scheduler was not running\n');
87
87
  return 0;
88
88
  case 'status': {
89
89
  const state = getDaemonState();
@@ -150,12 +150,12 @@ export function startDaemon(port = DEFAULT_PORT) {
150
150
  fs.writeFileSync(daemonStatePath(), `${JSON.stringify(state, null, 2)}\n`);
151
151
  return { started: true, state };
152
152
  }
153
- /** 停止守护进程并清状态。 */
154
- export function stopDaemon() {
153
+ /** 停止守护进程并清状态。异步:树杀不再阻塞事件循环(见 jobs/launch.ts killTree)。 */
154
+ export async function stopDaemon() {
155
155
  const state = getDaemonState();
156
156
  if (!state)
157
157
  return false;
158
- const ok = killTree(state.pid);
158
+ const ok = await killTree(state.pid);
159
159
  try {
160
160
  fs.rmSync(daemonStatePath());
161
161
  }
@@ -66,6 +66,20 @@ export class SessionStore {
66
66
  currentSessionIdProvider;
67
67
  currentSessionIdSetter;
68
68
  currentSessionId;
69
+ /**
70
+ * 已确保存在的会话目录缓存(2026-10-05)。
71
+ *
72
+ * `mkdirSync(recursive)` 单次 0.20ms —— 比 existsSync+statSync(合计 0.009ms)贵 20 倍,
73
+ * 因为 recursive 模式要逐级向下 stat 每层路径。而 appendTrace 每 step 调 14 次
74
+ * (step_start/end + model_start/end + 每工具 2 个),每轮 20 step ≈ 280 次全花在
75
+ * 「确认目录已存在」上,一轮白烧 56ms。
76
+ *
77
+ * 缓存的是「已 mkdir 成功」这件事,不是路径存在性。**代价**:外部删掉目录后缓存会失真。
78
+ * 兜底是三个写盘方法统一的「失败 → invalidateEnsuredDir → 重试一次」模式
79
+ * (见 appendTrace / appendUsage / save),既保住性能又不丢数据。
80
+ * 护栏见 tests/session-dir-cache.test.ts 的「外部删除后自愈」三例。
81
+ */
82
+ ensuredDirs = new Set();
69
83
  constructor(options = {}) {
70
84
  if (typeof options.workspaceRoot === 'function') {
71
85
  const provider = options.workspaceRoot;
@@ -92,9 +106,26 @@ export class SessionStore {
92
106
  }
93
107
  sessionDir() {
94
108
  const root = this.sessionsRoot;
95
- mkdirSync(root, { recursive: true });
109
+ this.ensureDir(root);
96
110
  return root;
97
111
  }
112
+ /**
113
+ * 幂等建目录:同一路径只在首次真正 mkdir(见 ensuredDirs 的成本说明)。
114
+ *
115
+ * 缓存意味着「外部删掉目录后不自愈」,故所有写盘方法都用
116
+ * {@link invalidateEnsuredDir} + 重试一次兜住(retention 归档后 rmSync、
117
+ * 用户手工清理等)。单靠这里的 Set 不会自愈,必须配对那个重试。
118
+ */
119
+ ensureDir(dir) {
120
+ if (this.ensuredDirs.has(dir))
121
+ return;
122
+ mkdirSync(dir, { recursive: true });
123
+ this.ensuredDirs.add(dir);
124
+ }
125
+ /** 让 ensureDir 下次真正 mkdir(用于 append 失败后的自愈重试)。 */
126
+ invalidateEnsuredDir(dir) {
127
+ this.ensuredDirs.delete(dir);
128
+ }
98
129
  createId() {
99
130
  return createTimestampId();
100
131
  }
@@ -119,22 +150,38 @@ export class SessionStore {
119
150
  * 然后清空当前文件。纯诊断数据,任何失败静默降级为普通追加。
120
151
  */
121
152
  appendTrace(id, value) {
122
- try {
123
- const dir = path.join(this.sessionsRoot, id);
124
- mkdirSync(dir, { recursive: true });
125
- const line = `${JSON.stringify(value)}\n`;
126
- const tracePath = path.join(dir, 'trace.jsonl');
127
- // env 覆盖仅供测试:MOCODE_MAX_TRACE_BYTES=200 可立刻触发轮转。
128
- const envMax = Number(process.env.MOCODE_MAX_TRACE_BYTES);
129
- const maxTraceBytes = Number.isFinite(envMax) && envMax >= 0 ? envMax : MAX_TRACE_BYTES;
130
- const size = existsSync(tracePath) ? statSync(tracePath).size : 0;
131
- if (size >= maxTraceBytes) {
132
- this.rotateTrace(dir, tracePath);
153
+ const dir = path.join(this.sessionsRoot, id);
154
+ const line = `${JSON.stringify(value)}\n`;
155
+ const tracePath = path.join(dir, 'trace.jsonl');
156
+ // env 覆盖仅供测试:MOCODE_MAX_TRACE_BYTES=200 可立刻触发轮转。
157
+ const envMax = Number(process.env.MOCODE_MAX_TRACE_BYTES);
158
+ const maxTraceBytes = Number.isFinite(envMax) && envMax >= 0 ? envMax : MAX_TRACE_BYTES;
159
+ // 目录缓存可能因外部删除而失真(如 retention 归档后 rmSync):首次失败清缓存重试一次。
160
+ for (let attempt = 0; attempt < 2; attempt++) {
161
+ try {
162
+ this.ensureDir(dir); // 缓存:每 step 14 次调用,省 14×0.20ms
163
+ // statSync 一次即可拿到「不存在 / 大小」两种答案,省掉 existsSync 那一跳
164
+ // (两者合计仅 0.009ms,但顺手消掉一次 TOCTOU 窗口)。
165
+ let size = 0;
166
+ try {
167
+ size = statSync(tracePath).size;
168
+ }
169
+ catch {
170
+ size = 0;
171
+ }
172
+ if (size >= maxTraceBytes) {
173
+ this.rotateTrace(dir, tracePath);
174
+ }
175
+ writeFileSync(tracePath, line, { encoding: 'utf8', flag: 'a' });
176
+ return;
177
+ }
178
+ catch {
179
+ this.invalidateEnsuredDir(dir);
180
+ if (attempt === 1) {
181
+ // Observability is best-effort and cannot block coding work.
182
+ return;
183
+ }
133
184
  }
134
- writeFileSync(tracePath, line, { encoding: 'utf8', flag: 'a' });
135
- }
136
- catch {
137
- // Observability is best-effort and cannot block coding work.
138
185
  }
139
186
  }
140
187
  rotateTrace(dir, tracePath) {
@@ -174,13 +221,23 @@ export class SessionStore {
174
221
  * 与 appendTrace 同策略:纯诊断,失败静默,不做轮转(长会话的量级远小于 trace)。
175
222
  */
176
223
  appendUsage(id, value) {
177
- try {
178
- const dir = path.join(this.sessionsRoot, id);
179
- mkdirSync(dir, { recursive: true });
180
- writeFileSync(path.join(dir, 'usage.jsonl'), `${JSON.stringify(value)}\n`, { encoding: 'utf8', flag: 'a' });
181
- }
182
- catch {
183
- // Metrics must never block coding work.
224
+ const dir = path.join(this.sessionsRoot, id);
225
+ const line = `${JSON.stringify(value)}\n`;
226
+ const file = path.join(dir, 'usage.jsonl');
227
+ // 同 appendTrace:目录缓存失真时清缓存重试一次。
228
+ for (let attempt = 0; attempt < 2; attempt++) {
229
+ try {
230
+ this.ensureDir(dir); // 同appendTrace:每 step 1 次,省 0.20ms
231
+ writeFileSync(file, line, { encoding: 'utf8', flag: 'a' });
232
+ return;
233
+ }
234
+ catch {
235
+ this.invalidateEnsuredDir(dir);
236
+ if (attempt === 1) {
237
+ // Metrics must never block coding work.
238
+ return;
239
+ }
240
+ }
184
241
  }
185
242
  }
186
243
  /** 读取 <id>/usage.jsonl 全部记录;文件不存在或损坏行跳过。 */
@@ -202,6 +259,13 @@ export class SessionStore {
202
259
  }
203
260
  return out;
204
261
  }
262
+ /**
263
+ * 全量落盘会话(history + queryHistory + lastToolGroups),原子写(先 tmp 再 rename)。
264
+ *
265
+ * 与 appendTrace/appendUsage 的差别:**失败会抛给调用方**——这是用户数据,
266
+ * 静默丢失不可接受(那两个是诊断数据,静默降级是对的)。目录缓存失真时会
267
+ * 清缓存重试一次,仍失败则照旧抛出。
268
+ */
205
269
  save(history, id, queryHistory = [], lastToolGroups = []) {
206
270
  const meta = {
207
271
  id,
@@ -216,21 +280,33 @@ export class SessionStore {
216
280
  if (history.length <= 1 && queryHistory.length === 0 && !existsSync(currentPath) && !existsSync(legacyPath)) {
217
281
  return meta;
218
282
  }
219
- mkdirSync(path.join(this.sessionsRoot, id), { recursive: true });
283
+ const dir = path.join(this.sessionsRoot, id);
284
+ const tmpPath = path.join(dir, 'session.json.tmp');
220
285
  const record = {
221
286
  ...meta,
222
287
  history,
223
288
  queryHistory: [...queryHistory],
224
289
  lastToolGroups: [...lastToolGroups],
225
290
  };
226
- // 原子落盘:先写同目录 tmp 再 rename——全量重写(长会话可达数 MB)中途崩溃/断电
227
- // 不会留下半个 JSON 把整个会话写坏(对齐 memory/store.ts writeAtomic)。
228
- const tmpPath = path.join(this.sessionsRoot, id, 'session.json.tmp');
229
- writeFileSync(tmpPath, JSON.stringify(record), 'utf8');
230
- renameSync(tmpPath, currentPath);
231
- if (existsSync(legacyPath))
232
- unlinkSync(legacyPath);
233
- return meta;
291
+ // 目录缓存失真时清缓存重试一次(与 appendTrace 同理);仍失败则照旧抛给调用方
292
+ // —— save 是用户数据落盘,失败必须可见(appendTrace/appendUsage 是诊断数据,静默)。
293
+ for (let attempt = 0;; attempt++) {
294
+ try {
295
+ this.ensureDir(dir);
296
+ // 原子落盘:先写同目录 tmp 再 rename——全量重写(长会话可达数 MB)中途崩溃/断电
297
+ // 不会留下半个 JSON 把整个会话写坏(对齐 memory/store.ts writeAtomic)。
298
+ writeFileSync(tmpPath, JSON.stringify(record), 'utf8');
299
+ renameSync(tmpPath, currentPath);
300
+ if (existsSync(legacyPath))
301
+ unlinkSync(legacyPath);
302
+ return meta;
303
+ }
304
+ catch (error) {
305
+ this.invalidateEnsuredDir(dir);
306
+ if (attempt === 1)
307
+ throw error;
308
+ }
309
+ }
234
310
  }
235
311
  load(id) {
236
312
  const currentPath = this.sessionPath(id);