feat(agent): 看门狗自动恢复 LKG —— 启动连续失败 N 次从 LKG 恢复(决策 R-1)

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 接入说明。
This commit is contained in:
jkooe committed 2026-10-26 00:09:56 +08:00
1 parent 1249c90725
commit cb468df7da
9 files changed
+1275 -21

No files matched your search

+28 -1
View File
@@ -1,7 +1,9 @@
import os from 'node:os';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { warmupWindowHelper } from './capabilities/window.js';
import { CAPABILITY_MANIFEST } from '@nodeagent/protocol';
import { loadAgentConfig, agentConfigPath } from './config.js';
import { agentDir, loadAgentConfig, agentConfigPath } from './config.js';
import { ensureCert } from './certs.js';
import { createAgentServer } from './server.js';
import { initAudit, audit, auditFilePath } from './audit.js';
@@ -19,6 +21,28 @@ function listLocalIps(): string[] {
return out;
}
/**
* 看门狗失败计数归零(v2.1.0,决策 R-1;scripts/startup-guard.mjs)。
*
* 看门狗用 `<数据目录>/state/startup-state.json` 记录「连续启动失败次数」,
* 达到阈值即从 LKG 恢复入口。计数必须在**启动成功后**归零 —— 与 LKG 同一
* 铁律:不把「启动中」当成功,否则坏版本每崩一次都会被上一次的成功洗白,
* 阈值形同虚设。写入失败一律吞掉:重置计数不能影响启动(与 recordLKG 同哲学)。
*/
function resetStartupGuardState(): void {
try {
const dir = join(agentDir(), 'state');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'startup-state.json'),
`${JSON.stringify({ failures: 0, updated_at: new Date().toISOString() })}\n`,
'utf8',
);
} catch {
/* 重置失败不影响启动 */
}
}
// ---------------------------------------------------------------------------
// v2.0.3:进程级最后一道防线(崩溃链兜底)
// ---------------------------------------------------------------------------
@@ -196,6 +220,9 @@ async function main(): Promise<void> {
// 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。
// 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。
recordLKG();
// 同理(v2.1.0 看门狗):启动成功才把「连续启动失败」计数归零。
// 顺序在 recordLKG 之后、启动横幅之前 —— 两者都是「起来了」的注脚。
resetStartupGuardState();
console.log('');
console.log(' nodeagent 被控端已启动');
+56 -15
View File
@@ -210,29 +210,65 @@ nodeagent 无服务端、无多环境:`agent` 跑在用户自己的机器上
| **先备份再原子替换**(同目录 rename,失败可回滚) | ✅ 已有 |
| **sha256 强制校验**(不校验不落盘) | ✅ 已有 |
| 更新后**重启并轮询校验新指纹**上线 | ✅ 已有(`deploy` 闭环) |
| **保留「最后已知可用版本」标记(LKG)** | ✅ **本次落地**(`apps/agent/src/lkg.ts`) |
| **更新后健康检查失败 → 自动回滚到备份** | ⚠️ **刻意不做**,见下 |
| **保留「最后已知可用版本」标记(LKG)** | ✅ 已有(`apps/agent/src/lkg.ts`) |
| **控制端手动回滚入口**(`nodeagent rollback`,读 LKG → 覆盖回入口 → 重启 → 复核) | ✅ **v2.1.0 新增**(需被控端可达) |
| **启动期自动恢复**(看门狗:启动连续失败 N 次 → 从 LKG 恢复) | ✅ **v2.1.0 新增**(`scripts/startup-guard.mjs`,三端 supervisor 已接入) |
| **运行期自动回滚**(更新后健康检查失败即自动回滚到备份) | ⚠️ **刻意不做**,见下 |
### LKG 的设计(本次落地)
### LKG 的设计(v2.0.x 落地,v2.1.0 补全量指纹)
- **写入时机**:agent **成功启动后**(服务器已 listen、心跳已起)才写 `agent-lkg.json`。
**不在更新写完时就写** —— 那会把尚未验证的版本当成已知可用,LKG 就失去意义。
- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径)、`bytes`、`script_path`、
- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径,前 12 位)、
`hash_full`(sha256 **全量 64 位**,v2.1.0 起记录 —— 备份指纹匹配用全值,
消除 12 位前缀碰撞的理论风险)、`bytes`、`script_path`、
`backup_glob`(v2.1.0 起,回滚内容候选的文件名模式 `<basename>.bak*`)、
`recorded_at`、`node_version`、`platform`。
**老记录(无 `hash_full`/`backup_glob`)不猜**:消费方回落 12 位前缀比对与
`<basename>.bak*` 约定,向后兼容。
- **暴露**:`system.info.lkg = { lkg, current_hash, matches, byte_delta }`。
`matches=false` = 当前版本还没被证明能启动 → 控制端应**暂缓向其余设备放量**。
### 为什么**不做**自动回滚(明确的设计取舍)
### 回滚通道:手动 rollback + 看门狗自动恢复 + 边界明示(v2.1.0 定稿)
自动回滚听起来更安全,但在远程被控端上会引入更坏的失败形态:
| 场景 | 通道 | 前提 |
|---|---|---|
| 新版本能启动但需回退(行为异常 / 主动避险) | **手动** `nodeagent rollback [--dry-run] [--backup <远端路径>]`:指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG` | **被控端可达**(RPC 命令;`--dry-run` 只读零写) |
| 新版本进程**完全起不来**(无监听可连,CLI 救不了) | **看门狗自动恢复** `scripts/startup-guard.mjs`:supervisor 每次拉起先过守卫,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口 | 外层 supervisor 在位(Windows 计划任务 / Linux systemd / macOS launchd,三端均已接入) |
| agent.mjs 自身损坏到 Node 无法加载 / Node 运行时缺失 | **带外恢复**(见附录 C):备份文件 + LKG 文件手工还原 | 物理或远程桌面可达 |
**为什么恢复目标只有 LKG 一个**:回滚循环(回滚 → 启动 → 又判定失败 → 再回滚)在
无人值守设备上比「停在坏版本」更难恢复。看门狗因此只做「回到上一个已知能启动的版本」
这一件事,恢复后计数归零;**不做多版本链式回滚、不做版本选择**。
> **边界(明示,任何文案不得暗示「失联也能一条命令救回」)**:
> ① `nodeagent rollback` 是 RPC 命令,**需被控端可达** —— 它救的是「新版本能启动但需回退」;
> ② 看门狗自身也是 Node 脚本 —— **agent.mjs 损坏到 Node 无法解析、或 Node 运行时缺失时,
> 守卫同样无法运行**,只能带外恢复(附录 C);
> ③ 看门狗只守护「启动」这一时刻;运行期崩溃由外层 supervisor 的重启策略负责
> (systemd `Restart=always` / 计划任务 `RestartCount=10`)。
**看门狗的判据(为什么不会误回滚)**:
- 只计**启动期**死亡(子进程存活 < 30s 宽限期);运行期死亡(计划内重启杀的是长命进程)不计;
- 端口已被别的实例服务 → 判定「使命达成」,退 0 不计数(防 supervisor 重启造成的撞车被误记成启动失败);
- 无 LKG / 无指纹匹配的备份 → **记日志后放行,绝不擦除 install-dir**;
- 计数在 agent **listen 成功后**由 agent 自身归零(与 LKG 同一铁律:不把「启动中」当成功);
- 恢复动作全量记 `<数据目录>/logs/startup-guard.log`(JSONL:时间、动作、命中备份、结果)。
### 为什么**运行期**不做自动回滚(明确的设计取舍,v2.1.0 收窄)
原三问(无法归因 / 回滚循环 / 失控)在**启动期**已被看门狗以受控方式回答
(判据见上:只看启动期死亡、单一 LKG 目标、恢复后归零、全程日志、无匹配不动盘)。
但**运行期**仍维持原取舍、刻意不做:
1. **无法归因**:远程设备上区分不出「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务未起」。
2. **回滚循环**:回滚 → 启动 → 又判定失败 → 再回滚。在无人值守设备上,这比「停在坏版本」更难恢复。
3. **失控**:被控端一旦自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。
2. **回滚循环**:见上 —— 运行期自动回滚会把「停一次服务」放大成「来回换版本」。
3. **失控**:被控端一旦在运行期自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。
故定位为:**LKG 提供确定的回滚目标,决策权留给控制端**(`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启)。
现有 `system.agent.update` 已具备备份、哈希校验、写后校验、不一致即回滚 —— 那是**写入期**的闭环;
**运行期**的可用性判断交给人,不交给一个可能误判的自动机。
故定位为:**LKG 提供确定的回滚目标;启动期决策交给看门狗(有界、可审计),
运行期决策交给人**(`nodeagent rollback`)。写入期的闭环(备份、哈希校验、写后校验、
不一致即回滚)由 `system.agent.update` 既有实现承担,本文不改动它。
---
@@ -374,10 +410,14 @@ chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设
**回滚动作**
1. 登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak-<ts>` → `agent.mjs`。
2. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。
3. 重启 agent(计划任务或 `run-temp.cmd`)。
4. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。
1. **被控端可达**(能连上)→ `nodeagent rollback`(指纹比对定位 LKG 备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG`;`--dry-run` 可先只读比对)。
2. **被控端起不来**(连不上)→ 看门狗应已介入:启动连续失败 3 次后自动从 LKG 恢复,过程见 `<数据目录>/logs/startup-guard.log`。若看门狗未恢复(无 LKG / 无指纹匹配的备份)或状态异常,登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak`(或 `agent.mjs.bak-<ts>`)→ `agent.mjs`。
3. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。
4. 重启 agent(计划任务或 `run-temp.cmd`)。
5. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。
> 手动回滚与看门狗的分工:rollback 是「人决策、被控端可达」;看门狗是「机器决策、只限启动期」。
> 两者都只认 LKG 一个目标;边界(agent.mjs/Node 损坏时两者都无能为力)见 §6 的明示块。
---
@@ -386,3 +426,4 @@ chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设
| 日期 | 内容 |
|---|---|
| 2026-10-17 | 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 `590f62e`,单测 341 全绿。 |
| 2026-10-25 | v2.1.0:§6 定稿回滚通道 —— 手动 `nodeagent rollback`(需被控端可达)+ 看门狗自动恢复(`scripts/startup-guard.mjs`,启动连续失败 N 次从 LKG 恢复,三端 supervisor 接入)+ 边界明示(agent.mjs/Node 损坏时守卫也无法运行,只能带外恢复);「运行期自动回滚」维持刻意不做(三问论证收窄到运行期)。LKG 记录补 `hash_full`/`backup_glob` 只增字段(老记录回落 12 位前缀)。附录 C 同步。 |
+59
View File
@@ -728,6 +728,65 @@ const timer = setTimeout(() => {
| 证书 | 自签证书(两端固定校验,防中间人) |
| 连接方向 | 控制端主动 → 被控端监听 |
### 7.4 被控端自启与看门狗(v2.1.0,决策 R-1)
被控端的启动链是:**外层 supervisor → 看门狗(`scripts/startup-guard.mjs`)→ agent**。
看门狗解决 `nodeagent rollback` 覆盖不到的场景:进程**完全起不来**时没有监听可连,
RPC 命令无能为力;看门狗跑在 supervisor 链上,启动连续失败 N 次(默认 3)自动从
LKG 恢复入口文件。判据与边界见 `docs/DELIVERY-PIPELINE.md` §6。
| 平台 | supervisor | 接入点(仓库文件) |
|---|---|---|
| Windows | 计划任务 `nodeagent` | `scripts/install.ps1` 的 `New-ScheduledTaskAction`(启动动作包一层 guard,`RestartCount 10`) |
| Linux | systemd unit `nodeagent` | `scripts/install-linux.sh` 的 `ExecStart`(包一层 guard,`Restart=always` 负责重启) |
| macOS | launchd LaunchAgent | **无安装器** —— 手工按下方 plist 接入(仓库不含 macOS 被控端安装器) |
**Windows / Linux 的接入由安装器自动完成**,无需手工配置;`install.ps1` 找不到看门狗
脚本时按原样直启 agent(兜底,不因缺脚本而装不上)。
**macOS 手工接入(launchd)**——被控端以 LaunchAgent 形式常驻时,把
`ProgramArguments` 包一层看门狗(`~/Library/LaunchAgents/com.nodeagent.agent.plist`):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.nodeagent.agent</string>
<key>ProgramArguments</key>
<array>
<string>/opt/nodeagent/runtime/bin/node</string>
<string>/opt/nodeagent/scripts/startup-guard.mjs</string>
<string>--data-dir</string><string>/Users/Shared/.nodeagent</string>
<string>--lkg-dir</string><string>/opt/nodeagent</string>
<string>--install-dir</string><string>/opt/nodeagent</string>
<string>--threshold</string><string>3</string>
<string>--</string>
<string>/opt/nodeagent/runtime/bin/node</string>
<string>/opt/nodeagent/agent.mjs</string>
</array>
<key>WorkingDirectory</key><string>/opt/nodeagent</string>
<key>EnvironmentVariables</key>
<dict><key>NODEAGENT_HOME</key><string>/Users/Shared/.nodeagent</string></dict>
<key>RunAtLoad</key><true/>
<!-- 退出码非 0 才拉起(0 = 服务已由别的实例提供 / 已干净停止);
配合 ThrottleInterval 防崩溃启动风暴 -->
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>10</integer>
<key>StandardOutPath</key><string>/opt/nodeagent/logs/agent.out.log</string>
<key>StandardErrorPath</key><string>/opt/nodeagent/logs/agent.err.log</string>
</dict>
</plist>
```
加载:`launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nodeagent.agent.plist`。
看门狗日志(恢复动作、失败计数)在 `<data-dir>/logs/startup-guard.log`(JSONL)。
**铁律**:看门狗自身必须「裸环境可跑」——零项目依赖、不 import 项目内模块,
只依赖 Node ≥22 内置模块 + 同目录 `lib/startup-guard-core.mjs`(纯函数)。
分发时两个文件必须保持相对位置一起拷贝(pack.mjs / install-linux.sh 已保证)。
---
## 第 8 章 工程结构
+25 -2
View File
@@ -213,6 +213,26 @@ mkdir -p "$INSTALL_DIR" "$DATA_DIR"
install -m 0644 "$AGENT_JS_SRC" "$INSTALL_DIR/agent.mjs"
ok "已安装:$INSTALL_DIR/agent.mjs($(wc -c < "$INSTALL_DIR/agent.mjs" | tr -d ' ') 字节)"
# ---------------- 看门狗(v2.1.0,决策 R-1) ----------------
#
# 启动连续失败 N 次自动从 LKG 恢复入口文件。
# 为什么需要:nodeagent rollback 是 RPC 命令,被控端「完全起不来」时无监听可连,
# CLI 救不了(ARCH A-P0-6);看门狗跑在 supervisor 链上,不要求被控端可达。
# ⚠️ 两个文件必须保持相对位置一起安装:guard 同目录的 lib/ 下是它的核心纯函数。
GUARD_MJS="$PROJECT_ROOT/scripts/startup-guard.mjs"
GUARD_LIB="$PROJECT_ROOT/scripts/lib/startup-guard-core.mjs"
if [ -f "$GUARD_MJS" ] && [ -f "$GUARD_LIB" ]; then
mkdir -p "$INSTALL_DIR/scripts/lib"
install -m 0644 "$GUARD_MJS" "$INSTALL_DIR/scripts/startup-guard.mjs"
install -m 0644 "$GUARD_LIB" "$INSTALL_DIR/scripts/lib/startup-guard-core.mjs"
# 被看守的是「完整启动命令」(-- 之后原样 spawn):node agent.mjs
AGENT_CMD="$NODE_BIN $INSTALL_DIR/scripts/startup-guard.mjs --data-dir $DATA_DIR --lkg-dir $INSTALL_DIR --install-dir $INSTALL_DIR --threshold 3 -- $NODE_BIN $INSTALL_DIR/agent.mjs"
ok "看门狗就绪:启动连续失败 3 次自动从 LKG 恢复(日志 $DATA_DIR/logs/startup-guard.log)"
else
AGENT_CMD="$NODE_BIN $INSTALL_DIR/agent.mjs"
warn "未找到看门狗脚本($GUARD_MJS)—— 按原样直启 agent(无自动恢复)"
fi
# ---------------- 配置:复用既有密钥,否则生成 ----------------
CFG="$DATA_DIR/agent.json"
@@ -279,12 +299,15 @@ Wants=network-online.target
Type=simple
# 数据目录(含密钥与审计日志)—— 与 agent 侧 agentDir() 一致
Environment=NODEAGENT_HOME=$DATA_DIR
ExecStart=$NODE_BIN $INSTALL_DIR/agent.mjs
# v2.1.0:ExecStart 包一层看门狗($AGENT_CMD,见上文「看门狗」节)。
# 崩溃自拉起由 Restart=always 负责;看门狗负责「连续失败 N 次从 LKG 恢复」。
ExecStart=$AGENT_CMD
WorkingDirectory=$INSTALL_DIR
# 崩溃自拉起(对齐 Windows 计划任务的重启语义)
Restart=always
RestartSec=3
# 优雅停止:先 SIGTERM(agent 会关掉监听与审计写入),超时再 SIGKILL
# 优雅停止:先 SIGTERM(agent 会关掉监听与审计写入),超时再 SIGKILL。
# 看门狗收到 SIGTERM 会转发给子进程后退出,不会被误记成「启动失败」。
KillSignal=SIGTERM
TimeoutStopSec=15
# 日志进 journald:journalctl -u $UNIT_NAME -f
+24 -3
View File
@@ -206,7 +206,25 @@ Write-Ok "Inbound rule allowed on TCP $Port"
# 6. Scheduled task
Write-Step "Registering scheduled task..."
$taskName = "nodeagent"
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot
# v2.1.0 看门狗(决策 R-1):启动动作包一层 scripts/startup-guard.mjs ——
# 被控端「启动连续失败 N 次」时自动从 LKG 恢复入口文件。
# 为什么需要:nodeagent rollback 是 RPC 命令,进程完全起不来时无监听可连,
# CLI 救不了(ARCH A-P0-6);看门狗跑在 supervisor 链上,不需要被控端可达。
# 脚本位置:便携包内随 scripts/ 一起分发;开发模式用仓库 scripts/。
# 缺文件时**兜底直启 agent**(不因少一个脚本而装不上),行为与旧版一致。
$guardScript = Join-Path $PSScriptRoot "scripts\startup-guard.mjs"
if (-not (Test-Path $guardScript)) { $guardScript = Join-Path $ProjectRoot "scripts\startup-guard.mjs" }
$guardLib = Join-Path (Split-Path -Parent $guardScript) "lib\startup-guard-core.mjs"
if ((Test-Path $guardScript) -and (Test-Path $guardLib)) {
$agentHome = Split-Path -Parent $agentJs
# 被看守的是「完整启动命令」(-- 之后原样 spawn):node <agent.mjs>
$guardArgs = "`"$guardScript`" --data-dir `"$cfgDir`" --lkg-dir `"$agentHome`" --install-dir `"$agentHome`" --threshold 3 -- `"$nodeExe`" `"$agentJs`""
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument $guardArgs -WorkingDirectory $ProjectRoot
Write-Ok "启动动作已包一层看门狗(连续失败 3 次自动从 LKG 恢复)"
} else {
Write-Warn "未找到看门狗脚本($guardScript)—— 按原样直启 agent(无自动恢复)"
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot
}
# 触发器:登录时(默认)+ 可选开机时;无人值守模式下开机触发器才真正有意义
$triggers = @(New-ScheduledTaskTrigger -AtLogOn)
@@ -214,9 +232,12 @@ if ($AtStartup -or $Unattended) {
$triggers += New-ScheduledTaskTrigger -AtStartup
}
# 自愈:异常退出后自动重启(最多 3 次,间隔 1 分钟),且不设执行时长上限
# 自愈:异常退出后自动重启(间隔 1 分钟),且不设执行时长上限。
# v2.1.0:RestartCount 3 → 10 —— 看门狗要「连续失败 3 次」才触发 LKG 恢复,
# 加上恢复本身可能需要再起一次,3 次不够用(会在恢复前就放弃)。
# 良性撞车(端口已被别的实例服务)时看门狗直接退 0,不消耗重启次数。
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) `
-StartWhenAvailable
# 登录类型决定「能干什么」:
+175
View File
@@ -0,0 +1,175 @@
/**
* 看门狗核心(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;
}
+11
View File
@@ -122,6 +122,17 @@ async function main() {
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1'));
// 看门狗(v2.1.0,决策 R-1):install.ps1 把计划任务的启动动作包一层它 ——
// 被控端「启动连续失败 3 次」时自动从 LKG 恢复入口文件。
// ⚠️ 必须保持 scripts/lib/ 的相对位置:startup-guard.mjs 以 './lib/startup-guard-core.mjs'
// 相对导入其核心纯函数(裸环境零依赖,见该文件头)。
mkdirSync(join(PKG_DIR, 'scripts', 'lib'), { recursive: true });
copyFileSync(join(root, 'scripts', 'startup-guard.mjs'), join(PKG_DIR, 'scripts', 'startup-guard.mjs'));
copyFileSync(
join(root, 'scripts', 'lib', 'startup-guard-core.mjs'),
join(PKG_DIR, 'scripts', 'lib', 'startup-guard-core.mjs'),
);
// 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK)
// 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env node
/**
* 启动看门狗(startup-guard.mjs)—— 被控端「启动连续失败 N 次」自动从 LKG 恢复。
*
* ## 为什么需要(决策 R-1,ARCH A-P0-6)
* `nodeagent rollback` 是 RPC 命令:被控端**完全起不来时没有监听可连**,CLI 无能为力。
* 本脚本补上这条链路:外层 supervisor(Windows 计划任务 / Linux systemd /
* macOS launchd)每次拉起被控端都先过本守卫;启动连续失败达到阈值时,
* 自动用 LKG 记录 + 备份指纹匹配恢复入口文件,再放行启动。
*
* ## 裸环境铁律
* **零项目依赖、不 import 项目内模块**(连 @nodeagent/* 都不碰)——
* 它要在被控端的裸环境里跑:只依赖 Node ≥22 内置模块 + 同目录的
* `lib/startup-guard-core.mjs`(纯函数,也无依赖)。分发时两个文件必须
* 保持相对位置一起拷贝(见 install-linux.sh / install.ps1 / pack.mjs)。
*
* ## 用法
* node scripts/startup-guard.mjs \
* --data-dir <数据目录> --lkg-dir <备份所在目录> --install-dir <安装目录> \
* [--entry agent.mjs] [--threshold 3] [--port 8765] \
* -- <被看守的完整启动命令...>
*
* ## 行为契约(与 supervisor 的退出码约定)
* - 退出 0:服务已由别的实例提供 / 干净停止 / 本守卫被要求停;
* - 退出非 0:启动失败(已计数)或运行期退出 —— supervisor 据此重启,
* 重启后本守卫重新读计数,达到阈值即执行 LKG 恢复。
*
* ## 安全边界(写进 DELIVERY-PIPELINE §6 的明示)
* - **无 LKG / 无指纹匹配的备份 → 记日志后放行,绝不擦除 install-dir**;
* - agent.mjs 自身损坏到 Node 都无法解析、或 Node 运行时缺失时,
* 本守卫同样无法运行 —— 那种场景只能带外恢复(附录 C)。
*/
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import {
closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, statSync, writeFileSync,
} from 'node:fs';
import { basename, join, resolve } from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import {
STARTUP_GRACE_MS,
backupNameRegex,
decideAfterExit,
decideRestore,
initialState,
planRestore,
readState,
zeroFailures,
} from './lib/startup-guard-core.mjs';
// ---------- 路径与状态 ----------
const LKG_FILE = 'agent-lkg.json';
const HASH_CHUNK = 512 * 1024;
/** 状态文件路径:<data-dir>/state/startup-state.json */
export function statePath(dataDir) {
return join(dataDir, 'state', 'startup-state.json');
}
/** 读失败计数;文件缺失/损坏按 0(不猜、不拒绝服务)。 */
export function readStateFile(dataDir) {
try {
const p = statePath(dataDir);
if (!existsSync(p)) return initialState();
return readState(readFileSync(p, 'utf8'));
} catch {
return initialState();
}
}
/** 写失败计数(含目录创建);失败返回 false(不抛——计数丢了下次重新数)。 */
export function writeState(dataDir, state) {
try {
mkdirSync(join(dataDir, 'state'), { recursive: true });
writeFileSync(statePath(dataDir), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
return true;
} catch {
return false;
}
}
/** 追加一行 JSONL 日志:<data-dir>/logs/startup-guard.log(失败静默——日志不拦路)。 */
export function appendGuardLog(dataDir, obj) {
try {
mkdirSync(join(dataDir, 'logs'), { recursive: true });
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...obj })}\n`;
writeFileSync(join(dataDir, 'logs', 'startup-guard.log'), line, { flag: 'a' });
} catch {
/* 日志失败不影响启动 */
}
}
// ---------- LKG 与备份 ----------
/** 读 LKG 记录(<data-dir>/agent-lkg.json);缺失/损坏返回 null。 */
export function readLkgRecord(dataDir) {
try {
const p = join(dataDir, LKG_FILE);
if (!existsSync(p)) return null;
const rec = JSON.parse(readFileSync(p, 'utf8'));
if (!rec || typeof rec.hash !== 'string' || !rec.hash) return null;
return rec;
} catch {
return null;
}
}
/** 流式计算文件 sha256(分块读,不吃内存)。 */
export function hashFileSync(p) {
const fd = openSync(p, 'r');
try {
const h = createHash('sha256');
const buf = Buffer.alloc(HASH_CHUNK);
for (;;) {
const n = readSync(fd, buf, 0, buf.length, null);
if (n === 0) break;
h.update(buf.subarray(0, n));
}
return h.digest('hex');
} finally {
closeSync(fd);
}
}
/**
* 枚举备份候选:<lkg-dir> 下 `<entry>.bak` / `<entry>.bak-<ts>`,按 mtime 新→早。
*
* 与 CLI rollback 的 backupNameRegex 同口径(tests 有跨端一致性守卫):
* deploy 固定名 + selfupdate 时间戳名两套命名都要认。
*/
export function listBackupCandidates(lkgDir, entryName) {
const re = backupNameRegex(entryName);
let items;
try {
items = readdirSync(lkgDir, { withFileTypes: true });
} catch {
return [];
}
const out = [];
for (const it of items) {
if (!it.isFile() || !re.test(it.name)) continue;
try {
const st = statSync(join(lkgDir, it.name));
out.push({ path: join(lkgDir, it.name), name: it.name, mtime: st.mtimeMs, size: st.size });
} catch {
/* 单个文件 stat 失败就跳过 */
}
}
out.sort((a, b) => b.mtime - a.mtime);
return out;
}
/**
* 从 LKG 恢复入口文件(达到阈值时调用)。
*
* 流程:读 LKG → 枚举备份 → 流式指纹匹配(hash_full 全量优先,老记录 12 位前缀回落)
* → 命中则**就地覆写** install-dir/<entry>(同 selfupdate 姿势:不 rename,
* Windows 上 rename 会被占用的文件/杀软挡下,真机教训)→ 写后校验。
*
* 安全边界:无 LKG / 无候选 / 无匹配 / 读失败 → 原样返回不恢复,
* **绝不清空或部分擦写 install-dir**。任何异常都吞掉返回 error ——
* 守卫自己坏掉时也要放行启动,不能把「恢复」变成「致人死亡」。
*
* @returns {{restored: boolean, backup: string|null, reason: string, entry?: string,
* hash?: string, candidates?: string[]}}
*/
export function restoreFromLkg({ dataDir, lkgDir, installDir, entry }) {
try {
const lkg = readLkgRecord(dataDir);
if (!lkg) return { restored: false, backup: null, reason: 'no-lkg' };
const want = lkg.hash_full ?? lkg.hash;
const entryName = entry || basename(lkg.script_path || '') || 'agent.mjs';
const cands = listBackupCandidates(lkgDir, entryName);
if (cands.length === 0) return { restored: false, backup: null, reason: 'no-candidates', entry: entryName };
const hashed = [];
for (const c of cands) {
try {
hashed.push({ path: c.path, hash: hashFileSync(c.path), mtime: c.mtime });
} catch {
/* 跳过读不了的候选 */
}
}
const picked = planRestore(hashed, want);
if (!picked) {
return {
restored: false,
backup: null,
reason: 'no-match',
entry: entryName,
candidates: hashed.map((h) => `${h.path} ${h.hash.slice(0, 12)}`),
};
}
const target = join(installDir, entryName);
const buf = readFileSync(picked.path);
writeFileSync(target, buf);
// 写后校验:目标内容必须等于备份内容(防写一半/被杀软改坏)
const after = hashFileSync(target);
if (after !== picked.hash) {
return { restored: false, backup: picked.path, reason: 'verify-failed', entry: entryName };
}
return { restored: true, backup: picked.path, hash: picked.hash, entry: entryName, bytes: buf.length };
} catch (err) {
return { restored: false, backup: null, reason: `error: ${err instanceof Error ? err.message : String(err)}` };
}
}
// ---------- 端口探活 ----------
/**
* 解析被控端端口:--port 参数优先,否则读 <data-dir>/agent.json 的 port 字段。
* 都没有(如 Hub 模式不监听本地端口)→ null,此时跳过所有端口判断。
*/
export function resolvePort(dataDir, portArg) {
if (Number.isInteger(portArg) && portArg > 0 && portArg < 65536) return portArg;
try {
const p = join(dataDir, 'agent.json');
if (existsSync(p)) {
const cfg = JSON.parse(readFileSync(p, 'utf8'));
const n = Number(cfg.port);
if (Number.isInteger(n) && n > 0 && n < 65536) return n;
}
} catch {
/* 忽略:端口未知就不做端口判断 */
}
return null;
}
/**
* 端口是否已被服务(TCP 能连上即算,不做协议握手 —— 只要「有人在听」)。
*
* 用途有两个,都指向同一目的「别添乱」:
* ① 启动前:服务已由别的实例提供 → 本守卫直接退出 0,不再 spawn(避免撞车);
* ② 子进程死后:服务已由别的实例接管 → 不算启动失败(撞车输的那次不算)。
*/
export function serviceUp(port, host = '127.0.0.1', timeoutMs = 500) {
if (!port) return Promise.resolve(false);
return new Promise((done) => {
const s = net.connect({ host, port });
const finish = (v) => {
s.destroy();
done(v);
};
s.setTimeout(timeoutMs, () => finish(false));
s.once('connect', () => finish(true));
s.once('error', () => finish(false));
});
}
// ---------- 参数解析 ----------
/**
* 解析守卫自己的参数(纯函数,可单测)。
* `--` 之后的所有 token 原样作为「被看守的完整启动命令」。
*/
export function parseGuardArgs(argv) {
const sep = argv.indexOf('--');
const own = sep >= 0 ? argv.slice(0, sep) : argv.slice();
const out = {
dataDir: '',
lkgDir: '',
installDir: '',
entry: '',
threshold: 3,
port: null,
cmd: sep >= 0 ? argv.slice(sep + 1) : [],
help: false,
};
for (let i = 0; i < own.length; i += 1) {
const a = own[i];
const take = () => {
const v = own[i + 1];
if (v === undefined || v.startsWith('--')) throw new Error(`参数 ${a} 缺少取值`);
i += 1;
return v;
};
switch (a) {
case '--data-dir': out.dataDir = take(); break;
case '--lkg-dir': out.lkgDir = take(); break;
case '--install-dir': out.installDir = take(); break;
case '--entry': out.entry = take(); break;
case '--threshold': out.threshold = Number(take()); break;
case '--port': out.port = Number(take()); break;
case '-h': case '--help': out.help = true; break;
default: throw new Error(`未知参数: ${a}`);
}
}
return out;
}
const USAGE = '用法: node startup-guard.mjs --data-dir <目录> --install-dir <目录> [--lkg-dir <目录>] ' +
'[--entry agent.mjs] [--threshold 3] [--port 8765] -- <启动命令...>';
// ---------- 主流程 ----------
/**
* 看守一个子进程直到它退出,并按 decideAfterExit 的决策处理计数与退出码。
* (spawn / 信号转发都在这里;IO 之外的判断全部在 core 纯函数里。)
*/
async function supervise(opts, port) {
const [cmdName, ...cmdArgs] = opts.cmd;
const child = spawn(cmdName, cmdArgs, { stdio: 'inherit', cwd: opts.installDir, env: process.env });
let stopping = false;
const forwardSignal = (sig) => {
stopping = true;
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill(sig);
} catch {
/* 已退出 */
}
// 兜底:子进程不理信号就强杀(守卫不能陪着赖着不走)
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill('SIGKILL');
} catch {
/* 已退出 */
}
}
}, 10_000).unref();
}
};
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
process.on('SIGINT', () => forwardSignal('SIGINT'));
const startedAt = Date.now();
const exit = await new Promise((done) => {
child.on('exit', (code, signal) => done({ code, signal }));
child.on('error', (err) => {
// spawn 本身失败(命令不存在等)= 启动期失败,按 code=1 计数
appendGuardLog(opts.dataDir, { action: 'spawn-error', error: err.message });
done({ code: 1, signal: null });
});
});
const livedMs = Date.now() - startedAt;
const up = await serviceUp(port);
const decision = decideAfterExit({
code: exit.code,
signal: exit.signal,
livedMs,
stopping,
serviceUp: up,
state: readStateFile(opts.dataDir),
nowIso: new Date().toISOString(),
});
appendGuardLog(opts.dataDir, {
action: 'child-exit',
code: exit.code,
signal: exit.signal,
lived_ms: livedMs,
counted: decision.counted,
persist: decision.persist,
reason: decision.reason,
service_up: up,
failures: decision.state.failures,
grace_ms: STARTUP_GRACE_MS,
});
if (decision.persist) writeState(opts.dataDir, decision.state);
console.error(
`[startup-guard] 子进程退出(code=${exit.code ?? 'null'} signal=${exit.signal ?? 'null'},` +
`存活 ${Math.round(livedMs / 1000)}s,${decision.reason},失败计数 ${decision.state.failures})`,
);
return decision.exitCode;
}
async function main() {
let opts;
try {
opts = parseGuardArgs(process.argv.slice(2));
} catch (err) {
console.error(`[startup-guard] ${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
return 2;
}
if (opts.help) {
console.log(USAGE);
return 0;
}
if (!opts.dataDir || !opts.installDir || opts.cmd.length === 0) {
console.error(`[startup-guard] 缺少必要参数(--data-dir / --install-dir / -- 启动命令)\n${USAGE}`);
return 2;
}
if (!Number.isInteger(opts.threshold) || opts.threshold < 1) {
console.error(`[startup-guard] --threshold 需为正整数,收到: ${opts.threshold}`);
return 2;
}
const lkgDir = opts.lkgDir || opts.installDir;
const port = resolvePort(opts.dataDir, opts.port);
const dataDir = opts.dataDir;
// ① 服务已由别的实例提供(撞车/接管场景)→ 使命达成,不再 spawn
if (await serviceUp(port)) {
appendGuardLog(dataDir, { action: 'already-up', port, reason: 'service-up' });
console.error(`[startup-guard] 端口 ${port} 已被服务,本实例不重复启动`);
return 0;
}
// ② 读失败计数;达到阈值 → 尝试从 LKG 恢复(无 LKG/无匹配 → 放行,不动 install-dir)
let state = readStateFile(dataDir);
if (decideRestore(state, opts.threshold)) {
const r = restoreFromLkg({ dataDir, lkgDir, installDir: opts.installDir, entry: opts.entry });
appendGuardLog(dataDir, {
action: 'restore',
failures: state.failures,
threshold: opts.threshold,
restored: r.restored,
backup: r.backup,
entry: r.entry,
reason: r.reason,
candidates: r.candidates,
});
if (r.restored) {
state = zeroFailures(new Date().toISOString());
writeState(dataDir, state);
console.error(`[startup-guard] 已从 LKG 恢复入口: ${r.backup} → ${join(opts.installDir, r.entry)}(失败计数归零)`);
} else {
console.error(`[startup-guard] 未执行恢复(${r.reason})—— 按原状尝试启动`);
}
}
// ③ 看守启动命令(退出码契约见文件头)
console.error(`[startup-guard] 看守中: ${opts.cmd.join(' ')}(阈值 ${opts.threshold},当前失败 ${state.failures} 次)`);
appendGuardLog(dataDir, {
action: 'spawn',
cmd: opts.cmd,
failures: state.failures,
threshold: opts.threshold,
port,
});
return supervise(opts, port);
}
// ---------- 入口 ----------
const isMain = (() => {
try {
const self = resolve(fileURLToPath(import.meta.url));
const argv1 = process.argv[1] ? resolve(process.argv[1]) : '';
return !!argv1 && self === argv1;
} catch {
return false;
}
})();
if (isMain) {
main()
.then((code) => process.exit(code))
.catch((err) => {
// 守卫自身崩溃:放行一次(不让守卫成为启动瓶颈),但留下证据
console.error(`[startup-guard] 守卫自身异常(已放行): ${err instanceof Error ? err.message : String(err)}`);
process.exit(0);
});
}
+439
View File
@@ -0,0 +1,439 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
STARTUP_GRACE_MS,
backupNameRegex,
decideAfterExit,
decideRestore,
hashMatches,
initialState,
nextFailures,
planRestore,
readState,
zeroFailures,
} from '../../scripts/lib/startup-guard-core.mjs';
import {
appendGuardLog,
hashFileSync,
listBackupCandidates,
parseGuardArgs,
readLkgRecord,
readStateFile,
resolvePort,
restoreFromLkg,
statePath,
writeState,
} from '../../scripts/startup-guard.mjs';
// 跨端一致性:守卫与 CLI rollback 必须认同一批备份、同一套指纹规则
import { backupNameRegex as cliBackupNameRegex, hashMatches as cliHashMatches } from '../../apps/cli/dist/cmd/rollback.js';
/**
* 看门狗守卫(决策 R-1 / ARCH A-P0-6)。
*
* 为什么需要守卫:rollback 是 RPC 命令,被控端「完全起不来」时无监听可连。
* 这里钉死看门狗的**判断**不漂移:
* 1. 阈值边界:N-1 不恢复、N 才恢复;
* 2. 有 LKG 且指纹匹配才恢复;无 LKG / 无匹配 → 放行且**不擦除 install-dir**;
* 3. 状态文件损坏按 0(不猜、不拒绝服务);
* 4. 启动期死亡计数、运行期死亡不计数(防计划内重启误触发回滚);
* 5. 成功后归零;
* 6. 恢复日志必须落盘;
* 7. 与 CLI rollback 的备份命名/指纹规则同口径(否则两边认不出同一批文件)。
*/
// ---------- 纯函数:状态机 ----------
test('readState:缺失/空串/损坏 JSON/字段非法一律按 0', () => {
assert.deepEqual(readState(undefined), { failures: 0, updated_at: '' });
assert.deepEqual(readState(''), { failures: 0, updated_at: '' });
assert.deepEqual(readState(' '), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{ not json'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('[]'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":"3"}'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":-2}'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":2.5}'), { failures: 0, updated_at: '' });
assert.deepEqual(readState('{"failures":3,"updated_at":"2026-10-25T00:00:00Z"}'), {
failures: 3,
updated_at: '2026-10-25T00:00:00Z',
});
});
test('readState:CRLF 状态文件同样可解析(Windows 手工编辑场景)', () => {
const raw = '{\r\n "failures": 2,\r\n "updated_at": "t"\r\n}\r\n';
assert.deepEqual(readState(raw), { failures: 2, updated_at: 't' });
});
test('decideRestore:阈值边界 N-1 不恢复、N 恢复', () => {
assert.equal(decideRestore({ failures: 0 }, 3), false);
assert.equal(decideRestore({ failures: 2 }, 3), false, 'N-1 必须不恢复');
assert.equal(decideRestore({ failures: 3 }, 3), true, 'N 必须恢复');
assert.equal(decideRestore({ failures: 9 }, 3), true);
// 阈值非法 → 不恢复(守卫自身配置错误时不许乱动入口文件)
assert.equal(decideRestore({ failures: 99 }, 0), false);
assert.equal(decideRestore({ failures: 99 }, -1), false);
assert.equal(decideRestore({ failures: 99 }, 2.5), false);
assert.equal(decideRestore({ failures: 99 }, 'x'), false);
});
test('nextFailures:递增且带时间戳;zeroFailures 归零', () => {
const s1 = nextFailures({ failures: 0, updated_at: '' }, 'T1');
assert.deepEqual(s1, { failures: 1, updated_at: 'T1' });
const s2 = nextFailures(s1, 'T2');
assert.deepEqual(s2, { failures: 2, updated_at: 'T2' });
assert.deepEqual(zeroFailures('T3'), { failures: 0, updated_at: 'T3' });
});
test('decideAfterExit:启动期死亡(<宽限期)→ 计数 +1', () => {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 800, stopping: false, serviceUp: false,
state: { failures: 1, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, true);
assert.equal(d.persist, true);
assert.equal(d.state.failures, 2);
assert.equal(d.exitCode, 1);
assert.equal(d.reason, 'startup-failure');
});
test('decideAfterExit:运行期死亡(>=宽限期)→ 不计数(计划内重启杀的是长命进程)', () => {
const d = decideAfterExit({
code: null, signal: 'SIGKILL', livedMs: STARTUP_GRACE_MS + 1, stopping: false, serviceUp: false,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.persist, false, '状态没变就不写盘');
assert.equal(d.state.failures, 2, '计数不许被计划内重启搅动');
assert.equal(d.exitCode, 1, '退出码仍交回 supervisor 重启');
assert.equal(d.reason, 'runtime-exit');
});
test('decideAfterExit:服务已被别的实例提供 → 归零退出 0(撞车不算失败)', () => {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 500, stopping: false, serviceUp: true,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.state.failures, 0);
assert.equal(d.exitCode, 0);
assert.equal(d.reason, 'service-up');
});
test('decideAfterExit:本守卫被要求停(systemctl stop)→ 不计数', () => {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 60_000, stopping: true, serviceUp: false,
state: { failures: 1, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.persist, false);
assert.equal(d.state.failures, 1);
assert.equal(d.reason, 'stopping');
});
test('decideAfterExit:干净退出 0 → 成功后归零', () => {
const d = decideAfterExit({
code: 0, signal: null, livedMs: 5, stopping: false, serviceUp: false,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(d.counted, false);
assert.equal(d.state.failures, 0);
assert.equal(d.exitCode, 0);
assert.equal(d.reason, 'clean-exit');
});
// ---------- 纯函数:备份命名与指纹(含跨端一致性) ----------
test('backupNameRegex:认两套备份命名(deploy 固定名 + selfupdate 时间戳名)', () => {
const re = backupNameRegex('agent.mjs');
assert.ok(re.test('agent.mjs.bak'), 'deploy 固定名');
assert.ok(re.test('agent.mjs.bak-1729300000000'), 'selfupdate 时间戳名');
assert.ok(!re.test('agent.mjs.bakX'), '后缀必须整段匹配');
assert.ok(!re.test('agent.mjs'), '入口自己不是备份');
assert.ok(!re.test('other.mjs.bak'), '别名的备份不认');
const re2 = backupNameRegex('index.js');
assert.ok(re2.test('index.js.bak'));
});
test('跨端一致:守卫与 CLI rollback 的备份命名/指纹规则同口径', () => {
// 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致
const names = ['agent.mjs.bak', 'agent.mjs.bak-123', 'agent.mjs.bakX', 'agent.mjs', 'x.bak', 'a+b.c.bak'];
for (const entry of ['agent.mjs', 'index.js', 'a+b.c']) {
const a = backupNameRegex(entry);
const b = cliBackupNameRegex(entry);
for (const n of names) {
assert.equal(a.test(n), b.test(n), `口径漂移: entry=${entry} name=${n}`);
}
}
const full = createHash('sha256').update('x').digest('hex');
assert.equal(hashMatches(full, full.slice(0, 12)), cliHashMatches(full, full.slice(0, 12)));
assert.equal(hashMatches(full, full), cliHashMatches(full, full));
assert.equal(hashMatches('', 'x'), cliHashMatches('', 'x'));
});
test('hashMatches:全量相等/12 位前缀回落;空值与反向不匹配', () => {
const full = createHash('sha256').update('content').digest('hex');
assert.equal(hashMatches(full, full), true);
assert.equal(hashMatches(full, full.slice(0, 12)), true, '老 LKG 12 位前缀回落');
assert.equal(hashMatches(full.slice(0, 12), full), false, '不得反向匹配');
assert.equal(hashMatches('', full), false);
assert.equal(hashMatches(full, ''), false);
});
test('planRestore:无匹配返回 null;按给定顺序取第一个匹配者', () => {
const good = createHash('sha256').update('good').digest('hex');
const bad = createHash('sha256').update('bad').digest('hex');
assert.equal(planRestore([], good), null);
assert.equal(planRestore([{ path: '/a', hash: bad }], good), null);
assert.deepEqual(planRestore([{ path: '/a', hash: good }], good), { path: '/a', hash: good });
assert.deepEqual(
planRestore([{ path: '/new', hash: bad }, { path: '/old', hash: good }], good),
{ path: '/old', hash: good },
'匹配优先于新近(新备份不是 LKG 时不许被抢)',
);
});
// ---------- 参数解析 ----------
test('parseGuardArgs:-- 之后是完整启动命令;缺值/未知参数报错', () => {
const o = parseGuardArgs([
'--data-dir', '/d', '--lkg-dir', '/l', '--install-dir', '/i',
'--threshold', '3', '--port', '8765', '--entry', 'agent.mjs',
'--', 'node', '/i/agent.mjs',
]);
assert.equal(o.dataDir, '/d');
assert.equal(o.lkgDir, '/l');
assert.equal(o.installDir, '/i');
assert.equal(o.threshold, 3);
assert.equal(o.port, 8765);
assert.equal(o.entry, 'agent.mjs');
assert.deepEqual(o.cmd, ['node', '/i/agent.mjs']);
assert.equal(parseGuardArgs([]).cmd.length, 0, '没有 -- 就没有启动命令');
assert.throws(() => parseGuardArgs(['--data-dir']), /缺少取值/);
assert.throws(() => parseGuardArgs(['--wat']), /未知参数/);
});
// ---------- IO 集成(真实临时目录) ----------
const GOOD = Buffer.from('GOOD-AGENT-LKG-CONTENT');
const BAD = Buffer.from('BROKEN-AGENT-CONTENT');
const goodHash = createHash('sha256').update(GOOD).digest('hex');
function makeBox() {
const root = mkdtempSync(join(tmpdir(), 'na-guard-'));
const dataDir = join(root, 'data');
const lkgDir = join(root, 'install');
const installDir = lkgDir;
mkdirSync(dataDir, { recursive: true });
mkdirSync(installDir, { recursive: true });
return { root, dataDir, lkgDir, installDir };
}
function writeLkg(dataDir, rec) {
writeFileSync(join(dataDir, 'agent-lkg.json'), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
}
test('restoreFromLkg:有 LKG 且指纹匹配 → 就地覆写入口 + 内容正确', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
assert.equal(r.backup, join(lkgDir, 'agent.mjs.bak'));
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD, '入口必须被还原成 LKG 内容');
});
test('restoreFromLkg:新备份不是 LKG 时不许被抢(匹配优先于新近)', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
const stale = join(lkgDir, 'agent.mjs.bak-1000');
const fresh = join(lkgDir, 'agent.mjs.bak-2000');
writeFileSync(stale, GOOD);
writeFileSync(fresh, BAD);
// fresh 更新(mtime 更大),但它不是 LKG
const now = Date.now() / 1000;
utimesSync(fresh, now, now);
utimesSync(stale, now - 3600, now - 3600);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
assert.equal(r.backup, stale, '必须命中指纹匹配的旧备份,而不是更新的那个');
});
test('restoreFromLkg:老 LKG 只有 12 位 hash 也能按前缀匹配', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), bytes: GOOD.length, // 无 hash_full(v2.1.0 之前的记录)
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'win32',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
});
test('restoreFromLkg:无 LKG → 放行且 install-dir 一个字节都不动', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const before = readFileSync(join(installDir, 'agent.mjs'));
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.equal(r.reason, 'no-lkg');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), before, '无 LKG 绝不清空入口');
});
test('restoreFromLkg:有 LKG 但无指纹匹配 → 放行且 install-dir 不动', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: 'aaaaaaaaaaaa', hash_full: createHash('sha256').update('other').digest('hex'),
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.equal(r.reason, 'no-match');
assert.equal(r.candidates.length, 1, '候选指纹要带回供排查');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD, '无匹配绝不清空入口');
});
test('restoreFromLkg:无备份候选 → no-candidates,不动入口', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(installDir, 'agent.mjs'), BAD);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.equal(r.reason, 'no-candidates');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD);
});
test('restoreFromLkg:目标不可写(异常路径)→ 吞掉返回 error,不抛', () => {
const { dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
mkdirSync(join(installDir, 'agent.mjs')); // 入口是目录 → 写必失败
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false);
assert.ok(r.reason.startsWith('error:'), '异常必须被吞成 error 返回');
});
test('listBackupCandidates:按 mtime 新→早,只收 <entry>.bak*', () => {
const { lkgDir } = makeBox();
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(lkgDir, 'agent.mjs.bak-1'), GOOD);
writeFileSync(join(lkgDir, 'agent.json'), '{}');
writeFileSync(join(lkgDir, 'other.bak'), GOOD);
const now = Date.now() / 1000;
utimesSync(join(lkgDir, 'agent.mjs.bak'), now - 100, now - 100);
utimesSync(join(lkgDir, 'agent.mjs.bak-1'), now, now);
const cands = listBackupCandidates(lkgDir, 'agent.mjs');
assert.deepEqual(cands.map((c) => c.name), ['agent.mjs.bak-1', 'agent.mjs.bak']);
});
test('hashFileSync:与 createHash 全量一致', () => {
const { lkgDir } = makeBox();
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
assert.equal(hashFileSync(join(lkgDir, 'agent.mjs.bak')), goodHash);
});
// ---------- 状态文件与日志 ----------
test('readStateFile/writeState:落盘可读回;损坏文件按 0', () => {
const { dataDir } = makeBox();
writeState(dataDir, { failures: 2, updated_at: 'T' });
assert.deepEqual(readStateFile(dataDir), { failures: 2, updated_at: 'T' });
assert.ok(existsSync(statePath(dataDir)));
// 损坏 → 0(不猜)
writeFileSync(statePath(dataDir), 'garbage', 'utf8');
assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' });
// 缺失 → 0
rmSync(statePath(dataDir), { force: true });
assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' });
});
test('appendGuardLog:JSONL 日志落盘且含关键字段', () => {
const { dataDir } = makeBox();
appendGuardLog(dataDir, { action: 'restore', restored: true, backup: '/x/agent.mjs.bak' });
appendGuardLog(dataDir, { action: 'child-exit', code: 1, counted: true, failures: 2 });
const p = join(dataDir, 'logs', 'startup-guard.log');
assert.ok(existsSync(p), '恢复/退出事件必须留日志');
const lines = readFileSync(p, 'utf8').trim().split('\n');
assert.equal(lines.length, 2);
const first = JSON.parse(lines[0]);
assert.equal(first.action, 'restore');
assert.equal(first.restored, true);
assert.ok(first.ts, '日志必须带时间戳');
});
test('readLkgRecord:损坏/缺字段 → null', () => {
const { dataDir } = makeBox();
assert.equal(readLkgRecord(dataDir), null);
writeFileSync(join(dataDir, 'agent-lkg.json'), 'nope', 'utf8');
assert.equal(readLkgRecord(dataDir), null);
writeFileSync(join(dataDir, 'agent-lkg.json'), JSON.stringify({ bytes: 1 }), 'utf8');
assert.equal(readLkgRecord(dataDir), null, '缺 hash 字段视为无效');
});
test('resolvePort:--port 优先 → agent.json 回落 → 都没有返回 null', () => {
const { dataDir } = makeBox();
assert.equal(resolvePort(dataDir, 9000), 9000);
writeFileSync(join(dataDir, 'agent.json'), JSON.stringify({ port: 8765 }), 'utf8');
assert.equal(resolvePort(dataDir, null), 8765, '未传 --port 时读 agent.json');
assert.equal(resolvePort(dataDir, 9000), 9000, '传了就以参数为准');
rmSync(join(dataDir, 'agent.json'), { force: true });
assert.equal(resolvePort(dataDir, null), null, 'Hub 模式无端口 → 跳过端口判断');
});
// ---------- 端到端小剧场:连续崩溃 → 恢复 → 再起 ----------
test('场景:崩溃三次后第四次守卫执行恢复并归零计数', () => {
const { root, dataDir, lkgDir, installDir } = makeBox();
writeLkg(dataDir, {
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
});
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
writeFileSync(join(installDir, 'agent.mjs'), BAD);
// 前三次:坏版本启动即死 → 计数 1、2、3(均未达阈值,不恢复)
let state = initialState('T0');
for (let i = 1; i <= 3; i += 1) {
const d = decideAfterExit({
code: 1, signal: null, livedMs: 600, stopping: false, serviceUp: false, state, nowIso: `T${i}`,
});
assert.equal(d.counted, true);
state = d.state;
assert.equal(decideRestore(state, 3), i === 3, `第 ${i} 次后阈值判定`);
}
assert.equal(state.failures, 3);
// 第四次:达到阈值 → 恢复 + 归零
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, true);
state = zeroFailures('T4');
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD);
assert.equal(state.failures, 0);
// 恢复日志已在盘上
appendGuardLog(dataDir, { action: 'restore', restored: true, backup: r.backup });
assert.ok(readFileSync(join(dataDir, 'logs', 'startup-guard.log'), 'utf8').includes('restore'));
rmSync(root, { recursive: true, force: true });
});