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.
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/dist/agent/model-turn.js +6 -1
- package/dist/agent/run-coordinator.js +220 -149
- package/dist/agent/stages/tool-dispatcher.js +150 -126
- package/dist/headless.js +27 -1
- package/dist/jobs/launch.js +14 -7
- package/dist/jobs/worktree.js +20 -0
- package/dist/llm/index.js +64 -3
- package/dist/llm/proxy.js +24 -1
- package/dist/repl/commands/diff.js +189 -0
- package/dist/repl/commands/jobs.js +2 -2
- package/dist/repl/commands/registry.js +2 -0
- package/dist/repl/commands.js +1 -0
- package/dist/repl/runtime.js +29 -10
- package/dist/rollback/store.js +56 -1
- package/dist/runtime/dev-server-manager.js +27 -5
- package/dist/schedule/cli.js +1 -1
- package/dist/schedule/daemon.js +3 -3
- package/dist/session/store.js +108 -32
- package/dist/skills/builtin-skills.js +99 -59
- package/dist/tools/builtins/index.js +10 -2
- package/dist/tools/builtins/run-command.js +32 -10
- package/dist/tools/policy.js +2 -2
- package/dist/tools/resource-lock.js +45 -4
- package/dist/tools/ripgrep-client.js +9 -1
- package/dist/ui/batch.js +5 -4
- package/dist/ui/layout-internal/content-write.js +154 -17
- package/dist/ui/layout-internal/core.js +18 -1
- package/dist/ui/layout-internal/state.js +14 -2
- package/dist/verification/discovery.js +1 -1
- package/dist/verification/prompt.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
109
|
+
executionEvents(index, parseArgs(calls[index].arguments), outcome);
|
|
178
110
|
resultEvent(index, outcome, null);
|
|
179
|
-
|
|
111
|
+
// done 紧跟最后一个只读结果(控制调用在尾部时与旧事件序完全一致)。
|
|
112
|
+
if (index === lastReadonlyIndex)
|
|
113
|
+
request.onEvent({ type: 'done' });
|
|
114
|
+
continue;
|
|
180
115
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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:
|
|
122
|
+
code: 'TOOL_DISABLED',
|
|
192
123
|
retryable: false,
|
|
193
|
-
output:
|
|
194
|
-
?
|
|
195
|
-
:
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 {
|
|
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
|
}
|
package/dist/jobs/launch.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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;
|
package/dist/jobs/worktree.js
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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;
|