mingdao-harness 0.2.6 → 0.2.7
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/skills/release-checklist/SKILL.md +5 -4
- package/src/cli.js +18 -3
- package/src/log-writer.js +5 -2
- package/src/pricing.js +10 -1
- package/src/prompts.js +1 -1
- package/src/schedule.js +3 -1
- package/src/tools/index.js +4 -4
- package/src/web/app.js +9 -3
- package/src/web/index.html +2 -1
- package/src/web/server.js +7 -2
package/package.json
CHANGED
|
@@ -11,13 +11,14 @@ description: 准备发布新版本、打包交付前使用
|
|
|
11
11
|
2. **版本**:版本号统一(package.json / 配置 / 文档互相一致),遵循语义化版本。
|
|
12
12
|
3. **变更说明**:changelog 用用户语言描述新增/修复/破坏性变更。
|
|
13
13
|
4. **构建验证**:实际构建/打包一次,并在干净环境验证产物可安装可运行。
|
|
14
|
-
5.
|
|
14
|
+
5. **发布前自检(常设步骤,2026-09-02 起)**:不依赖外部评估报告,纯代码审查主动找 bug——重点覆盖本次改动的调用链、锁/并发、权限/安全、跨平台与「打包期无声、运行期爆炸」类边界;每一条发现必须给出**文件+行号证据并按严重度排序**(高危优先修,不可带着已知高危发布)。历史战例:v0.2.6 自检一次捞出 6 条(含外部六份评估全漏的 `schedule stop` 双守护进程 bug)与 2 条自改回归。
|
|
15
|
+
6. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
|
|
15
16
|
- CI 已内置:desktop.yml 在 Linux 腿用 xvfb 运行 `dist/linux-unpacked/mingdao --no-sandbox`(`MINGDAO_DESKTOP_SMOKE=1`),模块求值通过输出 `MINGDAO_DESKTOP_SMOKE_OK` 并退出 0,否则 workflow 失败;
|
|
16
17
|
- 本地有显示器时可加验:`cd desktop && MINGDAO_DESKTOP_SMOKE=1 dist/linux-unpacked/mingdao --no-sandbox`(AppImage 加 `--appimage-extract-and-run`);
|
|
17
18
|
- 教训:0.2.2 三平台启动即崩(main.js 顶层调用 createLogWriter 漏导入),打包期不报错、运行期才炸——静态护栏(test/smoke.js「桌面主进程静态护栏」组)与打包冒烟双保险。
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
7. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
|
|
20
|
+
8. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
|
|
21
|
+
9. **发布后冒烟**:发布完成后立即做一次端到端验证。
|
|
21
22
|
|
|
22
23
|
## 桌面版发布流水线(官网分发 + 镜像 + 转运附件回收)
|
|
23
24
|
|
package/src/cli.js
CHANGED
|
@@ -257,8 +257,16 @@ async function main() {
|
|
|
257
257
|
const { readTask } = await import('./tasks.js');
|
|
258
258
|
const handled = new Set();
|
|
259
259
|
const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
|
|
260
|
+
const nonce = String(opts.prompt[1] || '');
|
|
260
261
|
try {
|
|
261
262
|
for (;;) {
|
|
263
|
+
// 守护进程租约自检(2026-09-03,防双 daemon):pidfile 不再等于「我的 pid nonce」
|
|
264
|
+
// 即视为已被 stop 或已被新 daemon 取代——立即退出,绝不与新 daemon 并跑重复执行任务
|
|
265
|
+
try {
|
|
266
|
+
if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() !== `${process.pid} ${nonce}`) break;
|
|
267
|
+
} catch {
|
|
268
|
+
break; // pidfile 被删 = 已被 stop
|
|
269
|
+
}
|
|
262
270
|
const jobs = listSchedules(home0);
|
|
263
271
|
if (!jobs.some((j) => j.status === 'pending' || j.status === 'running')) break;
|
|
264
272
|
for (const j of jobs) {
|
|
@@ -295,8 +303,12 @@ async function main() {
|
|
|
295
303
|
await new Promise((r) => setTimeout(r, 2000));
|
|
296
304
|
}
|
|
297
305
|
} finally {
|
|
306
|
+
// 只删除「仍指向自己」的 pidfile:租约被改写/被新 daemon 取代而退出时,
|
|
307
|
+
// 绝不误删新 daemon 的 pidfile(否则新 daemon 下一次租约检查会连锁退出,双守护全灭)
|
|
298
308
|
try {
|
|
299
|
-
fs.
|
|
309
|
+
if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() === `${process.pid} ${nonce}`) {
|
|
310
|
+
fs.rmSync(daemonPidFile(home0), { force: true });
|
|
311
|
+
}
|
|
300
312
|
} catch {}
|
|
301
313
|
}
|
|
302
314
|
return;
|
|
@@ -368,11 +380,14 @@ async function main() {
|
|
|
368
380
|
},
|
|
369
381
|
};
|
|
370
382
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
371
|
-
// A2:预热——await 连接(6s
|
|
383
|
+
// A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
|
|
384
|
+
// 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
|
|
385
|
+
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
|
|
372
386
|
mcpManager = await Promise.race([
|
|
373
|
-
|
|
387
|
+
mcpStartP,
|
|
374
388
|
new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
|
|
375
389
|
]);
|
|
390
|
+
if (!mcpManager) mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
|
|
376
391
|
if (mcpManager) {
|
|
377
392
|
const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
|
|
378
393
|
if (io && !opts.prompt.length) {
|
package/src/log-writer.js
CHANGED
|
@@ -9,7 +9,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
|
|
|
9
9
|
try {
|
|
10
10
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
11
|
const line = new Date().toISOString() + ' ' + String(msg) + '\n';
|
|
12
|
-
|
|
12
|
+
// 自查 #5:日志可能含工具参数等敏感信息——创建与轮转一律 600(多用户机器防同机读取)
|
|
13
|
+
if (!fs.existsSync(file)) fs.appendFileSync(file, line, { mode: 0o600 });
|
|
14
|
+
else fs.appendFileSync(file, line);
|
|
13
15
|
const st = fs.statSync(file);
|
|
14
16
|
if (st.size > maxBytes) {
|
|
15
17
|
const raw = fs.readFileSync(file, 'utf8');
|
|
@@ -17,8 +19,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
|
|
|
17
19
|
const nl = raw.indexOf('\n', cut);
|
|
18
20
|
const keep = nl === -1 ? raw.slice(cut) : raw.slice(nl + 1); // 行对齐保留尾部
|
|
19
21
|
const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
|
|
20
|
-
fs.writeFileSync(tmp, keep);
|
|
22
|
+
fs.writeFileSync(tmp, keep, { mode: 0o600 }); // 轮转重建保持 600
|
|
21
23
|
fs.renameSync(tmp, file);
|
|
24
|
+
try { fs.chmodSync(file, 0o600); } catch {} // rename 后兜底收权(历史 644 日志迁移)
|
|
22
25
|
}
|
|
23
26
|
} catch {
|
|
24
27
|
// 日志失败绝不抛错(best-effort)
|
package/src/pricing.js
CHANGED
|
@@ -42,9 +42,18 @@ export function pricingDataStale() { return externalPricing().stale; }
|
|
|
42
42
|
export async function refreshPricingFromSource(cfg) {
|
|
43
43
|
const src = String(cfg?.pricing?.source || '').trim();
|
|
44
44
|
if (!src) return { ok: false, lines: ['未配置 pricing.source(config.json 的 pricing.source 填官方价格 JSON 地址后重试)'] };
|
|
45
|
+
// 自查 #4:与 sync/自定义模型同类的第三外联入口——协议白名单 + 响应大小上限(防误配/恶意源灌内存)
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(src);
|
|
48
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, lines: ['价格源仅支持 http/https 地址'] };
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, lines: ['价格源必须是合法的 http(s) URL'] };
|
|
51
|
+
}
|
|
45
52
|
const res = await fetch(src, { signal: AbortSignal.timeout(15000) });
|
|
46
53
|
if (!res.ok) return { ok: false, lines: ['价格源请求失败:HTTP ' + res.status] };
|
|
47
|
-
const
|
|
54
|
+
const buf = await res.arrayBuffer();
|
|
55
|
+
if (buf.byteLength > 512 * 1024) return { ok: false, lines: ['价格源响应超过 512KB 上限,已拒绝'] };
|
|
56
|
+
const d = JSON.parse(Buffer.from(buf).toString('utf8'));
|
|
48
57
|
const models = d && typeof d === 'object' && !Array.isArray(d) ? /** @type {any} */ (d).models : null;
|
|
49
58
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
|
50
59
|
return { ok: false, lines: ['价格源格式不符:应为 {"models": {"模型名": {"input":…,"output":…,"cacheHit":…,"peak":{…}}}}'] };
|
package/src/prompts.js
CHANGED
|
@@ -14,7 +14,7 @@ const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI
|
|
|
14
14
|
2. 精准修改:优先用 edit 做小步精确替换;新建文件用 write;保持改动最小化;改错可用 undo 撤销。
|
|
15
15
|
3. 大内容分批写:单个 write 的参数总长控制在 6000 字符以内;大文件先写核心骨架,再分多次 write/edit 逐步补充——单次输出超限会被截断并导致参数解析失败。
|
|
16
16
|
4. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
|
|
17
|
-
5.
|
|
17
|
+
5. 委托与专注:把「互不依赖、可并行」的子问题(同时调研多个方向、复核多份文件、独立实现多模块)用 task 委托子代理;多个只读调研子代理应在同一轮一起派发(readOnly=true,自动并行,效率最高);依赖当前对话细节的小改动自己做,不要委托。子代理返回后拿结果继续主线。
|
|
18
18
|
6. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
|
|
19
19
|
7. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
|
|
20
20
|
8. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
|
package/src/schedule.js
CHANGED
|
@@ -293,7 +293,9 @@ export function daemonAlive(/** @type {any} */ home) {
|
|
|
293
293
|
}
|
|
294
294
|
export function stopDaemon(/** @type {any} */ home) {
|
|
295
295
|
try {
|
|
296
|
-
|
|
296
|
+
// pidfile 格式 "<pid> <nonce>"——必须取首段(此前整串 Number()=NaN,SIGTERM 永远不发,
|
|
297
|
+
// 只删 pidfile → 孤儿 daemon 继续跑,新 daemon 再被拉起 → 双守护重复执行任务)
|
|
298
|
+
const pid = Number(String(fs.readFileSync(daemonPidFile(home), 'utf8')).trim().split(/\s+/)[0]);
|
|
297
299
|
if (pid) {
|
|
298
300
|
try {
|
|
299
301
|
process.kill(pid, 'SIGTERM');
|
package/src/tools/index.js
CHANGED
|
@@ -83,9 +83,9 @@ const SKILL_SCHEMA = {
|
|
|
83
83
|
const TASK_SCHEMA = {
|
|
84
84
|
type: 'object',
|
|
85
85
|
properties: {
|
|
86
|
-
description: { type: 'string', description: '
|
|
87
|
-
prompt: { type: 'string', description: '
|
|
88
|
-
readOnly: { type: 'boolean', description: '
|
|
86
|
+
description: { type: 'string', description: '子任务一句话描述(用于进度展示)。' },
|
|
87
|
+
prompt: { type: 'string', description: '子代理的完整任务说明(全新上下文,需自带全部必要背景)。' },
|
|
88
|
+
readOnly: { type: 'boolean', description: '只读调研任务(不写文件不执行命令)。多个 readOnly 子任务同一轮派发会并行执行,效率最高。' },
|
|
89
89
|
},
|
|
90
90
|
required: ['prompt'],
|
|
91
91
|
};
|
|
@@ -185,7 +185,7 @@ const TOOLS = [
|
|
|
185
185
|
type: 'function',
|
|
186
186
|
function: {
|
|
187
187
|
name: 'task',
|
|
188
|
-
description: '
|
|
188
|
+
description: '把独立、可并行的子任务委托给全新上下文子代理并返回汇报。适合:多方向调研/复核/独立实现等互不依赖的任务(多个只读调研子任务应同一轮一起派发,自动并行,效率最高);不适合:依赖当前对话细节的小改动。',
|
|
189
189
|
parameters: TASK_SCHEMA,
|
|
190
190
|
},
|
|
191
191
|
},
|
package/src/web/app.js
CHANGED
|
@@ -101,6 +101,7 @@ $('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#di
|
|
|
101
101
|
$('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
|
|
102
102
|
const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
|
|
103
103
|
let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
|
|
104
|
+
let bgTasks=[]; // 后台任务列表快照(chip tooltip 详情用,updateTasksPanel 每 2s 刷新)
|
|
104
105
|
let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
|
|
105
106
|
const sessionSubs=[]; // 本会话全部子代理(task 工具)条目:{seq, question, result, msg}
|
|
106
107
|
// 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
|
|
@@ -421,9 +422,13 @@ function renderWorkStatus(){
|
|
|
421
422
|
let html='';
|
|
422
423
|
if(generating){
|
|
423
424
|
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
424
|
-
html='<span class="ws-busy"><span class="spinner"></span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+'
|
|
425
|
+
html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ 第 '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子代理':'')+'</span></span>';
|
|
425
426
|
} else if(bgRunning>0){
|
|
426
|
-
|
|
427
|
+
// 后台任务 chip(非顶部,位于输入框上方):计数 + 最新任务 + 悬浮详情 tooltip,点击打开详情面板
|
|
428
|
+
const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
|
|
429
|
+
const latest=running.length?running[running.length-1]:null;
|
|
430
|
+
const tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
|
|
431
|
+
html='<span class="ws-bg" title="'+esc(String(tip)).replace(/"/g,'"')+'">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
|
|
427
432
|
}
|
|
428
433
|
el.style.display=html?'flex':'none';
|
|
429
434
|
el.innerHTML=html;
|
|
@@ -553,6 +558,7 @@ async function updateTasksPanel(){
|
|
|
553
558
|
const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
|
|
554
559
|
// 质检(等待状态静默):后台任务(worker/调度)并入计数与面板;状态转换时在聊天区弹可见横幅
|
|
555
560
|
bgRunning=(Number(j.running)||0)+(Number(j.bgRunning)||0);
|
|
561
|
+
bgTasks=j.background||[];
|
|
556
562
|
for(const t of (j.background||[])){
|
|
557
563
|
const prev=lastBgStatus.get(t.id);
|
|
558
564
|
if(prev && prev.status==='running' && t.status!=='running'){
|
|
@@ -591,7 +597,7 @@ async function refreshCostBadge(){
|
|
|
591
597
|
const bd=j.breakdown||{}; const gd=j.guard||null;
|
|
592
598
|
let t='今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
593
599
|
if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
|
|
594
|
-
if(gd&&gd.limit>0) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
600
|
+
if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
595
601
|
$('#costBadge').textContent=t;
|
|
596
602
|
}
|
|
597
603
|
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
package/src/web/index.html
CHANGED
|
@@ -62,7 +62,8 @@ header select{flex:none}
|
|
|
62
62
|
#tasksPanel{position:fixed;right:0;top:52px;bottom:0;width:280px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
|
|
63
63
|
/* 输入框上方工作状态条(审计:长任务静默硬伤——实时显示执行步数/耗时与后台任务数)。
|
|
64
64
|
与输入框同宽对齐(复用 #composer 的居中容器),不再横贯整个聊天窗口 */
|
|
65
|
-
#workStatus{max-width:
|
|
65
|
+
#workStatus{width:fit-content;max-width:100%;margin:0 auto 4px;display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;font-size:12.5px;color:var(--dim);flex:none;overflow:hidden}
|
|
66
|
+
#workStatus .ws-busy{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
66
67
|
/* 状态条 busy 行:承载完整阶段信息(合并原顶部活动条),与输入框同列不遮挡聊天区 */
|
|
67
68
|
#workStatus .ws-phase{color:var(--accent2);font-weight:600;white-space:nowrap}
|
|
68
69
|
/* 左侧轨迹固定栏 + 右侧子代理面板(布局重构:轨迹在左、子代理在右) */
|
package/src/web/server.js
CHANGED
|
@@ -195,11 +195,16 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
195
195
|
/** @type {any} */
|
|
196
196
|
let mcpManager = null;
|
|
197
197
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
198
|
+
// 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
|
|
199
|
+
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
|
|
198
200
|
mcpManager = await Promise.race([
|
|
199
|
-
|
|
201
|
+
mcpStartP,
|
|
200
202
|
new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
|
|
201
203
|
]);
|
|
202
|
-
if (!mcpManager)
|
|
204
|
+
if (!mcpManager) {
|
|
205
|
+
console.error('[MingDao] ⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao web 可重试)');
|
|
206
|
+
mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
|
|
207
|
+
}
|
|
203
208
|
}
|
|
204
209
|
const mcpFacade = {
|
|
205
210
|
toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
|