mingdao-harness 0.4.3 → 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/presets/local-audit.json +2 -2
- package/src/agent.js +49 -7
- package/src/atomic-write.js +9 -0
- package/src/cachestats.js +5 -4
- package/src/cli.js +30 -15
- package/src/commands/repl.js +5 -3
- package/src/cost-guard.js +26 -6
- package/src/hooks.js +1 -0
- package/src/mcp.js +19 -4
- 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 +84 -43
- package/src/skill-lib.js +23 -4
- package/src/skills.js +2 -2
- package/src/tasks/worker.js +5 -2
- package/src/tasks.js +4 -1
- package/src/tools/fetch.js +2 -1
- package/src/tools/git.js +8 -0
- package/src/web/app.js +18 -7
- package/src/web/attachments.js +2 -1
- package/src/web/routes/domains/workspace.js +3 -0
- package/src/web/server.js +65 -11
- package/src/workspace.js +5 -9
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,17 +125,17 @@ 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
|
|
|
122
|
-
/** @param {{ host?: string, port?: number, authToken?: string|null, [key: string]: any }} [opts] */
|
|
123
|
-
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken } = {}) {
|
|
137
|
+
/** @param {{ host?: string, port?: number, authToken?: string|null, onBusy?: (busy: boolean) => void, [key: string]: any }} [opts] */
|
|
138
|
+
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken, onBusy } = {}) {
|
|
124
139
|
const home = ensureHome();
|
|
125
140
|
const cfg = loadConfig();
|
|
126
141
|
if (!cfg) {
|
|
@@ -206,7 +221,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
206
221
|
let mcpManager = null;
|
|
207
222
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
208
223
|
// 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
|
|
209
|
-
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
|
|
224
|
+
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir, cfg).catch(() => null);
|
|
210
225
|
mcpManager = await Promise.race([
|
|
211
226
|
mcpStartP,
|
|
212
227
|
new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
|
|
@@ -230,6 +245,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
230
245
|
let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
|
|
231
246
|
const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
|
|
232
247
|
let taskSeq = 0;
|
|
248
|
+
// 忙状态通知(v0.4.3 network error 修复):有 running 任务即「忙」——桌面版据此在生成期
|
|
249
|
+
// 防睡眠/防熄屏(macOS 熄屏会中断 Chromium 网络栈导致 SSE 断连)。onBusy 由调用方注入。
|
|
250
|
+
let lastBusy = false;
|
|
251
|
+
function notifyBusy() {
|
|
252
|
+
const busy = [...tasks.values()].some((/** @type {any} */ t) => t.status === 'running');
|
|
253
|
+
if (busy !== lastBusy) {
|
|
254
|
+
lastBusy = busy;
|
|
255
|
+
try {
|
|
256
|
+
onBusy?.(busy);
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
233
260
|
// 会话文件互斥(质检 C1/M3):同一 session 文件的 append 与 compact 整文件重写必须串行
|
|
234
261
|
const sessionLocks = new Map();
|
|
235
262
|
/** @param {any} file @param {any} fn */
|
|
@@ -321,6 +348,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
321
348
|
const entry = /** @type {any} */ ({ res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 });
|
|
322
349
|
srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
|
|
323
350
|
tasks.set(taskId, entry);
|
|
351
|
+
notifyBusy(); // 进入 running → 忙
|
|
324
352
|
/** @param {any} obj */
|
|
325
353
|
const send = (obj) => {
|
|
326
354
|
try {
|
|
@@ -350,6 +378,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
350
378
|
const built = buildUserContent(userMessage, body.attachments, visionSupported);
|
|
351
379
|
if (built.error) {
|
|
352
380
|
entry.status = 'failed';
|
|
381
|
+
notifyBusy();
|
|
353
382
|
send({ type: 'error', message: built.error });
|
|
354
383
|
clearInterval(progressTimer);
|
|
355
384
|
res.end();
|
|
@@ -490,6 +519,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
490
519
|
providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
|
|
491
520
|
} catch (/** @type {any} */ err) {
|
|
492
521
|
entry.status = 'failed';
|
|
522
|
+
notifyBusy();
|
|
493
523
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
494
524
|
send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
|
|
495
525
|
clearInterval(progressTimer);
|
|
@@ -497,6 +527,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
497
527
|
pruneTasks();
|
|
498
528
|
return;
|
|
499
529
|
}
|
|
530
|
+
// v0.4.4:长任务费用逐轮入账——记录已按轮入账的累计 usage,最终补记最后一轮 + 兜底总结的剩余。
|
|
531
|
+
// v0.4.5:onUsage 收 (modelName, delta)——主/子代理各自按实际模型归属(子代理经 spawnTask 透传,费用不漏计)。
|
|
532
|
+
const recorded = { prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0 };
|
|
533
|
+
const onUsage = (/** @type {string} */ modelName, /** @type {any} */ delta) => {
|
|
534
|
+
recorded.prompt_tokens += delta?.prompt_tokens || 0;
|
|
535
|
+
recorded.completion_tokens += delta?.completion_tokens || 0;
|
|
536
|
+
recorded.prompt_cache_hit_tokens += delta?.prompt_cache_hit_tokens || 0;
|
|
537
|
+
recorded.prompt_cache_miss_tokens += delta?.prompt_cache_miss_tokens || 0;
|
|
538
|
+
try {
|
|
539
|
+
recordUsage(modelName, delta, null);
|
|
540
|
+
} catch {}
|
|
541
|
+
};
|
|
500
542
|
const agent = createAgent({
|
|
501
543
|
provider: providerNow,
|
|
502
544
|
permission,
|
|
@@ -511,6 +553,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
511
553
|
withSessionLock(session.file, () => rewriteSession(session.file, msgs)).catch(() => {}); // onCompact 为同步回调:入队串行即可
|
|
512
554
|
persistedBefore = msgs.length;
|
|
513
555
|
},
|
|
556
|
+
// 每轮结束回调本轮增量 usage → 逐轮入账(长任务期间「今日费用」实时累计)
|
|
557
|
+
onUsage,
|
|
514
558
|
// 审计记录归入当前会话(P3-5)
|
|
515
559
|
sessionRef: { name: path.basename(session.file) },
|
|
516
560
|
});
|
|
@@ -531,6 +575,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
531
575
|
entry.abortHandler?.();
|
|
532
576
|
} catch {}
|
|
533
577
|
entry.status = 'failed';
|
|
578
|
+
notifyBusy();
|
|
534
579
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
535
580
|
pruneTasks();
|
|
536
581
|
}
|
|
@@ -544,7 +589,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
544
589
|
session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
|
|
545
590
|
await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
|
|
546
591
|
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
547
|
-
|
|
592
|
+
// v0.4.4:补记最后一轮 + 兜底总结的剩余 usage(自动续跑轮已在 onUsage 逐轮入账,避免重复计)
|
|
593
|
+
const remaining = {
|
|
594
|
+
prompt_tokens: Math.max(0, (r.usage?.prompt_tokens || 0) - recorded.prompt_tokens),
|
|
595
|
+
completion_tokens: Math.max(0, (r.usage?.completion_tokens || 0) - recorded.completion_tokens),
|
|
596
|
+
prompt_cache_hit_tokens: Math.max(0, (r.usage?.prompt_cache_hit_tokens || 0) - recorded.prompt_cache_hit_tokens),
|
|
597
|
+
prompt_cache_miss_tokens: Math.max(0, (r.usage?.prompt_cache_miss_tokens || 0) - recorded.prompt_cache_miss_tokens),
|
|
598
|
+
};
|
|
599
|
+
recordUsage(r.perf?.usedModel || runModel, remaining, /** @type {any} */ (r.perf));
|
|
548
600
|
// v0.3.0 P0-3:轮末提取项目记忆(有工具工作才提,fire-and-forget)。写文件供「未来会话」用,
|
|
549
601
|
// 当前会话用快照(上文已注入),故不会中途改系统提示、不破坏前缀缓存。
|
|
550
602
|
if (io.stats().toolCount > 0) {
|
|
@@ -579,6 +631,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
579
631
|
clearTaskState(finalSessionName);
|
|
580
632
|
}
|
|
581
633
|
entry.status = r.aborted ? 'aborted' : 'done';
|
|
634
|
+
notifyBusy();
|
|
582
635
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
583
636
|
srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
|
|
584
637
|
// 预算可视化(评估 A5):会话当前 token 占用 / 预算
|
|
@@ -607,6 +660,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
607
660
|
});
|
|
608
661
|
} catch (/** @type {any} */ err) {
|
|
609
662
|
entry.status = 'failed';
|
|
663
|
+
notifyBusy();
|
|
610
664
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
611
665
|
srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));
|
|
612
666
|
send({ type: 'error', message: String(err?.message || err) });
|
package/src/workspace.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
9
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
9
10
|
|
|
10
11
|
export function workspacesFile() {
|
|
11
12
|
return path.join(mingdaoHome(), 'workspaces.json');
|
|
@@ -23,11 +24,8 @@ export function loadWorkspaces() {
|
|
|
23
24
|
export function saveWorkspaces(/** @type {any} */ ws) {
|
|
24
25
|
try {
|
|
25
26
|
ensureHome();
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
const tmp = target + '.tmp';
|
|
29
|
-
fs.writeFileSync(tmp, JSON.stringify(ws, null, 2) + '\n');
|
|
30
|
-
fs.renameSync(tmp, target);
|
|
27
|
+
// 原子写(评估 6.6):随机 tmp 名 + rename,避免崩溃冲空与跨进程共名 tmp 串扰
|
|
28
|
+
atomicWriteFileSync(workspacesFile(), JSON.stringify(ws, null, 2) + '\n');
|
|
31
29
|
} catch {}
|
|
32
30
|
}
|
|
33
31
|
|
|
@@ -121,10 +119,8 @@ export function loadSessionWorkspaces() {
|
|
|
121
119
|
export function saveSessionWorkspaces(/** @type {any} */ map) {
|
|
122
120
|
try {
|
|
123
121
|
ensureHome();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
fs.writeFileSync(tmp, JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
|
|
127
|
-
fs.renameSync(tmp, target);
|
|
122
|
+
// 原子写(评估 6.6):随机 tmp 名 + rename
|
|
123
|
+
atomicWriteFileSync(sessionWorkspacesFile(), JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
|
|
128
124
|
} catch {}
|
|
129
125
|
}
|
|
130
126
|
|