- VERSION file as authoritative version source - export.mjs reads VERSION with package.json fallback - bw-ota.ps1 DryRun mode for safe testing - auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Stop 合并调度器
|
|
* 合并 6 个 Stop hooks 为单进程,减少 5 次 Node.js 进程启动 (~200-250ms)
|
|
*
|
|
* 合并清单:
|
|
* 1. route-auditor.js (hooks/) — 路由审计 + 反馈闭环
|
|
* 1b. session-pin.js (scripts/) — 钉住命名会话防清理
|
|
* 2. auto-cleanup.js (scripts/) — 磁盘清理
|
|
* 3. daily-health-snapshot.js (scripts/) — 健康快照
|
|
* 4. implicit-feedback.js (scripts/) — 隐式反馈推断
|
|
* 5. constitution-session-report.js (hooks/) — 宪法违规摘要
|
|
* 6. log-rotator.js (hooks/) — 日志轮转
|
|
*
|
|
* 所有子模块 fail-open: 任何异常不影响其他模块
|
|
* 退出码: 始终 0
|
|
*
|
|
* stdin: { session_id, transcript_path, hook_event_name: "Stop" }
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const readStdin = require('./lib/read-stdin.js');
|
|
|
|
// W2_UNCAUGHT_HANDLER_v1: 顶层异常守护 — 替代 settings.json 的 shell `2>>` 重定向
|
|
(function installErrorGuard() {
|
|
const _errLog = require('path').join(require('./lib/root.js'), 'debug', 'hook-errors.log');
|
|
const _persist = (kind, err) => {
|
|
try {
|
|
const line = '[' + new Date().toISOString() + '] stop-dispatcher ' + kind + ': ' +
|
|
(err && (err.stack || err.message || String(err)) || 'unknown') + '\n';
|
|
require('fs').appendFileSync(_errLog, line);
|
|
} catch {}
|
|
};
|
|
process.on('uncaughtException', e => { _persist('uncaughtException', e); try { process.exit(0); } catch {} });
|
|
process.on('unhandledRejection', e => { _persist('unhandledRejection', e); });
|
|
})();
|
|
|
|
|
|
readStdin({ maxSize: 128 * 1024 }).then(input => {
|
|
runAll(input);
|
|
}).catch(() => {
|
|
// stdin 解析失败也尝试运行 (Stop hooks 通常不依赖 stdin 内容)
|
|
runAll({});
|
|
});
|
|
|
|
|
|
// GH-1: hook-errors.log JSONL 去重聚合
|
|
function deduplicateHookErrors() {
|
|
const errLog = require('path').join(require('./lib/root.js'), 'debug', 'hook-errors.log');
|
|
if (!require('fs').existsSync(errLog)) return;
|
|
try {
|
|
const content = require('fs').readFileSync(errLog, 'utf8');
|
|
if (content.length < 100) return;
|
|
const entries = {};
|
|
const blocks = content.split(/\n(?=[A-Z\[])/);
|
|
for (const block of blocks) {
|
|
if (!block.trim()) continue;
|
|
const firstLine = block.split('\n')[0].trim();
|
|
const key = firstLine.slice(0, 100);
|
|
if (!entries[key]) entries[key] = { firstLine: key, count: 0, lastSeen: new Date().toISOString() };
|
|
entries[key].count++;
|
|
}
|
|
const jsonl = Object.values(entries)
|
|
.map(e => JSON.stringify(e))
|
|
.join('\n') + '\n';
|
|
const tmp = errLog + '.tmp.' + process.pid;
|
|
require('fs').writeFileSync(tmp, jsonl);
|
|
require('fs').renameSync(tmp, errLog);
|
|
} catch {}
|
|
}
|
|
|
|
async function runAll(input) {
|
|
// P0V2_PARALLEL_BATCHES (2026-04-19 performance-expert)
|
|
// 14 子模块串行 (P99=12.1s) → 三批编排 (Batch1 轻量并行 / Batch2 学习管线 / Batch3 尾部串行)
|
|
// fail-open: 任一阶段 reject/timeout 不阻断后续 batch; 记录 debug/hook-timeout.log
|
|
const { runStageWithTimeout: race } = require('./lib/run-stage.js');
|
|
const _perf_start = Date.now();
|
|
const _stageRecords = [];
|
|
const _record = (rs) => { for (const r of rs) if (r && r.status === 'fulfilled') _stageRecords.push(r.value); };
|
|
|
|
// ─── Batch 1 · 轻量 I/O 并行 (500-800ms) ───
|
|
// 无互相依赖: 各自读写不同文件 (route-feedback.jsonl / pinned-sessions.json / session-* / hook-errors.log / constitution-report)
|
|
_record(await Promise.allSettled([
|
|
race('route-auditor', () => {
|
|
const ra = require('./route-auditor.js');
|
|
if (ra.runAudit) ra.runAudit();
|
|
}, 800),
|
|
race('session-pin', () => {
|
|
const sp = require('../scripts/session-pin.js');
|
|
if (sp.pinSession) sp.pinSession(input);
|
|
}, 500),
|
|
race('session-memory', () => {
|
|
const sm = require('../scripts/session-memory.js');
|
|
if (sm.cleanExpiredSessions) sm.cleanExpiredSessions();
|
|
}, 800),
|
|
// C2_DEDUP_TAIL_v1: 已迁移到 Batch3 尾部 (避免被后续 append 抢占覆盖)
|
|
race('constitution', () => {
|
|
const csr = require('./constitution-session-report.js');
|
|
if (csr.runReport) csr.runReport();
|
|
}, 800),
|
|
]));
|
|
|
|
// ─── Batch 2 · 学习管线 + 巡检 并行 (2-15s) ───
|
|
// implicit-feedback → fusion-weight-learner 链式 (读后写依赖)
|
|
// daily-health / skill-effectiveness 各自独立冷却, 与学习管线并发无冲突
|
|
_record(await Promise.allSettled([
|
|
race('implicit→fwl', async () => {
|
|
try {
|
|
const imf = require('../scripts/implicit-feedback.js');
|
|
const fn = imf.generateImplicitFeedback || imf.inferAndWrite;
|
|
if (fn) fn({ days: 1 });
|
|
} catch {}
|
|
try {
|
|
const fwl = require('../scripts/fusion-weight-learner.js');
|
|
if (fwl.atomicWeightUpdate) {
|
|
fwl.atomicWeightUpdate();
|
|
} else {
|
|
if (fwl.bootstrapWeights) fwl.bootstrapWeights();
|
|
if (fwl.learnFusionWeights) fwl.learnFusionWeights();
|
|
if (fwl.applyImplicitWeights) fwl.applyImplicitWeights();
|
|
}
|
|
} catch {}
|
|
}, 15000),
|
|
race('daily-health', () => {
|
|
const dhs = require('../scripts/daily-health-snapshot.js');
|
|
if (dhs.main) dhs.main();
|
|
}, 2000),
|
|
race('skill-effectiveness', () => {
|
|
const se = require('../scripts/skill-effectiveness.js');
|
|
if (!se.analyze) return;
|
|
const reportFile = path.join(require('./lib/root.js'), 'debug', 'skill-effectiveness-report.json');
|
|
const lastRun = fs.existsSync(reportFile) ? fs.statSync(reportFile).mtimeMs : 0;
|
|
if (Date.now() - lastRun > 23 * 60 * 60 * 1000) se.analyze({ save: true });
|
|
}, 1500),
|
|
]));
|
|
|
|
// ─── Batch 3 · 尾部串行 (明确依赖顺序) ───
|
|
// C1_BATCH3_BUDGET_v1: 总预算硬截断,防止 Stop hook 整体超过 5000ms 宿主 kill
|
|
// sentinel append evolution-log → cleanup 才能 truncate
|
|
// auto-backup → auto-git-push (快照先于远端推送)
|
|
const _BUDGET_MS = 4200;
|
|
const _deadline = _perf_start + _BUDGET_MS;
|
|
const _budgetRace = async (name, fn, origMs) => {
|
|
const remaining = _deadline - Date.now();
|
|
if (remaining <= 100) {
|
|
_stageRecords.push({ name, ok: false, ms: 0, skipped: true, reason: 'budget-exhausted' });
|
|
return;
|
|
}
|
|
_stageRecords.push(await race(name, fn, Math.min(origMs, remaining)));
|
|
};
|
|
await _budgetRace('consistency-sentinel', () => runConsistencySentinel(), 1000);
|
|
await _budgetRace('auto-cleanup', () => {
|
|
const ac = require('../scripts/auto-cleanup.js');
|
|
if (ac.main) ac.main({ execute: true, ifStale: 86400 });
|
|
}, 5000);
|
|
await _budgetRace('log-rotator', () => {
|
|
const lr = require('./log-rotator.js');
|
|
if (lr.runRotation) lr.runRotation();
|
|
}, 800);
|
|
await _budgetRace('auto-backup', () => {
|
|
const backup = require('../scripts/auto-backup.js');
|
|
backup();
|
|
}, 3000);
|
|
await _budgetRace('auto-git-push', () => {
|
|
const sync = require('../scripts/auto-git-sync.js');
|
|
sync.pushChanges();
|
|
}, 5000);
|
|
// C2_DEDUP_TAIL_v1: dedup 放最后,确保所有 append 已完成
|
|
await _budgetRace('dedup-errors', () => deduplicateHookErrors(), 500);
|
|
|
|
// ─── 慢 Stop 总耗时监控 + W3 告警 (保持原语义) ───
|
|
try {
|
|
const _total = Date.now() - _perf_start;
|
|
if (_total > 2000) {
|
|
const debugDir = path.join(require('./lib/root.js'), 'debug');
|
|
const errLog = path.join(debugDir, 'hook-slow.log');
|
|
fs.appendFileSync(errLog, JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
hook: 'stop-dispatcher',
|
|
totalMs: _total,
|
|
stages: _stageRecords.map(r => ({ n: r.name, ok: r.ok, ms: r.ms })),
|
|
}) + '\n');
|
|
try {
|
|
const alertSentinel = path.join(debugDir, '.slow-alert.sentinel');
|
|
const now = Date.now();
|
|
let lastAlert = 0;
|
|
try {
|
|
if (fs.existsSync(alertSentinel)) lastAlert = fs.statSync(alertSentinel).mtimeMs || 0;
|
|
} catch {}
|
|
if (now - lastAlert > 60000) {
|
|
process.stderr.write('[stop-dispatcher] 慢 Stop 事件 (' + _total + 'ms) — 详见 debug/hook-slow.log\n');
|
|
try {
|
|
const _tmp = alertSentinel + '.tmp.' + process.pid;
|
|
fs.writeFileSync(_tmp, String(now));
|
|
fs.renameSync(_tmp, alertSentinel);
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
// consistency-sentinel 拆出为独立函数 (原 L171-232, P0v2 重构)
|
|
function runConsistencySentinel() {
|
|
try {
|
|
const crypto = require('crypto');
|
|
const sRoot = require('./lib/root.js');
|
|
const evoLog = path.join(sRoot, 'evolution-log.jsonl');
|
|
const findings = [];
|
|
|
|
try {
|
|
const ffPath = path.join(sRoot, 'feature-flags.json');
|
|
const sigPath = path.join(sRoot, 'feature-flags.json.sig');
|
|
if (fs.existsSync(ffPath) && fs.existsSync(sigPath)) {
|
|
const actual = crypto.createHash('sha256').update(fs.readFileSync(ffPath)).digest('hex');
|
|
const stored = fs.readFileSync(sigPath, 'utf8').trim();
|
|
if (actual !== stored) {
|
|
const tmp = sigPath + '.tmp.' + process.pid;
|
|
fs.writeFileSync(tmp, actual);
|
|
fs.renameSync(tmp, sigPath);
|
|
findings.push({ id: 'sig-drift', fix: 'auto', detail: 'feature-flags.sig 重签 ' + actual.slice(0, 8) });
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
const statsPath = path.join(sRoot, 'stats-compiled.json');
|
|
const skillsDir = path.join(sRoot, 'skills');
|
|
if (fs.existsSync(statsPath) && fs.existsSync(skillsDir)) {
|
|
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
|
const actual = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
.filter(d => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
|
|
.filter(d => fs.existsSync(path.join(skillsDir, d.name, 'SKILL.md')))
|
|
.length;
|
|
const declared = stats && stats.summary && stats.summary.skills;
|
|
if (declared && declared !== actual) {
|
|
findings.push({ id: 'skills-drift', fix: 'todo', detail: 'stats=' + declared + ' actual=' + actual + ', 需跑 generate-stats.js' });
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
if (findings.length > 0 && fs.existsSync(evoLog)) {
|
|
const evoContent = fs.readFileSync(evoLog, 'utf8');
|
|
const lines = evoContent.trim().split('\n');
|
|
let maxSeq = 0;
|
|
for (const line of lines) {
|
|
try { const e = JSON.parse(line); if (e.seq > maxSeq) maxSeq = e.seq; } catch {}
|
|
}
|
|
const fixed = findings.filter(f => f.fix === 'auto').length;
|
|
const entry = {
|
|
seq: maxSeq + 1,
|
|
ts: new Date().toISOString().slice(0, 10),
|
|
version: 'v6.5.1',
|
|
scope: 'consistency-sentinel',
|
|
trigger: 'stop-dispatcher',
|
|
summary: 'T1 自动巡检: ' + findings.map(f => f.id + '(' + f.fix + ')').join(', ') + ' | ' + findings.map(f => f.detail).join(' | '),
|
|
fix_count: fixed,
|
|
tags: ['learning-loop', 'auto-sentinel']
|
|
};
|
|
const needsNewline = evoContent.length > 0 && !evoContent.endsWith('\n');
|
|
// C2_SAFE_APPEND_v1: 文件锁避免并发半写
|
|
if (needsNewline) { try { fs.appendFileSync(evoLog, '\n'); } catch {} }
|
|
try {
|
|
const { safeAppendJsonl } = require('./lib/safe-append.js');
|
|
safeAppendJsonl(evoLog, entry, { useLock: true });
|
|
} catch {
|
|
try { fs.appendFileSync(evoLog, JSON.stringify(entry) + '\n'); } catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|