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.
@@ -82,119 +82,48 @@ class LegacyCompatibleToolDispatcher {
82
82
  calls.forEach((call, index) => (call.name === ADD_TOOL_GROUPS_TOOL_NAME ? controlIndexes : otherIndexes).push(index));
83
83
  const hasRouteBarrier = controlIndexes.length > 0;
84
84
  if (hasRouteBarrier) {
85
- // 同批的非控制调用全部是「未被禁用的并行安全(只读)工具」时(纯 solo 时空集也满足),
86
- // 允许它们与扩容同批:先并发执行只读调用,再应用扩容。只读结果本 step 即得,新增 schema
87
- // 下一 step 生效——不必为扩容空耗一轮。只要混有写/执行等非并行工具(或某只读调用已被
88
- // 当前 snapshot 拒绝),即退回整批保守拒绝。
89
- const safeReadonlyBatch = otherIndexes.every((index) => isParallelTool(calls[index].name, toolRuntime) && !request.isDenied(calls[index].name));
90
- if (safeReadonlyBatch) {
91
- // 所有 header 按调用原序先发(渲染侧据此建组容器),只读批的 header 必须先于 execute。
92
- for (let index = 0; index < calls.length; index++) {
93
- request.onEvent({ type: 'header', call: calls[index] });
94
- }
95
- if (otherIndexes.length > 0) {
96
- request.onEvent({ type: 'start', tool: calls[otherIndexes[0]].name });
97
- const startedReadonly = otherIndexes.map((index) => execute(calls[index]));
98
- for (let offset = 0; offset < otherIndexes.length; offset++) {
99
- const index = otherIndexes[offset];
100
- const outcome = await startedReadonly[offset];
101
- record(index, outcome);
102
- executionEvents(index, parseArgs(calls[index].arguments), outcome);
103
- resultEvent(index, outcome, null);
104
- }
105
- request.onEvent({ type: 'done' });
106
- }
107
- // 控制调用(header 已发):逐个校验并应用扩容;solo 与同批语义一致。
108
- for (const index of controlIndexes) {
109
- const call = calls[index];
110
- const parsed = parseArgs(call.arguments);
111
- let outcome;
112
- if (request.isDenied(call.name)) {
113
- outcome = {
114
- status: 'denied',
115
- code: 'TOOL_DISABLED',
116
- retryable: false,
117
- output: `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`,
118
- changedFiles: [],
119
- durationMs: 0,
120
- };
121
- }
122
- else if (!request.expandToolGroups) {
123
- outcome = {
124
- status: 'denied',
125
- code: 'TOOL_DISABLED',
126
- retryable: false,
127
- output: '错误:当前 Agent 未启用动态工具策略,无法调用 add_tool_groups。',
128
- changedFiles: [],
129
- durationMs: 0,
130
- };
131
- }
132
- else if (!parsed ||
133
- !Array.isArray(parsed.groups) ||
134
- parsed.groups.length === 0 ||
135
- typeof parsed.reason !== 'string' ||
136
- !parsed.reason.trim()) {
137
- outcome = {
138
- status: 'error',
139
- code: 'INVALID_ARGUMENTS',
140
- retryable: false,
141
- output: '错误:add_tool_groups 需要非空 groups 数组和非空 reason。',
142
- changedFiles: [],
143
- durationMs: 0,
144
- };
145
- }
146
- else {
147
- const expansion = request.expandToolGroups(parsed.groups, parsed.reason);
148
- const succeeded = expansion.added.length > 0;
149
- const details = [
150
- succeeded
151
- ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
152
- : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
153
- expansion.implied.length > 0 ? `Implied groups also activated: ${expansion.implied.join(', ')}.` : '',
154
- expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
155
- succeeded ? 'The added tool schemas become available on the next model step.' : '',
156
- ]
157
- .filter(Boolean)
158
- .join('\n');
159
- outcome = {
160
- status: succeeded ? 'success' : 'error',
161
- code: succeeded ? 'OK' : 'INVALID_ARGUMENTS',
162
- retryable: false,
163
- output: details,
164
- changedFiles: [],
165
- durationMs: 0,
166
- };
167
- request.onEvent({
168
- type: 'route_expand',
169
- fromVersion: request.policy.toolPolicy?.version,
170
- expansion,
171
- requestedGroups: parsed.groups,
172
- reason: parsed.reason,
173
- status: outcome.status,
174
- });
175
- }
85
+ // add_tool_groups 混批语义 = 部分执行:同批所有「未被禁用的并行安全(只读)」调用
86
+ // 本 step 照常并发执行,控制调用照常应用扩容(只改下一 step 的 schema);仅写/执行
87
+ // 等非并行调用被配对拒绝并提示下一 step 重试——单个违规调用不再让整批陪葬。
88
+ // 所有 header 按调用原序先发(渲染侧据此建组容器),只读批的 header 必须先于 execute。
89
+ for (let index = 0; index < calls.length; index++) {
90
+ request.onEvent({ type: 'header', call: calls[index] });
91
+ }
92
+ // 只读调用先并发启动(执行序不变);但发布(history 回灌/事件)一律按 provider 声明序
93
+ // 交错进行:history 批校验按 assistant tool_calls 逐位配对 id,若按执行序发布(只读先、
94
+ // 扩容后),add_tool_groups 排在只读之前时必然 id 错位,整批 rollback 中断本轮。
95
+ // 非并行(写/执行/串行)调用不启动:它们与扩容同批时被拒绝,下一 step 重试。
96
+ const startedReadonly = new Array(calls.length);
97
+ const eligibleReadonly = otherIndexes.filter((index) => isParallelTool(calls[index].name, toolRuntime) && !request.isDenied(calls[index].name));
98
+ if (eligibleReadonly.length > 0) {
99
+ request.onEvent({ type: 'start', tool: calls[eligibleReadonly[0]].name });
100
+ for (const index of eligibleReadonly)
101
+ startedReadonly[index] = execute(calls[index]);
102
+ }
103
+ const lastReadonlyIndex = eligibleReadonly.length > 0 ? eligibleReadonly[eligibleReadonly.length - 1] : -1;
104
+ for (let index = 0; index < calls.length; index++) {
105
+ const readonlyPromise = startedReadonly[index];
106
+ if (readonlyPromise) {
107
+ const outcome = await readonlyPromise;
176
108
  record(index, outcome);
177
- request.onEvent({ type: 'host_outcome', call, parsed: parsed ?? {}, outcome });
109
+ executionEvents(index, parseArgs(calls[index].arguments), outcome);
178
110
  resultEvent(index, outcome, null);
179
- traceEnd(index, outcome);
111
+ // done 紧跟最后一个只读结果(控制调用在尾部时与旧事件序完全一致)。
112
+ if (index === lastReadonlyIndex)
113
+ request.onEvent({ type: 'done' });
114
+ continue;
180
115
  }
181
- }
182
- else {
183
- // 不安全批(混有写/执行等非并行工具):不扩容、不执行任何普通工具,逐 call 按原序配对拒绝结果。
184
- for (let index = 0; index < calls.length; index++) {
116
+ if (calls[index].name !== ADD_TOOL_GROUPS_TOOL_NAME) {
117
+ // 非并行(写/执行/串行)或被禁用的普通调用:与扩容同批时不执行,配对拒绝并提示
118
+ // 下一 step 重试;控制调用与只读不受其影响照常处理。
185
119
  const call = calls[index];
186
- request.onEvent({ type: 'header', call });
187
- const isControl = call.name === ADD_TOOL_GROUPS_TOOL_NAME;
188
- const isReadonly = isParallelTool(call.name, toolRuntime);
189
120
  const outcome = {
190
121
  status: 'denied',
191
- code: isControl ? 'INVALID_ARGUMENTS' : 'TOOL_DISABLED',
122
+ code: 'TOOL_DISABLED',
192
123
  retryable: false,
193
- output: isControl
194
- ? '错误:add_tool_groups 只能单独调用,或与只读工具(read_file/glob/grep/web 等)同批;本次没有扩容。'
195
- : isReadonly
196
- ? `错误:同一响应包含 add_tool_groups,工具 ${call.name} 未执行。请在下一 step 重试。`
197
- : `错误:add_tool_groups 不能与写/执行工具 ${call.name} 同批;请先完成扩容,再在下一 step 调用 ${call.name}。`,
124
+ output: request.isDenied(call.name)
125
+ ? `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`
126
+ : `错误:add_tool_groups 不能与写/执行工具 ${call.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${call.name}。`,
198
127
  changedFiles: [],
199
128
  durationMs: 0,
200
129
  };
@@ -202,7 +131,81 @@ class LegacyCompatibleToolDispatcher {
202
131
  request.onEvent({ type: 'host_outcome', call, parsed: parseArgs(call.arguments) ?? {}, outcome });
203
132
  resultEvent(index, outcome, null);
204
133
  traceEnd(index, outcome);
134
+ continue;
205
135
  }
136
+ // 控制调用(header 已发):逐个校验并应用扩容;solo 与同批语义一致。
137
+ // 扩容只改下一 step 的 schema;本批只读执行都走 step 快照,先后无行为差。
138
+ const call = calls[index];
139
+ const parsed = parseArgs(call.arguments);
140
+ let outcome;
141
+ if (request.isDenied(call.name)) {
142
+ outcome = {
143
+ status: 'denied',
144
+ code: 'TOOL_DISABLED',
145
+ retryable: false,
146
+ output: `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`,
147
+ changedFiles: [],
148
+ durationMs: 0,
149
+ };
150
+ }
151
+ else if (!request.expandToolGroups) {
152
+ outcome = {
153
+ status: 'denied',
154
+ code: 'TOOL_DISABLED',
155
+ retryable: false,
156
+ output: '错误:当前 Agent 未启用动态工具策略,无法调用 add_tool_groups。',
157
+ changedFiles: [],
158
+ durationMs: 0,
159
+ };
160
+ }
161
+ else if (!parsed ||
162
+ !Array.isArray(parsed.groups) ||
163
+ parsed.groups.length === 0 ||
164
+ typeof parsed.reason !== 'string' ||
165
+ !parsed.reason.trim()) {
166
+ outcome = {
167
+ status: 'error',
168
+ code: 'INVALID_ARGUMENTS',
169
+ retryable: false,
170
+ output: '错误:add_tool_groups 需要非空 groups 数组和非空 reason。',
171
+ changedFiles: [],
172
+ durationMs: 0,
173
+ };
174
+ }
175
+ else {
176
+ const expansion = request.expandToolGroups(parsed.groups, parsed.reason);
177
+ const succeeded = expansion.added.length > 0;
178
+ const details = [
179
+ succeeded
180
+ ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
181
+ : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
182
+ expansion.implied.length > 0 ? `Implied groups also activated: ${expansion.implied.join(', ')}.` : '',
183
+ expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
184
+ succeeded ? 'The added tool schemas become available on the next model step.' : '',
185
+ ]
186
+ .filter(Boolean)
187
+ .join('\n');
188
+ outcome = {
189
+ status: succeeded ? 'success' : 'error',
190
+ code: succeeded ? 'OK' : 'INVALID_ARGUMENTS',
191
+ retryable: false,
192
+ output: details,
193
+ changedFiles: [],
194
+ durationMs: 0,
195
+ };
196
+ request.onEvent({
197
+ type: 'route_expand',
198
+ fromVersion: request.policy.toolPolicy?.version,
199
+ expansion,
200
+ requestedGroups: parsed.groups,
201
+ reason: parsed.reason,
202
+ status: outcome.status,
203
+ });
204
+ }
205
+ record(index, outcome);
206
+ request.onEvent({ type: 'host_outcome', call, parsed: parsed ?? {}, outcome });
207
+ resultEvent(index, outcome, null);
208
+ traceEnd(index, outcome);
206
209
  }
207
210
  }
208
211
  let index = hasRouteBarrier ? calls.length : 0;
@@ -234,11 +237,16 @@ class LegacyCompatibleToolDispatcher {
234
237
  request.onEvent({ type: 'header', call });
235
238
  request.onEvent({ type: 'start', tool: batch[0].name });
236
239
  const started = batch.map((call) => execute(call));
240
+ // 完成即发:usage/host_outcome/trace_end 在各 outcome 落地瞬间发出(1s 的 grep 不再
241
+ // 被 60s 的 run_command 卡住可见性);result(history 回灌)严格保持原序。
242
+ await Promise.all(started.map(async (pending, offset) => {
243
+ const outcome = await pending;
244
+ executionEvents(index + offset, parseArgs(batch[offset].arguments), outcome);
245
+ }));
237
246
  for (let offset = 0; offset < batch.length; offset++) {
238
247
  const callIndex = index + offset;
239
248
  const outcome = await started[offset];
240
249
  record(callIndex, outcome);
241
- executionEvents(callIndex, parseArgs(batch[offset].arguments), outcome);
242
250
  resultEvent(callIndex, outcome, null);
243
251
  }
244
252
  request.onEvent({ type: 'done' });
@@ -284,25 +292,36 @@ class LegacyCompatibleToolDispatcher {
284
292
  const firstAllowed = entries.find((entry) => !entry.denied);
285
293
  if (firstAllowed)
286
294
  request.onEvent({ type: 'start', tool: firstAllowed.call.name });
287
- for (let chunkStart = 0; chunkStart < entries.length; chunkStart += concurrency) {
288
- const chunk = entries.slice(chunkStart, chunkStart + concurrency);
289
- // 块内同时启动,再按原序 await + 回灌:完成顺序任意,history/trace 始终是原调用序。
290
- const started = chunk.map((entry) => {
291
- if (entry.denied)
292
- return Promise.resolve(entry.denied);
293
- return execute(entry.call, request.argumentErrorHint(entry.call.name), (lockedArgs) => {
294
- entry.diff = readDiffContext(entry.call, lockedArgs, jailResolve);
295
- });
296
- });
297
- for (let k = 0; k < chunk.length; k++) {
298
- const callIndex = index + chunkStart + k;
299
- const entry = chunk[k];
300
- const outcome = await started[k];
301
- record(callIndex, outcome);
302
- executionEvents(callIndex, entry.parsed, outcome);
303
- resultEvent(callIndex, outcome, entry.denied ? null : entry.parsed, entry.diff);
304
- invalidate(outcome);
295
+ // 动态补位池:启动上限 concurrency,任意一个完成立即补位下一个——静态分块时
296
+ // 「7 个任务、1 个跑 10 分钟」会让第 5 分钟起 4 个槽位空转而 6/7 号干等,总耗时
297
+ // ≈最慢链;补位后 ≈最满调度。
298
+ // 完成即发:usage/host_outcome/trace_end 在各 outcome 落地瞬间发出(快完成的
299
+ // 子 agent 不再被批内前面的慢调用卡住可见性)。history 回灌(result 事件)严格
300
+ // 保持 provider 原调用序——批校验按 assistant tool_calls 逐位配对 id。
301
+ // 实现用 worker 循环而非「补位回调 + outcomes.map」:outcomes 是稀疏数组,
302
+ // Array.prototype.map 会跳过未启动槽位的洞,补位链条会在第 N+1 个断掉。
303
+ const outcomes = new Array(entries.length);
304
+ let cursor = 0;
305
+ const runWorker = async () => {
306
+ while (cursor < entries.length) {
307
+ const position = cursor++;
308
+ const entry = entries[position];
309
+ const outcome = entry.denied
310
+ ? entry.denied
311
+ : await execute(entry.call, request.argumentErrorHint(entry.call.name), (lockedArgs) => {
312
+ entry.diff = readDiffContext(entry.call, lockedArgs, jailResolve);
313
+ });
314
+ outcomes[position] = Promise.resolve(outcome);
315
+ executionEvents(index + position, entry.parsed, outcome);
305
316
  }
317
+ };
318
+ await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, () => runWorker()));
319
+ for (let offset = 0; offset < entries.length; offset++) {
320
+ const outcome = await outcomes[offset];
321
+ const callIndex = index + offset;
322
+ record(callIndex, outcome);
323
+ resultEvent(callIndex, outcome, entries[offset].denied ? null : entries[offset].parsed, entries[offset].diff);
324
+ invalidate(outcome);
306
325
  }
307
326
  if (firstAllowed)
308
327
  request.onEvent({ type: 'done' });
@@ -352,12 +371,17 @@ class LegacyCompatibleToolDispatcher {
352
371
  entry.diff = readDiffContext(entry.call, lockedArgs, jailResolve);
353
372
  });
354
373
  });
374
+ // 完成即发:usage/host_outcome/trace_end 落地瞬间发出;result(history 回灌)严格原序
375
+ // ——批校验按 assistant tool_calls 逐位配对 id,同文件排队由 canonical file 锁保证。
376
+ await Promise.all(started.map(async (pending, offset) => {
377
+ const outcome = await pending;
378
+ executionEvents(index + offset, entries[offset].parsed, outcome);
379
+ }));
355
380
  for (let offset = 0; offset < entries.length; offset++) {
356
381
  const callIndex = index + offset;
357
382
  const entry = entries[offset];
358
383
  const outcome = await started[offset];
359
384
  record(callIndex, outcome);
360
- executionEvents(callIndex, entry.parsed, outcome);
361
385
  resultEvent(callIndex, outcome, entry.denied ? null : entry.parsed, entry.diff);
362
386
  invalidate(outcome);
363
387
  }
package/dist/headless.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // 权限:非交互默认 fail-closed(permissions/index.ts 的 !isTTY 分支)。
15
15
  // --dangerously-skip-permissions 显式关闭权限闸。
16
16
  import path from 'node:path';
17
+ import { mkdirSync, writeFileSync } from 'node:fs';
17
18
  import { setSandboxRoot } from './sandbox/root.js';
18
19
  import { registerToolsExtension } from './tools/registry.js';
19
20
  // 装配官方默认工具包:registry 不顶层 import builtins(破模块循环),入口须显式装配。
@@ -26,7 +27,8 @@ import { defaultRuntime, Runtime } from './runtime/index.js';
26
27
  import { runAgentCore } from './agent/core.js';
27
28
  import { summarizeToolCall, summarizeToolResult } from './ui/render.js';
28
29
  import { getToolChatSchema } from './tools/policy.js';
29
- import { createWorktree, removeWorktree } from './jobs/worktree.js';
30
+ import { jobsRoot, updateJob } from './jobs/store.js';
31
+ import { createWorktree, exportWorktreePatch, removeWorktree } from './jobs/worktree.js';
30
32
  import { runAsIdentity } from './bots/bus.js';
31
33
  import { createAsyncApprovalChecker } from './jobs/approval.js';
32
34
  import { writeCheckpoint, readCheckpoint } from './jobs/checkpoint.js';
@@ -257,6 +259,24 @@ export async function runHeadless(opts) {
257
259
  await runtime.close();
258
260
  if (wt) {
259
261
  try {
262
+ // worktree 成果挽留:销毁前把相对 HEAD 的改动导成 patch 落到主工作区
263
+ // .mocode/jobs/,后台任务/一次性 worktree 跑完不再"自毁成果"。job 模式顺带
264
+ // 回写 patchPath 供 /jobs 与通知引用;非 git 改动(导出失败)不阻塞清理。
265
+ const patch = exportWorktreePatch(wt);
266
+ if (patch !== null) {
267
+ const patchFile = path.join(jobsRoot(), opts.jobId ? `${opts.jobId}.patch` : `wt-${Date.now()}.patch`);
268
+ try {
269
+ mkdirSync(path.dirname(patchFile), { recursive: true });
270
+ writeFileSync(patchFile, patch, 'utf8');
271
+ process.stderr.write(`mocode: worktree 改动已导出 patch:${patchFile}\n`);
272
+ if (opts.jobId) {
273
+ updateJob(opts.jobId, { patchPath: patchFile });
274
+ }
275
+ }
276
+ catch (e) {
277
+ process.stderr.write(`mocode: patch 导出失败(${e instanceof Error ? e.message : String(e)}),worktree 改动将丢失\n`);
278
+ }
279
+ }
260
280
  removeWorktree(wt);
261
281
  }
262
282
  catch {
@@ -279,6 +299,12 @@ export async function runHeadless(opts) {
279
299
  };
280
300
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
281
301
  }
302
+ else if (result.terminationReason === 'max_steps' && result.finalText) {
303
+ // 撞限 salvage 摘要:非 json 模式也必须落 stdout。它从未被 onText 流式输出,
304
+ // 走旧的「!textBuffer 才补打」分支会在本轮早前已有叙述文本时把它整个吞掉。
305
+ // 尾部换行沿用下方 isTTY 统一补行逻辑,与 completed 路径行为一致。
306
+ process.stdout.write(result.finalText);
307
+ }
282
308
  else if (finalText && !textBuffer) {
283
309
  process.stdout.write(finalText);
284
310
  }
@@ -5,7 +5,7 @@
5
5
  // tsx loader 单进程跑 src/index.ts(不用 tsx CLI:它会 respawn 一个不 hide 的弹窗 node)。
6
6
  //
7
7
  // 通用件 buildEntryArgs / spawnDetached 也供 schedule 守护进程复用(src/schedule/daemon.ts)。
8
- import { spawn, spawnSync } from 'node:child_process';
8
+ import { spawn } from 'node:child_process';
9
9
  import { openSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -68,13 +68,19 @@ export function launchBackgroundJob(prompt, opts = {}) {
68
68
  return { record: { ...record, pid: child.pid }, child };
69
69
  }
70
70
  /**
71
- * 树杀进程并置 killed。
71
+ * 树杀进程。返回「kill 信号已成功发出」(Windows)或「信号已发送」(POSIX),不保证目标已退出。
72
72
  * Windows:taskkill /T /F 杀整棵进程树;POSIX:负 pid 杀整个进程组。
73
+ *
74
+ * 异步化:spawnSync('taskkill') 会同步阻塞整个事件循环(扫树期间流式渲染/并行工具停摆)。
75
+ * 调用方「已知 job 已死」的语义靠 exited 轮询(isDaemonRunning / reconcile),不依赖本函数的返回时序。
73
76
  */
74
- export function killTree(pid) {
77
+ export async function killTree(pid) {
75
78
  if (process.platform === 'win32') {
76
- const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
77
- return r.status === 0;
79
+ return new Promise((resolve) => {
80
+ const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
81
+ killer.on('close', (code) => resolve(code === 0));
82
+ killer.on('error', () => resolve(false));
83
+ });
78
84
  }
79
85
  try {
80
86
  process.kill(-pid, 'SIGTERM');
@@ -85,10 +91,11 @@ export function killTree(pid) {
85
91
  }
86
92
  }
87
93
  /** 树杀 job 并置 killed。 */
88
- export function killBackgroundJob(record) {
94
+ export async function killBackgroundJob(record) {
89
95
  if (typeof record.pid !== 'number')
90
96
  return false;
91
- if (!killTree(record.pid))
97
+ const ok = await killTree(record.pid);
98
+ if (!ok)
92
99
  return false;
93
100
  updateJob(record.id, { status: 'killed', finishedAt: new Date().toISOString() }, { force: true });
94
101
  return true;
@@ -44,6 +44,26 @@ export function createWorktree() {
44
44
  }
45
45
  return { path: wtPath, repoRoot };
46
46
  }
47
+ /**
48
+ * 把 worktree 相对 HEAD 的全部改动导出为 unified diff(含未跟踪文件,binary 标记为
49
+ * Binary files differ)。无改动返回 null(调用方不写空 patch 文件)。
50
+ *
51
+ * 用 spawnSync 而非异步 spawn:本函数只在 headless/runner 的收尾路径调用(事件循环
52
+ * 已无流式渲染在跑),且 removeWorktree 本身就是同步链,拆异步收益为零。
53
+ */
54
+ export function exportWorktreePatch(wt) {
55
+ const status = git(['status', '--porcelain'], wt.path);
56
+ if (!status.ok || !status.output)
57
+ return null; // 无改动 / git 不可用
58
+ // 未跟踪文件:先 git add -N(intent-to-add) 让 diff 能覆盖新文件,否则 --worktree
59
+ // 默认跳过 untracked,新建的文件会随 worktree 一起消失。--intent-to-add 不改
60
+ // index 的实际内容、不产生对象,worktree remove --force 可正常清理。
61
+ git(['add', '-N', '.'], wt.path);
62
+ const diff = git(['diff', 'HEAD', '--binary'], wt.path);
63
+ if (!diff.ok || !diff.output)
64
+ return null;
65
+ return `${diff.output}\n`;
66
+ }
47
67
  /** 先删 node_modules 链接(防误伤目标),再移除 worktree + prune。 */
48
68
  export function removeWorktree(wt) {
49
69
  const link = join(wt.path, 'node_modules');
package/dist/llm/index.js CHANGED
@@ -26,6 +26,8 @@ if (process.env.DEBUG === 'true') {
26
26
  * 不重试 → 400 (bad request) / 401 (auth) / 其他 4xx / 用户中断 (AbortError / APIUserAbortError)
27
27
  *
28
28
  * 退避:指数 + ±20% jitter,首等 1s,翻倍,封顶 30s;若后端返回 Retry-After 头则优先按其值。
29
+ * 例外:「连接类」错误(连接被掐断 / 建连层网络错)的首次重试不等待(见 computeRetryWaitMs):
30
+ * 换条新连接几乎必成,白等 1s 只让用户干看「重试中」;第二次起照常退避。
29
31
  * 默认 10 次尝试(1 初始 + 9 重试),要调改 RETRY_MAX_ATTEMPTS。
30
32
  *
31
33
  * SDK 内置 maxRetries 默认 2(对所有 5xx+网络错重试),与本策略叠加会双重重试 5xx —— 显式置 0 让
@@ -351,6 +353,36 @@ export function computeBackoff(attempt, retryAfterMs) {
351
353
  const hi = base * (1 + RETRY_JITTER);
352
354
  return Math.max(0, Math.round(lo + Math.random() * (hi - lo)));
353
355
  }
356
+ /**
357
+ * 「连接类」错误:连接被掐断 / 建连层网络错(errno 藏在 cause 链)/ SDK 建连失败包装 /
358
+ * 折叠成文案的网络错。这类错误的共同点:**重发到一条新连接几乎必成** —— 池里的旧连接
359
+ * 被代理掐死(keepalive 竞速)是最常见成因,第一次失败换条连接就好。
360
+ * 判据全部复用 isRetryableError / hasStreamBreakSignal 已有的分类原语,不引入新词表。
361
+ */
362
+ export function isConnectionClassError(err) {
363
+ if (!err || typeof err !== 'object')
364
+ return false;
365
+ const e = err;
366
+ const ctor = e.constructor?.name ?? '';
367
+ if (ctor === 'APIConnectionError' || ctor === 'APIConnectionTimeoutError')
368
+ return true;
369
+ if (hasStreamBreakSignal(err))
370
+ return true;
371
+ if (causeChainFrames(err).some((f) => !!f.code && RETRYABLE_ERRNO.has(f.code)))
372
+ return true;
373
+ const msg = typeof e.message === 'string' ? e.message : '';
374
+ return /^connection error\.?$/i.test(msg.trim()) || /\bfetch failed\b/i.test(msg);
375
+ }
376
+ /**
377
+ * 第 N 次失败后的实际等待毫秒。连接类错误的**首次**重试不等待(0ms):换条新连接几乎
378
+ * 必成,白等 1s 只让用户干看「重试中」闪烁;第二次起照常指数退避(预算与封顶都不变)。
379
+ * Retry-After 存在时优先尊重服务端指令 —— 429 限流与断流可能同源,立即重发只会火上浇油。
380
+ */
381
+ export function computeRetryWaitMs(attempt, err, retryAfterMs) {
382
+ if (attempt === 1 && retryAfterMs === undefined && isConnectionClassError(err))
383
+ return 0;
384
+ return computeBackoff(attempt, retryAfterMs);
385
+ }
354
386
  function logRetry(attempt, err, waitMs) {
355
387
  const e = err;
356
388
  const tag = e.status ? `HTTP ${e.status}` : e.name || 'Error';
@@ -535,7 +567,7 @@ async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverrid
535
567
  }
536
568
  if (streamRetryable)
537
569
  streamRetries++;
538
- const wait = computeBackoff(attempt, getRetryAfterMs(err));
570
+ const wait = computeRetryWaitMs(attempt, err, getRetryAfterMs(err));
539
571
  const retry = {
540
572
  attempt,
541
573
  nextAttempt: attempt + 1,
@@ -851,8 +883,35 @@ export function contentTokens(content) {
851
883
  }
852
884
  return estimateTokens(contentToText(content));
853
885
  }
854
- /** 估算单条消息的 token 数:结构开销 + content + tool_calls 参数。 */
886
+ /** 单条消息的 token 估算缓存:命中键 = 该消息的「内容签名」(见 messageSignature)。 */
887
+ const messageTokensCache = new WeakMap();
888
+ /**
889
+ * 消息内容签名:content 引用 + role + 有无 tool_calls。
890
+ *
891
+ * **为什么签名里用 content 的「长度」而不是内容本身**:签名要在每次估算时算,必须是 O(1)。
892
+ * 用 `typeof content === 'string' ? content.length : -1` 兼顾 O(1) 与可判定性 ——
893
+ * string 不可变,原地改写(`msg.content = ...`)必然换成另一个字符串对象、长度通常也变;
894
+ * 长度相同而内容变了的情况(如 microcompact 截到同样长度)极少,且那种路径
895
+ * (compact / age-aware)本身会先 markContextChanged 走全量重估(见 scheduler)。
896
+ *
897
+ * **为什么不直接用对象身份当键**(即 WeakMap<ChatMessage, number>):有 4 处代码会
898
+ * **原地改写** message.content —— age-aware.ts:66、artifacts.ts:269(标 stale)、
899
+ * compact.ts:590/597(微压缩)。对象身份不变但内容已变,身份缓存会返回旧值 →
900
+ * token 低估 → 80% 压力线不触发 → prompt 实际已超窗口(llm/index.ts:1042-1044 注释
901
+ * 描述过这个失败模式:「模型突然抽风」)。签名方案让这4 处自动安全,无需挂失效钩子。
902
+ */
903
+ function messageSignature(m) {
904
+ const c = m.content;
905
+ const tcs = m.tool_calls;
906
+ return `${m.role ?? ''}|${typeof c === 'string' ? c.length : -1}|${tcs ? 1 : 0}`;
907
+ }
908
+ /** 估算单条消息的 token 数:结构开销 + content + tool_calls 参数。内容签名命中则复用上次结果。 */
855
909
  export function messageTokens(m) {
910
+ const key = m;
911
+ const sig = messageSignature(m);
912
+ const hit = messageTokensCache.get(key);
913
+ if (hit !== undefined && hit.sig === sig)
914
+ return hit.tokens;
856
915
  const role = m.role;
857
916
  let structural = 4; // {role}\n{content}\n 框架基线
858
917
  if (role === 'system')
@@ -865,7 +924,9 @@ export function messageTokens(m) {
865
924
  for (const tc of tcs)
866
925
  body += estimateTokens(tc?.function?.arguments ?? '');
867
926
  }
868
- return structural + body;
927
+ const tokens = structural + body;
928
+ messageTokensCache.set(key, { sig, tokens });
929
+ return tokens;
869
930
  }
870
931
  /** 估算整段 messages 的 token 数(不含工具 schema,含 priming 常数)。 */
871
932
  export function estimateMessagesTokens(messages) {
package/dist/llm/proxy.js CHANGED
@@ -72,6 +72,19 @@ function windowsProxyOptions() {
72
72
  noProxy,
73
73
  };
74
74
  }
75
+ /**
76
+ * 空闲 keepalive 连接的存活时长(毫秒)。
77
+ *
78
+ * undici 默认 4s:编码会话里工具执行(npm test/build)几乎总超过 4s,下一次 LLM 请求时
79
+ * 连接已被池回收,经 Clash 重新做 TCP+TLS 握手(实测 1.4–8s),每个 step 都吃一次;
80
+ * 「undici 拿到刚被代理掐死的连接」也正是偶发 ECONNRESET 的来源。拉长到 60s 让空闲
81
+ * 连接活过典型的工具执行间隔,复用率上升。可用 MOCODE_KEEPALIVE_MS 覆盖(非正数回退默认)。
82
+ */
83
+ const DEFAULT_KEEPALIVE_TIMEOUT_MS = 60_000;
84
+ function keepAliveTimeoutMs() {
85
+ const raw = Number(process.env.MOCODE_KEEPALIVE_MS);
86
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_KEEPALIVE_TIMEOUT_MS;
87
+ }
75
88
  export function installEnvProxy() {
76
89
  if (installed)
77
90
  return;
@@ -93,7 +106,17 @@ export function installEnvProxy() {
93
106
  return originalEmitWarning(...args);
94
107
  });
95
108
  try {
96
- setGlobalDispatcher(new EnvHttpProxyAgent(options));
109
+ // keepalive 调参:EnvHttpProxyAgent 把 ...agentOpts 透传给内部 Agent/ProxyAgent
110
+ // (undici 6.21 env-http-proxy-agent.js 构造器),Client 层消费这三个选项。
111
+ // threshold 刻意高于默认 2s:undici 会在「服务端 Keep-Alive 头声明的存活期 − threshold」
112
+ // 时刻掐自己的空闲连接,若代理声明的存活期略长于我们的 keepAliveTimeout,留出余量
113
+ // 可避免双方同时收线时撞上刚被掐死的连接(那正是 ECONNRESET 的形态)。
114
+ setGlobalDispatcher(new EnvHttpProxyAgent({
115
+ ...(options ?? {}),
116
+ keepAliveTimeout: keepAliveTimeoutMs(),
117
+ keepAliveMaxTimeout: 600_000,
118
+ keepAliveTimeoutThreshold: 5_000,
119
+ }));
97
120
  }
98
121
  finally {
99
122
  process.emitWarning = originalEmitWarning;