mingdao-harness 0.4.4 → 0.4.5
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/package.json +1 -1
- package/src/agent.js +34 -7
- package/src/atomic-write.js +9 -0
- package/src/cachestats.js +5 -4
- package/src/cli.js +29 -14
- package/src/commands/repl.js +5 -3
- package/src/cost-guard.js +26 -6
- package/src/memory.js +6 -5
- package/src/model-caps.js +41 -2
- package/src/pricing.js +16 -5
- package/src/providers/index.js +18 -9
- package/src/providers/openai-compatible.js +42 -1
- package/src/schedule.js +62 -30
- package/src/skill-lib.js +23 -4
- package/src/skills.js +2 -2
- package/src/tasks/worker.js +4 -1
- package/src/tasks.js +4 -1
- package/src/tools/fetch.js +2 -1
- package/src/tools/git.js +8 -0
- package/src/web/server.js +25 -9
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -27,7 +27,7 @@ const SUBAGENT_MAX_STEPS = 24;
|
|
|
27
27
|
* 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
|
|
28
28
|
* @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
|
|
29
29
|
* cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
|
|
30
|
-
* onUsage?: (usage: any) => void }} params
|
|
30
|
+
* onUsage?: (modelName: string, usage: any) => void }} params
|
|
31
31
|
*/
|
|
32
32
|
export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage }) {
|
|
33
33
|
const preset = modelPreset(modelName) || {};
|
|
@@ -112,6 +112,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
112
112
|
maxSteps: SUBAGENT_MAX_STEPS,
|
|
113
113
|
mcp,
|
|
114
114
|
sessionRef, // 子代理的审计记录归入主会话
|
|
115
|
+
onUsage, // P1-5(v0.4.5):透传逐轮入账回调——子代理消耗计入今日费用(子代理内部 activeModel=subModel 正确归属)
|
|
115
116
|
});
|
|
116
117
|
const sys =
|
|
117
118
|
`你是主智能体 MingDao 派出的子代理,独立完成一项子任务。` +
|
|
@@ -194,7 +195,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
194
195
|
const inFlightCost = () => estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, null, new Date());
|
|
195
196
|
const usedTodayWithInflight = () => {
|
|
196
197
|
const today = todayCost();
|
|
197
|
-
|
|
198
|
+
if (today == null) return null;
|
|
199
|
+
const inflight = inFlightCost();
|
|
200
|
+
// P0-4(v0.4.5):无价模型在途费用为 null(未知)——整体视为「无法判断」而非 +null→today 的静默忽略
|
|
201
|
+
return inflight == null ? null : today + inflight;
|
|
198
202
|
};
|
|
199
203
|
// 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
|
|
200
204
|
// 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
|
|
@@ -305,8 +309,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
305
309
|
for (const m of sanitized) promptTokens += messageTokens(m, count);
|
|
306
310
|
const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
|
|
307
311
|
const used = usedTodayWithInflight(); // 含本回合在途费用(防长回合烧穿)
|
|
308
|
-
if (used == null) {
|
|
309
|
-
//
|
|
312
|
+
if (used == null || worst == null) {
|
|
313
|
+
// P0-4(v0.4.5):统计不可读 或 无价格数据(最坏成本未知)→ 无法判断,跳过前置拦截
|
|
314
|
+
// (护栏主检查 checkCostGuard 会显式告警「无价格数据」,此处不重复误拦也不静默放行)
|
|
310
315
|
} else if (used + worst >= Number(g.dailyLimitYuan)) {
|
|
311
316
|
stripOrphanCalls();
|
|
312
317
|
return {
|
|
@@ -321,7 +326,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
321
326
|
// 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
|
|
322
327
|
// downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
|
|
323
328
|
if (cfg.costGuard) {
|
|
324
|
-
const guard = checkCostGuard();
|
|
329
|
+
const guard = checkCostGuard(activeModel);
|
|
325
330
|
if (guard) {
|
|
326
331
|
if (guard.blocked) {
|
|
327
332
|
stripOrphanCalls();
|
|
@@ -410,6 +415,24 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
410
415
|
stripOrphanCalls();
|
|
411
416
|
return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
412
417
|
}
|
|
418
|
+
// MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
|
|
419
|
+
// 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
|
|
420
|
+
const e = /** @type {any} */ (err);
|
|
421
|
+
if (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || ''))) {
|
|
422
|
+
stripOrphanCalls();
|
|
423
|
+
return {
|
|
424
|
+
text: null,
|
|
425
|
+
reasoning: '',
|
|
426
|
+
usage,
|
|
427
|
+
steps,
|
|
428
|
+
finish,
|
|
429
|
+
truncated: false,
|
|
430
|
+
aborted: false,
|
|
431
|
+
note: '本地模型内存不足(507 memory_refusal)——请压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后再继续。',
|
|
432
|
+
durationMs: Date.now() - startedAt,
|
|
433
|
+
perf: perf(),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
413
436
|
throw err;
|
|
414
437
|
}
|
|
415
438
|
|
|
@@ -523,6 +546,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
523
546
|
let allowed = false;
|
|
524
547
|
if (isMcp && mcp?.isReadonly(name)) {
|
|
525
548
|
allowed = true; // MCP 工具的只读标注自动放行
|
|
549
|
+
} else if (name === 'task' && args.readOnly === true) {
|
|
550
|
+
allowed = true; // P2-2(v0.4.5):只读子代理自动放行——它本身只读(readOnly 子代理只能读),
|
|
551
|
+
// 与 release note「可派 readOnly 子代理」意图一致;非 readOnly 的 task 仍走权限询问
|
|
526
552
|
} else {
|
|
527
553
|
try {
|
|
528
554
|
allowed = await permission.check(name, args);
|
|
@@ -740,9 +766,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
740
766
|
}
|
|
741
767
|
// v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
|
|
742
768
|
if (round < maxRounds - 1 && !aborted) {
|
|
743
|
-
// 每轮结束:回调本轮增量 usage
|
|
769
|
+
// 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账。activeModel 为本轮实际模型
|
|
770
|
+
// (护栏降级/子代理都据此正确归属——P2-5 模型名一致性 + P1-5 子代理费用入账)。
|
|
744
771
|
try {
|
|
745
|
-
onUsage?.({
|
|
772
|
+
onUsage?.(activeModel, {
|
|
746
773
|
prompt_tokens: usage.prompt_tokens - roundUsageStart.prompt_tokens,
|
|
747
774
|
completion_tokens: usage.completion_tokens - roundUsageStart.completion_tokens,
|
|
748
775
|
prompt_cache_hit_tokens: (usage.prompt_cache_hit_tokens || 0) - roundUsageStart.prompt_cache_hit_tokens,
|
package/src/atomic-write.js
CHANGED
|
@@ -31,10 +31,17 @@ export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any
|
|
|
31
31
|
|
|
32
32
|
// 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步);异常/完成释放。
|
|
33
33
|
// 进程崩溃遗留的陈旧锁(> staleMs 未更新)自动回收,避免永久卡死。
|
|
34
|
+
// 可重入(P0-1 修复,v0.4.5):本进程已持该锁时直接执行 fn——O_EXCL 锁不可重入,此前
|
|
35
|
+
// killTask 持锁内调 patchTask 二次抢同一把锁自死锁 5s(tasks kill/pause/remove 全失效)。
|
|
34
36
|
const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
35
37
|
const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
|
|
38
|
+
const heldLocks = new Set(); // 本进程当前持有的锁路径(可重入判定)
|
|
36
39
|
|
|
37
40
|
export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
|
|
41
|
+
// 可重入:同一调用栈内已持该锁 → 直接执行,不再二次抢锁
|
|
42
|
+
if (heldLocks.has(lockPath)) {
|
|
43
|
+
return fn();
|
|
44
|
+
}
|
|
38
45
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
39
46
|
const t0 = Date.now();
|
|
40
47
|
for (;;) {
|
|
@@ -45,9 +52,11 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
|
|
|
45
52
|
} finally {
|
|
46
53
|
fs.closeSync(fd);
|
|
47
54
|
}
|
|
55
|
+
heldLocks.add(lockPath);
|
|
48
56
|
try {
|
|
49
57
|
return fn();
|
|
50
58
|
} finally {
|
|
59
|
+
heldLocks.delete(lockPath);
|
|
51
60
|
try {
|
|
52
61
|
fs.unlinkSync(lockPath);
|
|
53
62
|
} catch {}
|
package/src/cachestats.js
CHANGED
|
@@ -5,7 +5,6 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
7
7
|
import { estimateCost, cacheSplit, beijingDayStart, beijingParts } from './pricing.js';
|
|
8
|
-
import { modelPreset } from './models.js';
|
|
9
8
|
import { withFileLockSync, atomicWriteFileSync } from './atomic-write.js';
|
|
10
9
|
|
|
11
10
|
export function cacheStatsFile() {
|
|
@@ -114,10 +113,12 @@ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usa
|
|
|
114
113
|
let cost = null;
|
|
115
114
|
let saved = null;
|
|
116
115
|
if (split) {
|
|
116
|
+
const base = estimateCost(modelName, prompt, completion, null);
|
|
117
117
|
cost = estimateCost(modelName, prompt, completion, split);
|
|
118
|
-
|
|
119
|
-
} else
|
|
120
|
-
//
|
|
118
|
+
if (base != null && cost != null) saved = base - cost;
|
|
119
|
+
} else {
|
|
120
|
+
// P0-4(v0.4.5):estimateCost 无价返 null,直接记录 null(未知)而非 0(免费)——
|
|
121
|
+
// 覆盖内置定价 + 外部定价 + config.pricing.overrides 三条来源,不再依赖 modelPreset 单一判断。
|
|
121
122
|
cost = estimateCost(modelName, prompt, completion, null);
|
|
122
123
|
}
|
|
123
124
|
recordCacheStats({
|
package/src/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
reconcileSchedules,
|
|
33
33
|
runSleeper,
|
|
34
34
|
formatScheduleRow,
|
|
35
|
+
postRunStatus,
|
|
35
36
|
} from './schedule.js';
|
|
36
37
|
import { createAgent } from './agent.js';
|
|
37
38
|
import { saveTaskStateMerge, clearTaskState } from './task-state.js';
|
|
@@ -120,21 +121,28 @@ function printHelpLines(/** @type {any} */ out) {
|
|
|
120
121
|
|
|
121
122
|
function parseArgs(/** @type {any} */ argv) {
|
|
122
123
|
const opts = /** @type {Record<string, any>} */ ({ prompt: [], model: null, continueSession: false, resume: false, format: 'text' });
|
|
124
|
+
// P1-2 / P2-24(v0.4.5):遇第一个位置参数后停止解析全局 flag——此前贪婪匹配任意位置的 --model/init,
|
|
125
|
+
// 导致 `mingdao run "任务" --model X` 的 --model 被顶层剥除(后台/定时指定模型静默失效),
|
|
126
|
+
// 且提问文本含「init」即误触发初始化向导。此后子命令 flag 原样收入 prompt 交子命令解析。
|
|
127
|
+
let seenArg = false;
|
|
123
128
|
for (let i = 0; i < argv.length; i++) {
|
|
124
129
|
const a = argv[i];
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
if (!seenArg) {
|
|
131
|
+
if (a === '-h' || a === '--help') { opts.help = true; continue; }
|
|
132
|
+
else if (a === '-v' || a === '--version') { opts.version = true; continue; }
|
|
133
|
+
else if (a === '-c' || a === '--continue') { opts.continueSession = true; continue; }
|
|
134
|
+
else if (a === '--journal') { opts.journal = true; continue; }
|
|
135
|
+
else if (a === '-p' || a === '--preset') { opts.preset = argv[++i]; continue; }
|
|
136
|
+
else if (a.startsWith('--preset=')) { opts.preset = a.slice(9); continue; }
|
|
137
|
+
else if (a === '-r' || a === '--resume') { opts.resume = true; continue; }
|
|
138
|
+
else if (a === '--init' || a === 'init') { opts.init = true; continue; }
|
|
139
|
+
else if (a === '-m' || a === '--model') { opts.model = argv[++i]; continue; }
|
|
140
|
+
else if (a.startsWith('--model=')) { opts.model = a.slice(8); continue; }
|
|
141
|
+
else if (a === '-f' || a === '--format') { opts.format = argv[++i] || 'text'; continue; }
|
|
142
|
+
else if (a.startsWith('--format=')) { opts.format = a.slice(9); continue; }
|
|
143
|
+
}
|
|
144
|
+
seenArg = true;
|
|
145
|
+
opts.prompt.push(a);
|
|
138
146
|
}
|
|
139
147
|
if (!['text', 'json'].includes(opts.format)) opts.format = 'text';
|
|
140
148
|
return opts;
|
|
@@ -289,7 +297,14 @@ async function main() {
|
|
|
289
297
|
}
|
|
290
298
|
if (t.status !== 'running') {
|
|
291
299
|
const result = t.status === 'done' ? 'done' : t.status === 'timedout' ? 'timedout' : 'failed';
|
|
292
|
-
|
|
300
|
+
// P1-4(v0.4.5):every 周期任务崩溃恢复不能终态化——复用 postRunStatus 重排下一期回 pending,
|
|
301
|
+
// 否则周期任务静默永停(此前与 once/after 一视同仁写成 done/failed,reconcile 永远跳过)。
|
|
302
|
+
if (j.kind === 'every') {
|
|
303
|
+
const ns = postRunStatus(j, result);
|
|
304
|
+
await writeSchedule(home0, { ...j, ...(ns || { status: result }), lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
|
|
305
|
+
} else {
|
|
306
|
+
await writeSchedule(home0, { ...j, status: result, lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
|
|
307
|
+
}
|
|
293
308
|
continue;
|
|
294
309
|
}
|
|
295
310
|
continue; // worker 仍在跑:等它(外层 2s 轮询)
|
package/src/commands/repl.js
CHANGED
|
@@ -617,13 +617,14 @@ export async function runRepl(ctx) {
|
|
|
617
617
|
);
|
|
618
618
|
} else io.print('尚无用量记录。');
|
|
619
619
|
} else if (cmd === '/status') {
|
|
620
|
+
const sc = estimateCost(modelName, stats.promptTokens, stats.completionTokens);
|
|
620
621
|
io.box('会话状态', [
|
|
621
622
|
`模型 ${modelName} · 权限 ${permission.mode}`,
|
|
622
623
|
`沙箱 ${cfg.sandbox || 'off'}${routing ? ` · 自动路由 ${routingEnabled ? '开' : '关'}(${routing.planner}⇄${routing.executor})` : ''}`,
|
|
623
624
|
`会话 ${path.basename(session.file)}`,
|
|
624
625
|
`轮次 ${stats.turns} · 消息 ${messages.length} 条`,
|
|
625
626
|
`Tokens ↑${stats.promptTokens} ↓${stats.completionTokens}`,
|
|
626
|
-
`费用
|
|
627
|
+
`费用 ${sc == null ? '≈¥—(无价格数据)' : '≈¥' + sc.toFixed(5)}(累计·按当前模型计价)`,
|
|
627
628
|
`计划模式 ${planMode ? '开' : '关'} · 思考显示 ${io.showReasoning ? '开' : '关'} · 任务 ${agent.getTodos().length} 项`,
|
|
628
629
|
]);
|
|
629
630
|
} else if (cmd === '/cost') {
|
|
@@ -634,7 +635,7 @@ export async function runRepl(ctx) {
|
|
|
634
635
|
`缓存命中率 ${bd.rate != null ? (bd.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}${bd.batchCost > 0 ? ` · Batch 半价任务 ≈¥${bd.batchCost.toFixed(5)}` : ''}`,
|
|
635
636
|
...bd.byModel.slice(0, 8).map((m) => ` ${m.model}:${m.turns} 轮(${m.batchTurns ? m.batchTurns + ' 批' : ''})· ↑${m.prompt} ↓${m.completion} · ≈¥${m.cost.toFixed(5)}${m.saved > 0 ? ` · 省 ¥${m.saved.toFixed(5)}` : ''}`),
|
|
636
637
|
]);
|
|
637
|
-
const guard = costGuardStatus();
|
|
638
|
+
const guard = costGuardStatus(modelName);
|
|
638
639
|
if (guard) {
|
|
639
640
|
io.print(
|
|
640
641
|
style(
|
|
@@ -643,7 +644,8 @@ export async function runRepl(ctx) {
|
|
|
643
644
|
)
|
|
644
645
|
);
|
|
645
646
|
}
|
|
646
|
-
|
|
647
|
+
const sc2 = estimateCost(modelName, stats.promptTokens, stats.completionTokens);
|
|
648
|
+
io.print(style('会话内累计(本次)' + (sc2 == null ? '≈¥—(无价格数据)' : '≈¥' + sc2.toFixed(5)), C.dim));
|
|
647
649
|
} else if (cmd === '/cache') {
|
|
648
650
|
const entries = listCacheStats();
|
|
649
651
|
if (!entries.length) {
|
package/src/cost-guard.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { listCacheStats, cacheStatsFile } from './cachestats.js';
|
|
9
|
-
import { beijingDayStart } from './pricing.js';
|
|
9
|
+
import { beijingDayStart, hasPricing } from './pricing.js';
|
|
10
10
|
import { loadConfig } from './config.js';
|
|
11
11
|
|
|
12
12
|
export function costGuardConfig() {
|
|
@@ -44,13 +44,22 @@ export function todayCost() {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* @param {any} [modelName] 实际使用模型;缺省回退 config.model(仪表盘/无会话上下文)
|
|
49
|
+
*/
|
|
50
|
+
export function costGuardStatus(modelName) {
|
|
48
51
|
const g = costGuardConfig();
|
|
49
52
|
if (!g) return null;
|
|
50
53
|
const cost = todayCost();
|
|
51
54
|
const limit = Number(g.dailyLimitYuan) || 0;
|
|
52
55
|
const warnAt = Number(g.warnAtYuan) || (limit > 0 ? limit * 0.8 : 0);
|
|
53
56
|
const action = g.action === 'block' || g.action === 'downgrade' ? g.action : 'warn';
|
|
57
|
+
// P0-4(v0.4.5):当前模型无价格数据时费用护栏静默失效(estimateCost 恒 0、overLimit 恒 false)。
|
|
58
|
+
// 显式标记 noPricing,调用方据此告警而非静默放行。modelName 优先取「实际使用模型」(agent 传 activeModel),
|
|
59
|
+
// 缺省回退 config.model(仪表盘/无会话上下文);两者都无则无法判断,不误标 noPricing。
|
|
60
|
+
const cfg = loadConfig();
|
|
61
|
+
const model = modelName ?? cfg?.model ?? null;
|
|
62
|
+
const noPricing = limit > 0 && model != null && !hasPricing(model);
|
|
54
63
|
return {
|
|
55
64
|
cost,
|
|
56
65
|
limit,
|
|
@@ -58,16 +67,27 @@ export function costGuardStatus() {
|
|
|
58
67
|
action,
|
|
59
68
|
downgradeModel: String(g.downgradeModel || 'deepseek-v4-flash'),
|
|
60
69
|
degraded: cost === null, // 统计不可读:护栏降级为「无法判断」
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
noPricing, // 无价格数据:费用护栏无法累计,需显式告警
|
|
71
|
+
overWarn: cost !== null && !noPricing && limit > 0 && cost >= warnAt,
|
|
72
|
+
overLimit: cost !== null && !noPricing && limit > 0 && cost >= limit,
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
// Agent 每轮开始前调用:返回 null 放行;blocked=true 应暂停本轮;downgrade=true 应切换便宜模型
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
/**
|
|
78
|
+
* @param {any} [modelName] 实际使用模型;缺省回退 config.model
|
|
79
|
+
*/
|
|
80
|
+
export function checkCostGuard(modelName) {
|
|
81
|
+
const st = costGuardStatus(modelName);
|
|
69
82
|
if (!st) return null;
|
|
70
83
|
if (st.degraded) return null; // 统计不可读:不误拦也不放水(已告警),按无法判断处理
|
|
84
|
+
if (st.noPricing) {
|
|
85
|
+
// P0-4(v0.4.5):无价格数据时护栏无法累计——显式告警而非静默放行(绝不当「没花钱」)
|
|
86
|
+
return {
|
|
87
|
+
blocked: false,
|
|
88
|
+
message: '⚠ 费用护栏:当前模型无价格数据,今日费用无法累计、dailyLimitYuan 不生效——请在 config.pricing.overrides 为模型补充定价,或改用有价的 DeepSeek 模型(deepseek-v4-pro/flash)。',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
71
91
|
if (st.overLimit && st.action === 'block') {
|
|
72
92
|
return {
|
|
73
93
|
blocked: true,
|
package/src/memory.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
10
|
import { beijingParts } from './pricing.js';
|
|
11
11
|
import { tokenize } from './session-index.js';
|
|
12
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
12
13
|
|
|
13
14
|
export function memoryFile() {
|
|
14
15
|
return path.join(mingdaoHome(), 'AGENTS.md');
|
|
@@ -47,7 +48,7 @@ function backupMemory() {
|
|
|
47
48
|
export function writeMemory(/** @type {any} */ content) {
|
|
48
49
|
backupMemory();
|
|
49
50
|
ensureHome();
|
|
50
|
-
|
|
51
|
+
atomicWriteFileSync(memoryFile(), String(content ?? ''));
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
// 去重:忽略日期前缀后内容相同的条目只保留第一条
|
|
@@ -70,7 +71,7 @@ export function dedupeMemory() {
|
|
|
70
71
|
}
|
|
71
72
|
if (removed > 0) {
|
|
72
73
|
backupMemory();
|
|
73
|
-
|
|
74
|
+
atomicWriteFileSync(memoryFile(), kept.join('\n') + '\n');
|
|
74
75
|
}
|
|
75
76
|
return removed;
|
|
76
77
|
}
|
|
@@ -91,7 +92,7 @@ export function removeMemoryLines(/** @type {any} */ keyword) {
|
|
|
91
92
|
}
|
|
92
93
|
if (removed > 0) {
|
|
93
94
|
backupMemory();
|
|
94
|
-
|
|
95
|
+
atomicWriteFileSync(memoryFile(), kept.join('\n'));
|
|
95
96
|
}
|
|
96
97
|
return removed;
|
|
97
98
|
}
|
|
@@ -117,7 +118,7 @@ export function appendJournal(/** @type {any} */ home, /** @type {any} */ entry)
|
|
|
117
118
|
const raw = fs.readFileSync(journalFile(), 'utf8');
|
|
118
119
|
const lines = raw.split('\n').filter(Boolean);
|
|
119
120
|
if (lines.length > 600) {
|
|
120
|
-
|
|
121
|
+
atomicWriteFileSync(journalFile(), lines.slice(-500).join('\n') + '\n');
|
|
121
122
|
journalCount = 500;
|
|
122
123
|
}
|
|
123
124
|
} catch {}
|
|
@@ -294,7 +295,7 @@ export function dedupeProjectMemory(/** @type {any} */ workingDir) {
|
|
|
294
295
|
kept.push(t);
|
|
295
296
|
}
|
|
296
297
|
if (removed > 0) {
|
|
297
|
-
try {
|
|
298
|
+
try { atomicWriteFileSync(projectMemoryFile(workingDir), kept.join('\n') + '\n'); } catch {}
|
|
298
299
|
}
|
|
299
300
|
return removed;
|
|
300
301
|
}
|
package/src/model-caps.js
CHANGED
|
@@ -7,6 +7,8 @@ import { modelPreset } from './models.js';
|
|
|
7
7
|
// fc00::/7、fe80::/10、::、::1、IPv4-mapped)——此前只查 IPv4 与 ::1,IPv6 本地模型被误判远程
|
|
8
8
|
// (超时档位错),且与 fetch 工具/SSRF 判定各维护一份、口径漂移。
|
|
9
9
|
import { isPrivateHost } from './tools/fetch.js';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
10
12
|
|
|
11
13
|
// 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
|
|
12
14
|
export const UNKNOWN_LOCAL_WINDOW = 32768;
|
|
@@ -21,11 +23,45 @@ export const COMFORT_RATIO = 0.75;
|
|
|
21
23
|
export const EDGE_RATIO = 0.85;
|
|
22
24
|
|
|
23
25
|
/** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
|
|
26
|
+
// MacBook 本地 507 根因(v0.4.5):自定义主机名(如 mtplx.server.openai 经 /etc/hosts 指向 127.0.0.1)
|
|
27
|
+
// 的字面量判定抓不到——isPrivateHost(hostname) 对非 IP/非 localhost 恒 false,导致本地模型被误判远程:
|
|
28
|
+
// 只读子代理不串行(9 路大 prefill 并发击穿内存)、压缩触发线用远程档(0.8 而非 0.6)、超时档位错。
|
|
29
|
+
// 同步查 /etc/hosts(含 Windows)复检「主机名 → 私网/回环 IP」的映射,命中即视为本地。
|
|
30
|
+
const hostsCache = /** @type {Map<string, boolean>} */ (new Map()); // hostname → 是否 /etc/hosts 映射私网
|
|
31
|
+
function hostsMapsToPrivate(/** @type {string} */ hostname) {
|
|
32
|
+
if (hostsCache.has(hostname)) return Boolean(hostsCache.get(hostname));
|
|
33
|
+
let mapped = false;
|
|
34
|
+
try {
|
|
35
|
+
const file = process.platform === 'win32'
|
|
36
|
+
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
|
|
37
|
+
: '/etc/hosts';
|
|
38
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
39
|
+
for (const line of text.split(/\r?\n/)) {
|
|
40
|
+
const clean = line.replace(/#.*/, '').trim();
|
|
41
|
+
if (!clean) continue;
|
|
42
|
+
const parts = clean.split(/\s+/);
|
|
43
|
+
const ip = parts[0];
|
|
44
|
+
if (!ip || !isPrivateHost(ip)) continue;
|
|
45
|
+
if (parts.slice(1).some((h) => h.toLowerCase() === hostname)) {
|
|
46
|
+
mapped = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
mapped = false; // 无 /etc/hosts 或不可读:维持字面量判定
|
|
52
|
+
}
|
|
53
|
+
hostsCache.set(hostname, mapped);
|
|
54
|
+
return mapped;
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
|
|
25
58
|
try {
|
|
26
59
|
const u = new URL(String(baseUrl || ''));
|
|
27
60
|
if (!u.hostname) return false;
|
|
28
|
-
|
|
61
|
+
const host = u.hostname.toLowerCase();
|
|
62
|
+
if (isPrivateHost(host)) return true;
|
|
63
|
+
// 自定义主机名(经 /etc/hosts 指向 127.0.0.1 等私网地址)复检;否则维持「远程」
|
|
64
|
+
return hostsMapsToPrivate(host);
|
|
29
65
|
} catch {
|
|
30
66
|
return false;
|
|
31
67
|
}
|
|
@@ -41,7 +77,10 @@ export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ mode
|
|
|
41
77
|
const preset = modelPreset(modelName);
|
|
42
78
|
const cm = (cfg?.customModels || {})[modelName] || {};
|
|
43
79
|
const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
|
|
44
|
-
|
|
80
|
+
// 显式声明优先:customModels.<name>.local=true/isLocal=true 强制按本地模型处理(压缩/串行/超时走本地档),
|
|
81
|
+
// 覆盖 baseUrl 字面量/hosts 判定不到的场景(如经公网反代回本机、特殊主机名)。
|
|
82
|
+
const explicitLocal = cm.local === true || cm.isLocal === true;
|
|
83
|
+
const isLocal = explicitLocal || isLocalBaseUrl(baseUrl);
|
|
45
84
|
const contextWindow =
|
|
46
85
|
Number(cm.contextWindow) > 0
|
|
47
86
|
? Number(cm.contextWindow)
|
package/src/pricing.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { modelPreset } from './models.js';
|
|
10
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
10
11
|
import { mingdaoHome } from './config.js';
|
|
11
12
|
|
|
12
13
|
// 内置价格表的数据时点(定价可能调整,配置覆盖可随时更新)
|
|
@@ -63,7 +64,7 @@ export async function refreshPricingFromSource(cfg) {
|
|
|
63
64
|
}
|
|
64
65
|
const file = pricingFilePath();
|
|
65
66
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
66
|
-
|
|
67
|
+
atomicWriteFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
|
|
67
68
|
extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
|
|
68
69
|
const names = Object.keys(models).join('、');
|
|
69
70
|
return { ok: true, lines: ['✓ 价格表已刷新(' + names + '),TTL 内费用估算/护栏/避峰自动跟随'] };
|
|
@@ -214,6 +215,13 @@ function pricingOverrides() {
|
|
|
214
215
|
/**
|
|
215
216
|
* @param {any} modelName
|
|
216
217
|
*/
|
|
218
|
+
// P0-4(v0.4.5):判断模型是否有价格数据(内置定价或外部定价或 overrides)——
|
|
219
|
+
// 无价模型 estimateCost 恒 0,费用护栏/仪表盘/分账静默失真,必须显式暴露给调用方告警。
|
|
220
|
+
export function hasPricing(/** @type {any} */ modelName) {
|
|
221
|
+
return Boolean(modelName && effectivePricing(modelName));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {any} modelName */
|
|
217
225
|
function effectivePricing(modelName) {
|
|
218
226
|
const preset = modelPreset(modelName);
|
|
219
227
|
const ext = externalPricing().data?.models?.[modelName] || null;
|
|
@@ -250,7 +258,9 @@ function effectivePricing(modelName) {
|
|
|
250
258
|
*/
|
|
251
259
|
export function estimateCost(modelName, promptTokens, completionTokens, cache = null, date = new Date()) {
|
|
252
260
|
const pricing = effectivePricing(modelName);
|
|
253
|
-
|
|
261
|
+
// P0-4(v0.4.5):无价返 null 而非 0——0 与「未知」语义完全混淆(0 被当作「免费/没花钱」),
|
|
262
|
+
// 下游护栏/分账/仪表盘据此静默失真。返回 null 强制调用方显式区分「未知」与「免费(0)」(技术评估 P0-4)。
|
|
263
|
+
if (!pricing) return null;
|
|
254
264
|
const price = isPeakHour(date) ? pricing.peak : pricing.offpeak;
|
|
255
265
|
if (cache && Number.isFinite(cache.hit) && Number.isFinite(cache.miss)) {
|
|
256
266
|
const hitPrice = price.cacheHit ?? 0;
|
|
@@ -268,10 +278,11 @@ export function estimateCost(modelName, promptTokens, completionTokens, cache =
|
|
|
268
278
|
* @param {any} [usage]
|
|
269
279
|
*/
|
|
270
280
|
export function estimateCostLabel(modelName, promptTokens, completionTokens, usage = null) {
|
|
271
|
-
const pricing = effectivePricing(modelName);
|
|
272
|
-
if (!pricing) return '';
|
|
273
281
|
const cache = cacheSplit(usage);
|
|
274
|
-
const
|
|
282
|
+
const c = estimateCost(modelName, promptTokens, completionTokens, cache);
|
|
283
|
+
// P0-4(v0.4.5):无价返 null → 标签置空(不显示「≈¥0.0000」冒充免费)
|
|
284
|
+
if (c == null) return '';
|
|
285
|
+
const yuan = c.toFixed(5);
|
|
275
286
|
const hitPart =
|
|
276
287
|
cache && cache.hit + cache.miss > 0 ? ` · 缓存命中 ${(cache.rate * 100).toFixed(0)}%` : ' · 未计缓存折扣';
|
|
277
288
|
return ` ≈¥${yuan}(${isPeakHour() ? '高峰' : '闲时'}${hitPart})${pricingDataStale() ? ' · ⚠ 价格表过期,运行 mingdao update --pricing 刷新' : ''}`;
|
package/src/providers/index.js
CHANGED
|
@@ -92,14 +92,20 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
92
92
|
config: pc,
|
|
93
93
|
async chat(/** @type {any} */ opts) {
|
|
94
94
|
let attempt = 0;
|
|
95
|
+
// P1-7(v0.4.5):总量护栏提到重试循环外层——此前每 attempt 重建 totalTimer,重试序列最坏
|
|
96
|
+
// 放大 retries+1 倍(本地 3×30min=90min)。总时长覆盖整个重试序列,单次首 token/流式空闲仍按 attempt 计。
|
|
97
|
+
let totalExpired = false;
|
|
98
|
+
let currentAc = /** @type {AbortController | null} */ (null);
|
|
99
|
+
const totalTimer = setTimeout(() => {
|
|
100
|
+
totalExpired = true;
|
|
101
|
+
try {
|
|
102
|
+
currentAc?.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
|
|
103
|
+
} catch {}
|
|
104
|
+
}, totalMs);
|
|
95
105
|
for (;;) {
|
|
96
106
|
const ac = new AbortController();
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
const totalTimer = setTimeout(() => {
|
|
100
|
-
timedOut = true;
|
|
101
|
-
ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
|
|
102
|
-
}, totalMs);
|
|
107
|
+
currentAc = ac;
|
|
108
|
+
let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
|
|
103
109
|
// 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
|
|
104
110
|
let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
|
|
105
111
|
timedOut = true;
|
|
@@ -125,6 +131,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
125
131
|
const onUserAbort = () => ac.abort(opts.signal?.reason);
|
|
126
132
|
if (opts.signal?.aborted) onUserAbort();
|
|
127
133
|
else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
|
|
134
|
+
let leaving = true; // 本次 attempt 是否退出(成功/不再重试);将重试则置 false,总量计时器保留覆盖下一 attempt
|
|
128
135
|
try {
|
|
129
136
|
return await openaiChat({
|
|
130
137
|
...opts,
|
|
@@ -135,9 +142,11 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
135
142
|
onActivity,
|
|
136
143
|
});
|
|
137
144
|
} catch (err) {
|
|
138
|
-
// 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C
|
|
139
|
-
|
|
145
|
+
// 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试。
|
|
146
|
+
// 总量超时(totalExpired)覆盖整个序列,超了直接抛不再重试。
|
|
147
|
+
const transient = !totalExpired && ((timedOut && !opts.signal?.aborted) || isTransient(err));
|
|
140
148
|
if (!transient || attempt >= retries) throw err;
|
|
149
|
+
leaving = false; // 将重试:本次不退出,总量计时器继续覆盖下一 attempt
|
|
141
150
|
attempt += 1;
|
|
142
151
|
// 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
|
|
143
152
|
// 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
|
|
@@ -147,7 +156,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
147
156
|
backoff = Math.min(backoff, 30000);
|
|
148
157
|
await sleep(backoff);
|
|
149
158
|
} finally {
|
|
150
|
-
clearTimeout(totalTimer);
|
|
159
|
+
if (leaving) clearTimeout(totalTimer); // 成功/最终失败即清——避免悬挂 totalMs 计时器(每请求一个)
|
|
151
160
|
if (firstTokenTimer) clearTimeout(firstTokenTimer);
|
|
152
161
|
if (idleTimer) clearTimeout(idleTimer);
|
|
153
162
|
opts.signal?.removeEventListener('abort', onUserAbort);
|
|
@@ -6,6 +6,20 @@
|
|
|
6
6
|
* @typedef {Error & { status?: number, headers?: Headers }} ApiError
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
// 部分网关/本地推理框架在拒绝请求(如 507 memory_refusal)时不回非 2xx,而是回
|
|
10
|
+
// 200 + {"error":{"code":507,"message":"memory_refusal"}},或 SSE 首帧夹带 error 对象。
|
|
11
|
+
// 若不识别,会被当作「空输出」吞掉,误报成「模型本轮没有输出正文。」(MacBook 本地 mtplx 507 根因)。
|
|
12
|
+
function extractStreamError(/** @type {any} */ json) {
|
|
13
|
+
const err = json?.error;
|
|
14
|
+
if (!err) return null;
|
|
15
|
+
const code = Number(err?.code || err?.status_code || err?.status || 0);
|
|
16
|
+
const msg = String(err?.message || err?.type || '模型返回错误');
|
|
17
|
+
/** @type {ApiError} */
|
|
18
|
+
const e = new Error(`[流式响应错误${code ? ' ' + code : ''}] ${msg}`);
|
|
19
|
+
if (code) e.status = code;
|
|
20
|
+
return e;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
10
24
|
const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
|
11
25
|
const payload = /** @type {Record<string, any>} */ ({ model, messages });
|
|
@@ -68,6 +82,8 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
|
|
85
|
+
const se = extractStreamError(json);
|
|
86
|
+
if (se) throw se;
|
|
71
87
|
const choice = json?.choices?.[0];
|
|
72
88
|
const msg = choice?.message ?? {};
|
|
73
89
|
if (msg.content) onDelta?.({ text: msg.content });
|
|
@@ -113,6 +129,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
113
129
|
} catch {
|
|
114
130
|
return;
|
|
115
131
|
}
|
|
132
|
+
// 200 流里夹带 error 帧(本地内存拒绝等):立即上抛,不当作空输出吞掉
|
|
133
|
+
const se = extractStreamError(json);
|
|
134
|
+
if (se) throw se;
|
|
116
135
|
// 注意:DeepSeek/OpenAI 流式最后一块常是 usage-only(choices 为空),必须先取 usage 再判 choices
|
|
117
136
|
if (json.usage) usage = json.usage;
|
|
118
137
|
const choice = json?.choices?.[0];
|
|
@@ -147,9 +166,31 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
147
166
|
if (choice.finish_reason) finish = choice.finish_reason;
|
|
148
167
|
};
|
|
149
168
|
|
|
169
|
+
// P1-6(v0.4.5):[DONE] 后不再无限等待网关关流——此前外层 for 继续 read 至流关闭,
|
|
170
|
+
// 部分网关/代理在 [DONE] 后不主动断开会挂到 streamIdleMs(120s)。但部分网关在 [DONE] 之后
|
|
171
|
+
// 才发 usage-only 终包(且末帧常不带换行),直接退出会丢 usage。因此进入「有界排空」:
|
|
172
|
+
// 继续读残余帧,每次 read 用短超时兜底,超时或流关闭即停止——usage 尾帧仍被捕获,
|
|
173
|
+
// 正常流在 [DONE] 后立即关流,Promise.race 随即以 done 返回,不增加延迟。
|
|
174
|
+
const DRAIN_TIMEOUT_MS = 500;
|
|
175
|
+
const readDrain = async () => {
|
|
176
|
+
let t;
|
|
177
|
+
const timer = new Promise((resolve) => {
|
|
178
|
+
t = setTimeout(() => resolve({ done: false, value: null, timedOut: true }), DRAIN_TIMEOUT_MS);
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
return await Promise.race([reader.read(), timer]);
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(t);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
150
186
|
for (;;) {
|
|
151
|
-
const { done, value } = await reader.read();
|
|
187
|
+
const { done, value } = await (doneFlag ? readDrain() : reader.read());
|
|
152
188
|
if (done) break;
|
|
189
|
+
if (value == null) {
|
|
190
|
+
// 排空超时:不再等,取消 reader 释放连接(防火墙/网关持流不关的场景)
|
|
191
|
+
reader.cancel().catch(() => {});
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
153
194
|
buf += decoder.decode(value, { stream: true });
|
|
154
195
|
let nl;
|
|
155
196
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
package/src/schedule.js
CHANGED
|
@@ -171,7 +171,10 @@ export function removeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
// 正在跑的 worker 同步停止,避免成孤儿继续执行
|
|
174
|
-
|
|
174
|
+
// P0-2(v0.4.5):killTask 失败不应阻断「删除」这一更强用户意图——包 try/catch 保证删除照常
|
|
175
|
+
try {
|
|
176
|
+
if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
|
|
177
|
+
} catch {}
|
|
175
178
|
try {
|
|
176
179
|
fs.unlinkSync(path.join(scheduleDir(home), id + '.json'));
|
|
177
180
|
} catch {}
|
|
@@ -192,7 +195,10 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
192
195
|
} catch {}
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
|
-
|
|
198
|
+
// P0-2(v0.4.5):killTask 失败不应阻断「暂停」的状态写
|
|
199
|
+
try {
|
|
200
|
+
if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
|
|
201
|
+
} catch {}
|
|
196
202
|
writeSchedule(home, { ...job, status: 'paused', pid: null, lastTaskId: null });
|
|
197
203
|
return true;
|
|
198
204
|
});
|
|
@@ -354,6 +360,15 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
354
360
|
|
|
355
361
|
const wait = (/** @type {any} */ ms) => new Promise((r) => setTimeout(r, ms));
|
|
356
362
|
|
|
363
|
+
// P1-15(v0.4.5):标记 running 前加锁并复查 paused——此前锁外 writeSchedule({...cur,'running'})
|
|
364
|
+
// 与 pauseSchedule 锁内写 paused 交错时,running 会覆盖 pause(任务继续触发,pause 语义失效)。
|
|
365
|
+
const markRunning = () => withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
366
|
+
const c = readSchedule(home, id);
|
|
367
|
+
if (!c || c.status === 'paused') return false; // 已删除或已暂停:不覆盖、不再触发
|
|
368
|
+
writeSchedule(home, { ...c, status: 'running' });
|
|
369
|
+
return true;
|
|
370
|
+
});
|
|
371
|
+
|
|
357
372
|
// 依赖检查:支持两种依赖——调度任务 id(chain 编排)或后台任务 id(mingdao run 输出)
|
|
358
373
|
async function depsSatisfied(/** @type {any} */ deps) {
|
|
359
374
|
for (const dep of deps) {
|
|
@@ -376,8 +391,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
376
391
|
// 顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
|
|
377
392
|
if (job.offpeak && isPeakHour(new Date())) {
|
|
378
393
|
const defer = deferToOffpeak(new Date());
|
|
379
|
-
|
|
380
|
-
|
|
394
|
+
// P2-4(v0.4.5):note 更新同样加锁 + 复查 paused(避免「读 curN 到写回之间 pause」被覆盖)
|
|
395
|
+
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
396
|
+
const curN = readSchedule(home, id);
|
|
397
|
+
if (curN && curN.status !== 'paused') writeSchedule(home, { ...curN, note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行)` });
|
|
398
|
+
});
|
|
381
399
|
await wait(defer.getTime() - Date.now() + 2000);
|
|
382
400
|
}
|
|
383
401
|
if (job.after?.length) {
|
|
@@ -406,26 +424,32 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
406
424
|
killTask(home, task.id);
|
|
407
425
|
t = { ...t, status: 'timedout' };
|
|
408
426
|
}
|
|
409
|
-
const cur0 = readSchedule(home, id);
|
|
410
|
-
if (!cur0) return 'failed'; // 任务已被删除:停止后续写入
|
|
411
|
-
const history = [...(cur0?.history || [])];
|
|
412
|
-
history.push({
|
|
413
|
-
taskId: task.id,
|
|
414
|
-
status: t?.status || 'unknown',
|
|
415
|
-
at: Date.now(),
|
|
416
|
-
durationMs: t?.durationMs ?? null,
|
|
417
|
-
text: (t?.text || t?.error || '').slice(0, 200),
|
|
418
|
-
});
|
|
419
|
-
if (history.length > 50) history.shift();
|
|
420
427
|
const result = t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
history
|
|
427
|
-
|
|
428
|
+
// 审计(H3 + 自检 P2):runOnce 收尾元数据读-改-写加锁——与 pause/remove(锁内写)跨进程互斥,
|
|
429
|
+
// 杜绝「读 cur0 到写回之间用户 pause」被覆盖(毫秒级窗口,彻底起见与终态写同锁、同复查)。
|
|
430
|
+
const wrote = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
431
|
+
const curL = readSchedule(home, id);
|
|
432
|
+
if (!curL) return false; // 已删除:停止后续写入
|
|
433
|
+
const historyL = [...(curL?.history || [])];
|
|
434
|
+
historyL.push({
|
|
435
|
+
taskId: task.id,
|
|
436
|
+
status: t?.status || 'unknown',
|
|
437
|
+
at: Date.now(),
|
|
438
|
+
durationMs: t?.durationMs ?? null,
|
|
439
|
+
text: (t?.text || t?.error || '').slice(0, 200),
|
|
440
|
+
});
|
|
441
|
+
if (historyL.length > 50) historyL.shift();
|
|
442
|
+
writeSchedule(home, {
|
|
443
|
+
...curL,
|
|
444
|
+
lastRunAt: Date.now(),
|
|
445
|
+
lastTaskId: task.id,
|
|
446
|
+
runs: (curL?.runs || 0) + 1,
|
|
447
|
+
history: historyL,
|
|
448
|
+
consecutiveFailures: result === 'done' ? 0 : prevFails + 1,
|
|
449
|
+
});
|
|
450
|
+
return true;
|
|
428
451
|
});
|
|
452
|
+
if (!wrote) return 'failed'; // 任务已被删除
|
|
429
453
|
return result;
|
|
430
454
|
};
|
|
431
455
|
|
|
@@ -438,7 +462,7 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
438
462
|
await wait(Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000));
|
|
439
463
|
continue;
|
|
440
464
|
}
|
|
441
|
-
|
|
465
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused(被暂停则直接退出)
|
|
442
466
|
const result = await runOnce();
|
|
443
467
|
// 质检 H3:状态读-改-写加锁(与 pause/remove 互斥,防丢更新)
|
|
444
468
|
const nextState = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
@@ -456,12 +480,14 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
456
480
|
await wait(Math.min(cur.nextRunAt - now, 60000));
|
|
457
481
|
continue;
|
|
458
482
|
}
|
|
459
|
-
|
|
483
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused
|
|
460
484
|
const result = await runOnce();
|
|
461
|
-
// 评估 6.4(v0.4.3):once
|
|
485
|
+
// 评估 6.4(v0.4.3)+ P2-4(v0.4.5):once 最终状态读-改-写加锁,且 paused 不覆盖——
|
|
486
|
+
// 用户执行期间 pause(cur2.status==='paused')绝不被 done/failed 覆盖(every 经 postRunStatus 有防护,此处补齐)
|
|
462
487
|
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
463
488
|
const cur2 = readSchedule(home, id);
|
|
464
|
-
if (cur2
|
|
489
|
+
if (!cur2 || cur2.status === 'paused') return;
|
|
490
|
+
writeSchedule(home, { ...cur2, status: result });
|
|
465
491
|
});
|
|
466
492
|
return;
|
|
467
493
|
} else {
|
|
@@ -472,15 +498,21 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
472
498
|
continue;
|
|
473
499
|
}
|
|
474
500
|
if (st === 'failed') {
|
|
475
|
-
|
|
501
|
+
// P2-4(v0.4.5):终态写加锁 + 复查 paused(依赖失败判 skipped 也不得覆盖 pause)
|
|
502
|
+
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
503
|
+
const c2 = readSchedule(home, id);
|
|
504
|
+
if (!c2 || c2.status === 'paused') return;
|
|
505
|
+
writeSchedule(home, { ...c2, status: 'skipped' });
|
|
506
|
+
});
|
|
476
507
|
return;
|
|
477
508
|
}
|
|
478
|
-
|
|
509
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused
|
|
479
510
|
const result = await runOnce();
|
|
480
|
-
// 评估 6.4(v0.4.3):after
|
|
511
|
+
// 评估 6.4(v0.4.3)+ P2-4(v0.4.5):after 最终状态读-改-写加锁,且 paused 不覆盖
|
|
481
512
|
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
482
513
|
const cur2 = readSchedule(home, id);
|
|
483
|
-
if (cur2
|
|
514
|
+
if (!cur2 || cur2.status === 'paused') return;
|
|
515
|
+
writeSchedule(home, { ...cur2, status: result });
|
|
484
516
|
});
|
|
485
517
|
return;
|
|
486
518
|
}
|
package/src/skill-lib.js
CHANGED
|
@@ -188,10 +188,26 @@ export function validateSkillMarkdown(text, hint) {
|
|
|
188
188
|
return { ok: true, name, description: desc };
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
// P1-5(v0.4.5):检测目录树内是否含符号链接——symlink 可越权读任意本机文件并架空 sha256 指纹检测。
|
|
192
|
+
function containsSymlink(/** @type {string} */ dir) {
|
|
193
|
+
const stack = [dir];
|
|
194
|
+
while (stack.length) {
|
|
195
|
+
const d = /** @type {string} */ (stack.pop());
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (const e of entries) {
|
|
203
|
+
if (e.isSymbolicLink()) return true;
|
|
204
|
+
if (e.isDirectory()) stack.push(path.join(d, e.name));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @param {any} dir @param {any} hint */
|
|
195
211
|
export function validateSkillDir(dir, hint) {
|
|
196
212
|
const skillMd = path.join(dir, 'SKILL.md');
|
|
197
213
|
let text;
|
|
@@ -200,6 +216,9 @@ export function validateSkillDir(dir, hint) {
|
|
|
200
216
|
} catch {
|
|
201
217
|
return { error: `未找到 SKILL.md:${skillMd}` };
|
|
202
218
|
}
|
|
219
|
+
if (containsSymlink(dir)) {
|
|
220
|
+
return { error: '技能目录含符号链接,已拒绝(防越权读取本机文件/绕过篡改检测)' };
|
|
221
|
+
}
|
|
203
222
|
return validateSkillMarkdown(text, hint);
|
|
204
223
|
}
|
|
205
224
|
|
package/src/skills.js
CHANGED
|
@@ -63,7 +63,7 @@ export function tamperedSkillNames(/** @type {any} */ workingDir) {
|
|
|
63
63
|
if (!e.isDirectory()) continue;
|
|
64
64
|
const skillDir = path.join(dir, e.name);
|
|
65
65
|
try {
|
|
66
|
-
if (!fs.
|
|
66
|
+
if (!fs.lstatSync(path.join(skillDir, 'SKILL.md')).isFile()) continue;
|
|
67
67
|
} catch {
|
|
68
68
|
continue;
|
|
69
69
|
}
|
|
@@ -89,7 +89,7 @@ export function listSkills(/** @type {any} */ workingDir) {
|
|
|
89
89
|
if (!e.isDirectory() || seen.has(e.name)) continue;
|
|
90
90
|
const skillMd = path.join(dir, e.name, 'SKILL.md');
|
|
91
91
|
try {
|
|
92
|
-
if (!fs.
|
|
92
|
+
if (!fs.lstatSync(skillMd).isFile()) continue;
|
|
93
93
|
} catch {
|
|
94
94
|
continue;
|
|
95
95
|
}
|
package/src/tasks/worker.js
CHANGED
|
@@ -104,7 +104,10 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
|
|
|
104
104
|
if (title) renameSessionFile(fs, path, home, session, title);
|
|
105
105
|
} catch {}
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
// P1-3(v0.4.5):capHit(跑满步数上限后收尾)语义是「未真正完成、可续跑」——此前 worker 不判
|
|
108
|
+
// capHit 直接判 done,后台任务/链式编排把未完成任务当成功(假完成)。
|
|
109
|
+
const finalStatus = res.capHit ? 'failed' : res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
|
|
110
|
+
if (res.capHit && !note) note = '达到步数上限,任务未完成(可续跑)。';
|
|
108
111
|
recordUsage(res.perf?.usedModel || modelName, res.usage, /** @type {any} */ (res.perf));
|
|
109
112
|
finish({
|
|
110
113
|
status: finalStatus,
|
package/src/tasks.js
CHANGED
|
@@ -115,7 +115,10 @@ function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
115
115
|
try {
|
|
116
116
|
owned = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').includes(id);
|
|
117
117
|
} catch {}
|
|
118
|
-
|
|
118
|
+
// P0-3(v0.4.5):owned===null(非 Linux 无 /proc,或无法读取)时降级为「按 pid 存活即杀」——
|
|
119
|
+
// 此前 null 直接跳过,导致 macOS/Windows 上 kill 只改状态不杀进程、worker 继续跑完覆盖状态。
|
|
120
|
+
// 任务 id 含随机 + 启动时 pid,PID 复用误杀概率极低,且任务 id 本就是用户显式指定的目标。
|
|
121
|
+
if (owned === true || owned === null) {
|
|
119
122
|
try {
|
|
120
123
|
// worker 是 detached 进程(自成进程组):优先杀整组,避免工具子进程成孤儿
|
|
121
124
|
process.kill(-t.pid, 'SIGTERM');
|
package/src/tools/fetch.js
CHANGED
|
@@ -49,7 +49,6 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
|
|
|
49
49
|
let cur = u;
|
|
50
50
|
let res = /** @type {any} */ (null);
|
|
51
51
|
for (let hop = 0; hop <= 5; hop++) {
|
|
52
|
-
if (hop > 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
|
|
53
52
|
const ch = String(cur.hostname || '').toLowerCase();
|
|
54
53
|
let hopBlocked = isPrivateHost(ch);
|
|
55
54
|
if (!hopBlocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
|
|
@@ -63,6 +62,8 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
|
|
|
63
62
|
if (hopBlocked) return { ok: false, error: `拒绝访问内网/本机地址(${ch})——SSRF 重定向防护。` };
|
|
64
63
|
res = await fetch(cur, { signal: ac.signal, redirect: 'manual' });
|
|
65
64
|
if (res.status >= 300 && res.status < 400) {
|
|
65
|
+
// P2-3(v0.4.5):原 `if(hop>5)` 死代码(hop 最大 5 永不触发)——超限应在 3xx 分支内判定
|
|
66
|
+
if (hop >= 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
|
|
66
67
|
const loc = res.headers.get('location');
|
|
67
68
|
if (!loc) break; // 无 Location:按最终响应处理
|
|
68
69
|
try {
|
package/src/tools/git.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
|
|
5
5
|
const GIT_READONLY = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'branch', 'tag', 'ls-files', 'shortlog']);
|
|
6
|
+
// P1-8(v0.4.5):参数级过滤——此前只校验子命令首词,`diff --no-index` 可越界读任意文件(git∈
|
|
7
|
+
// READONLY_TOOLS 免权限确认)、`branch -D`/`tag -f/-d` 破坏元数据、`--output` 写文件全部放行。
|
|
8
|
+
const BANNED_FLAGS = new Set(['--no-index', '--output', '-o', '--delete', '-D', '-d', '--force', '-f', '-m', '--move', '-M', '--rename']);
|
|
6
9
|
|
|
7
10
|
export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
8
11
|
const command = String(args.command ?? '').trim();
|
|
@@ -14,6 +17,11 @@ export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
14
17
|
if (!GIT_READONLY.has(sub)) {
|
|
15
18
|
return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
|
|
16
19
|
}
|
|
20
|
+
// P1-8(v0.4.5):拒绝破坏性/越界 flag——`--no-index` 越界读、`--output` 写、`-D/-d/-f/-m` 破坏元数据
|
|
21
|
+
const banned = argv.find((/** @type {string} */ a) => BANNED_FLAGS.has(a) || a.startsWith('--output='));
|
|
22
|
+
if (banned) {
|
|
23
|
+
return { ok: false, error: `git ${sub} 含被禁止的参数 ${banned}——只读工具不允许写文件/越界读/破坏元数据(写操作请用 bash)。` };
|
|
24
|
+
}
|
|
17
25
|
// 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)——
|
|
18
26
|
// v0.4.1 P2 修复:此前注释声明限量但无实现,git log -p 在大型仓库会撞 maxBuffer 4MB 报 ENOBUFS。
|
|
19
27
|
let effectiveArgv = argv;
|
package/src/web/server.js
CHANGED
|
@@ -85,15 +85,30 @@ function readBody(req, limit = 40 * 1024 * 1024) {
|
|
|
85
85
|
/** @type {any[]} */
|
|
86
86
|
const chunks = [];
|
|
87
87
|
let size = 0;
|
|
88
|
+
let settled = false;
|
|
89
|
+
const settle = (/** @type {any} */ fn, /** @type {any} */ v) => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(slowTimer);
|
|
93
|
+
fn(v);
|
|
94
|
+
};
|
|
88
95
|
// 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
|
|
89
96
|
const slowTimer = setTimeout(() => {
|
|
90
97
|
const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传超时(60s)'));
|
|
91
98
|
err.status = 408;
|
|
92
99
|
req.destroy();
|
|
93
|
-
reject
|
|
100
|
+
settle(reject, err);
|
|
94
101
|
}, 60000);
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
// P1-6(v0.4.5):body 上传中断(客户端刷新/abort)时 Node 通常只触发 close 不发 error/end——
|
|
103
|
+
// 此前 close 仅 clearTimeout 不 reject → Promise 永久 pending → /api/chat 的 inflight 槽永久泄漏,
|
|
104
|
+
// 重复 N 次后服务对所有会话 429。close 且未 settle 时 reject(status 499)。
|
|
105
|
+
req.on('close', () => {
|
|
106
|
+
if (!settled) {
|
|
107
|
+
const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传中断(客户端断开)'));
|
|
108
|
+
err.status = 499;
|
|
109
|
+
settle(reject, err);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
97
112
|
req.on('data', (/** @type {any} */ d) => {
|
|
98
113
|
size += d.length;
|
|
99
114
|
if (size > MAX_BODY) {
|
|
@@ -102,7 +117,7 @@ function readBody(req, limit = 40 * 1024 * 1024) {
|
|
|
102
117
|
// 排空残余数据但保留连接:让上层 catch 返回真 413(此前 destroy 导致客户端只收到连接重置)
|
|
103
118
|
req.pause();
|
|
104
119
|
req.resume();
|
|
105
|
-
reject
|
|
120
|
+
settle(reject, err);
|
|
106
121
|
return;
|
|
107
122
|
}
|
|
108
123
|
chunks.push(d);
|
|
@@ -110,12 +125,12 @@ function readBody(req, limit = 40 * 1024 * 1024) {
|
|
|
110
125
|
req.on('end', () => {
|
|
111
126
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
112
127
|
try {
|
|
113
|
-
resolve
|
|
128
|
+
settle(resolve, raw ? JSON.parse(raw) : {});
|
|
114
129
|
} catch {
|
|
115
|
-
resolve
|
|
130
|
+
settle(resolve, {});
|
|
116
131
|
}
|
|
117
132
|
});
|
|
118
|
-
req.on('error', reject);
|
|
133
|
+
req.on('error', (/** @type {any} */ e) => settle(reject, e));
|
|
119
134
|
});
|
|
120
135
|
}
|
|
121
136
|
|
|
@@ -513,14 +528,15 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
513
528
|
return;
|
|
514
529
|
}
|
|
515
530
|
// v0.4.4:长任务费用逐轮入账——记录已按轮入账的累计 usage,最终补记最后一轮 + 兜底总结的剩余。
|
|
531
|
+
// v0.4.5:onUsage 收 (modelName, delta)——主/子代理各自按实际模型归属(子代理经 spawnTask 透传,费用不漏计)。
|
|
516
532
|
const recorded = { prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0 };
|
|
517
|
-
const onUsage = (/** @type {any} */ delta) => {
|
|
533
|
+
const onUsage = (/** @type {string} */ modelName, /** @type {any} */ delta) => {
|
|
518
534
|
recorded.prompt_tokens += delta?.prompt_tokens || 0;
|
|
519
535
|
recorded.completion_tokens += delta?.completion_tokens || 0;
|
|
520
536
|
recorded.prompt_cache_hit_tokens += delta?.prompt_cache_hit_tokens || 0;
|
|
521
537
|
recorded.prompt_cache_miss_tokens += delta?.prompt_cache_miss_tokens || 0;
|
|
522
538
|
try {
|
|
523
|
-
recordUsage(
|
|
539
|
+
recordUsage(modelName, delta, null);
|
|
524
540
|
} catch {}
|
|
525
541
|
};
|
|
526
542
|
const agent = createAgent({
|