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/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(err);
100
+ settle(reject, err);
94
101
  }, 60000);
95
- req.on('end', () => clearTimeout(slowTimer));
96
- req.on('close', () => clearTimeout(slowTimer));
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(err);
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(raw ? JSON.parse(raw) : {});
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
- recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
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
- // 原子写:临时文件 + rename,避免崩溃后注册表被冲空
27
- const target = workspacesFile();
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
- const target = sessionWorkspacesFile();
125
- const tmp = target + '.tmp';
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