scripts/startup-guard.mjs + scripts/lib/startup-guard-core.mjs(新增): - 被控端启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口脚本 - 30s 启动宽限期 + 端口探活,避免计划内重启与 supervisor 重启撞车被误计数 - 无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir - 恢复成功或服务已就绪均归零失败计数;被控端启动成功后 resetStartupGuardState 归零 (apps/agent/src/index.ts 为本任务对 agent 的唯一改动点) 三端 supervisor 接入: - Windows install.ps1:计划任务包一层 guard,RestartCount 3→10(保证阈值可达) - Linux install-linux.sh:systemd unit 的 ExecStart 包一层 guard - macOS launchd:无安装器,docs/DEVELOPMENT.md §7.4 给出完整接法 docs:DELIVERY-PIPELINE §6 由「不做自动回滚」收窄为 「手动 rollback + 看门狗自动恢复 + 边界明示」,附录 C 同步; DEVELOPMENT.md 新增 §7.4 三端 supervisor 接入说明。
176 lines
8.1 KiB
JavaScript
176 lines
8.1 KiB
JavaScript
/**
|
||
* 看门狗核心(startup-guard-core.mjs)—— 纯函数 + 零依赖 + 零 IO。
|
||
*
|
||
* 本文件是 scripts/startup-guard.mjs 的「大脑」:所有**判断**都在这里,
|
||
* 且全部是可单测的纯函数;文件系统、子进程、网络都在 startup-guard.mjs 里
|
||
* 以薄封装注入。铁律:本文件**不 import 任何东西**(连 node: 内置都不用),
|
||
* 保证它在任何 Node ≥22 环境里都能被加载、被测试。
|
||
*
|
||
* ## 这个看门狗解决什么问题(决策 R-1)
|
||
* `nodeagent rollback` 是 RPC 命令,**被控端进程完全起不来时无监听可连**,
|
||
* CLI 救不了(ARCH A-P0-6)。故加一层启动看护:启动连续失败 N 次,
|
||
* 自动从 LKG(最后已知可用版本)恢复入口文件。
|
||
*
|
||
* ## 与 DELIVERY-PIPELINE §6 的关系
|
||
* §6 原取舍「不做自动回滚」的三问(无法归因/回滚循环/失控)在这里的答复:
|
||
* - 无法归因 → 只看**启动期**死亡(存活 < STARTUP_GRACE_MS),
|
||
* 运行期死亡不计(计划内重启杀的是长命进程,见 decideAfterExit);
|
||
* - 回滚循环 → 恢复后计数归零,且恢复目标只有一个(LKG),不滚动;
|
||
* - 失控 → 恢复动作全量记 JSONL 日志,且**无 LKG/无匹配时绝不擦除 install-dir**。
|
||
*/
|
||
|
||
/**
|
||
* 启动宽限期(ms):子进程存活超过它再死,视为**运行期事件**而非启动失败。
|
||
*
|
||
* 为什么需要:本项目的受控端重启机制(system.agent.restart)是
|
||
* 「detached 进程杀旧起新」——旧进程是被 kill -9/taskkill 打死的**长命**进程。
|
||
* 若不区分启动期/运行期,一次计划内重启就会被记成「启动失败」,
|
||
* 累积 N 次后触发**误回滚**(把好版本回滚掉,比坏版本停摆更糟)。
|
||
* 30s 的依据:agent 正常启动(含首次生成证书)在秒级完成;
|
||
* 坏版本(语法错/缺模块/端口冲突)在 1-2s 内死亡。
|
||
*/
|
||
export const STARTUP_GRACE_MS = 30_000;
|
||
|
||
/** 全新状态(文件缺失时的起点)。 */
|
||
export function initialState(nowIso = '') {
|
||
return { failures: 0, updated_at: nowIso };
|
||
}
|
||
|
||
/** 启动成功后的状态:计数归零(与 agent 侧 listen 成功后写状态同口径)。 */
|
||
export function zeroFailures(nowIso = '') {
|
||
return { failures: 0, updated_at: nowIso };
|
||
}
|
||
|
||
/**
|
||
* 解析状态文件文本 → `{failures, updated_at}`。
|
||
*
|
||
* 缺失/损坏/字段非法一律按 0 —— **不猜、但也不因此拒绝服务**:
|
||
* 状态文件只是计数,坏了的代价从「重新数」开始,而不是「不启动」。
|
||
* nowIso 仅在文本合法时保留原 updated_at;调用方需要时自行覆盖。
|
||
*/
|
||
export function readState(rawText) {
|
||
if (typeof rawText !== 'string' || rawText.trim() === '') return initialState();
|
||
let obj;
|
||
try {
|
||
obj = JSON.parse(rawText);
|
||
} catch {
|
||
return initialState();
|
||
}
|
||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return initialState();
|
||
// 只认 number 型的非负整数:字符串 "3"、布尔、null 一律按 0(不猜)
|
||
const n = obj.failures;
|
||
const failures = typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : 0;
|
||
const updatedAt = typeof obj.updated_at === 'string' ? obj.updated_at : '';
|
||
return { failures, updated_at: updatedAt };
|
||
}
|
||
|
||
/** 失败一次后的新状态(nowIso 由调用方注入,保证可测)。 */
|
||
export function nextFailures(state, nowIso = '') {
|
||
const prev = Number.isInteger(state?.failures) && state.failures >= 0 ? state.failures : 0;
|
||
return { failures: prev + 1, updated_at: nowIso };
|
||
}
|
||
|
||
/**
|
||
* 是否达到恢复阈值:连续失败 **>= threshold** 才恢复。
|
||
*
|
||
* 阈值非法(非正整数)→ 不恢复(放行启动)—— 守卫自身配置错误时,
|
||
* 宁可多试几次,也不能乱动入口文件。
|
||
*/
|
||
export function decideRestore(state, threshold) {
|
||
const t = Number(threshold);
|
||
if (!Number.isInteger(t) || t < 1) return false;
|
||
return (state?.failures ?? 0) >= t;
|
||
}
|
||
|
||
/**
|
||
* 子进程退出后的处置决策(纯函数,本模块最重要的判断)。
|
||
*
|
||
* @param {object} input
|
||
* @param {number|null} [input.code] 子进程退出码(被信号打死时为 null)
|
||
* @param {string|null} [input.signal] 打死子进程的信号名(正常退出为 null)
|
||
* @param {number} input.livedMs 子进程存活时长
|
||
* @param {boolean} input.stopping 本 guard 是否正在被要求停止
|
||
* (systemctl stop / 任务停止 → 已转发信号给子进程)
|
||
* @param {boolean} input.serviceUp 端口是否已被(别的实例)服务
|
||
* @param {object} input.state 进入本次决策前的状态
|
||
* @param {string} input.nowIso 当前时间(注入)
|
||
* @returns {{state: object, counted: boolean, persist: boolean, exitCode: number, reason: string}}
|
||
*
|
||
* 优先级(从高到低):
|
||
* 1. serviceUp —— 服务已由别的实例提供(撞车/接管):使命达成,归零退出 0;
|
||
* 2. stopping —— 本 guard 被要求停:不计数,按子进程退出码退出;
|
||
* 3. 干净退出 0 —— 成功,归零退出 0;
|
||
* 4. 运行期死亡(livedMs >= 宽限期)—— 不计「启动失败」,退出码交回外层 supervisor;
|
||
* 5. 启动期死亡且无人接管 —— 连续启动失败 +1,退出码交回外层 supervisor。
|
||
*
|
||
* persist 表示「本次决策是否要落盘」(stopping / runtime-exit 不写:
|
||
* 状态没变,写了反而把 updated_at 搅乱)。
|
||
*/
|
||
export function decideAfterExit(input) {
|
||
const {
|
||
code = 0,
|
||
signal = null,
|
||
livedMs = 0,
|
||
stopping = false,
|
||
serviceUp = false,
|
||
state = initialState(),
|
||
nowIso = '',
|
||
} = input ?? {};
|
||
|
||
if (serviceUp) {
|
||
return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'service-up' };
|
||
}
|
||
if (stopping) {
|
||
return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'stopping' };
|
||
}
|
||
if (!signal && code === 0) {
|
||
return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'clean-exit' };
|
||
}
|
||
const lived = Number.isFinite(livedMs) ? livedMs : 0;
|
||
if (lived >= STARTUP_GRACE_MS) {
|
||
return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'runtime-exit' };
|
||
}
|
||
return { state: nextFailures(state, nowIso), counted: true, persist: true, exitCode: code ?? 1, reason: 'startup-failure' };
|
||
}
|
||
|
||
/**
|
||
* 备份文件名匹配:`<entry>.bak`(deploy 固定名)与 `<entry>.bak-<ts>`(selfupdate 时间戳名)。
|
||
*
|
||
* 与控制端 `apps/cli/src/cmd/rollback.ts` 的 `backupNameRegex` **同口径** ——
|
||
* 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致。
|
||
* (tests/unit/startup-guard.test.mjs 有跨端一致性守卫。)
|
||
* 文件名里的正则元字符一律转义;Windows 反斜杠不当分隔符处理(文件名内不出现)。
|
||
*/
|
||
export function backupNameRegex(entryName) {
|
||
const base = String(entryName ?? '');
|
||
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
return new RegExp(`^${esc}\\.bak(-.*)?$`);
|
||
}
|
||
|
||
/**
|
||
* 指纹比对:全量相等,或老 LKG(12 位)按前缀比。
|
||
*
|
||
* 与 CLI 的 `hashMatches` 同口径:LkgRecord 老数据只有 12 位 hash
|
||
* (ARCH A-P0-3/R-2),不得要求被控端先升级才能被看门狗恢复。
|
||
*/
|
||
export function hashMatches(got, want) {
|
||
if (typeof got !== 'string' || typeof want !== 'string' || !got || !want) return false;
|
||
if (got === want) return true;
|
||
return want.length < got.length && got.startsWith(want);
|
||
}
|
||
|
||
/**
|
||
* 从「已算好指纹」的备份候选里挑恢复源(纯函数;mtime 新→早的排序在外层做)。
|
||
*
|
||
* @param {Array<{path: string, hash: string, mtime?: number}>} cands
|
||
* @param {string} wantHash LKG 指纹(hash_full ?? hash)
|
||
* @returns {{path: string, hash: string}|null} 第一个匹配者;无匹配返回 null
|
||
*/
|
||
export function planRestore(cands, wantHash) {
|
||
if (!wantHash || !Array.isArray(cands)) return null;
|
||
for (const c of cands) {
|
||
if (c && hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash };
|
||
}
|
||
return null;
|
||
}
|