Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=89174a0f5c28ff7f49c794f37ea7e9528f9b526e. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
@@ -16,3 +16,6 @@ runtime/
|
||||
|
||||
# Mimosa 安全扫描插件的逐会话状态(hook-status/reports/ledger,机器自动重建)
|
||||
.mimosa/
|
||||
|
||||
# e2e.sh 运行工件(构建/种子/日志;scripts/e2e.sh 输出)
|
||||
.tmp/
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# feature/rdm-u1-dev-closeout 任务卡
|
||||
|
||||
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:weichen
|
||||
任务主记录:PR #363 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/363(merge 3ef65a9142);状态:已完成(回填);owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
## 结果与验证
|
||||
|
||||
- 结果:方案 r1 状态行(已合入 #340 + U1 RDM 侧落地);U1 进展段(RDM #199 merge 9b1b78a / #200);INDEX 导航行;Dev 钉 modules/documents=ab6b36c;PROJECT.md 状态行更新。
|
||||
- 验证:make test exit 0(SELF,收据在卡;含 pins/test-worktree 组合检查);CI 以 PR 为准 [GATE]。
|
||||
@@ -243,3 +243,9 @@
|
||||
}
|
||||
```
|
||||
| 同步前验收 | 53a3effef777c83bd6baa63f8181f34480533559 | d3e3ce02d600a7e0802ea86d4eb7d4fee012f175 | 2026-10-11T14:21:19Z |
|
||||
|
||||
## 闭合记录(card-autoclose 回填)
|
||||
|
||||
- 依据:merge 3ef65a91428cf28e73dd2a23127dda014e455d87(PR #363)已包含于 origin/main,分支成果已落盘;本段为合入后回填。
|
||||
- 合并主题:Merge pull request 'Record U1 progress in the final-target plan and pin the closeout documents' (#363) from feature/rdm-u1-dev-closeout into main
|
||||
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ PLUGINS := plugins
|
||||
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
|
||||
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update test-backend test-backend-pg test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
|
||||
|
||||
version:
|
||||
@echo "agent-system $(VERSION)"
|
||||
@@ -67,6 +67,8 @@ help:
|
||||
@echo "make svc-commits 列出可部署提交:make svc-commits SRC=dev|release [N=20]"
|
||||
@echo "make svc-history 事件历史:make svc-history [ENV] [N=20](完整文档:docs/svc.md)"
|
||||
@echo "make svc-update 自动更新过时服务:make svc-update [NAMES='dev pg1'] [ARGS='--check --all']"
|
||||
@echo "make svc-recover 自恢复:拉回 autostart=1 但已死的服务:make svc-recover [ARGS='dev --quiet']"
|
||||
@echo "make svc-watchdog 自恢复看护:make svc-watchdog ARGS='install|remove|status'(systemd 用户 timer)"
|
||||
@echo "make dev 开发模式说明(前后端分离热更新)"
|
||||
@echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)"
|
||||
@echo "make status submodule 状态"
|
||||
@@ -175,6 +177,12 @@ svc-commits:
|
||||
svc-history:
|
||||
scripts/svc.sh history $(ENV) $(N)
|
||||
|
||||
svc-recover:
|
||||
scripts/svc.sh recover $(ARGS)
|
||||
|
||||
svc-watchdog:
|
||||
scripts/svc.sh watchdog $(ARGS)
|
||||
|
||||
svc-update:
|
||||
scripts/svc.sh update $(NAMES) $(ARGS)
|
||||
|
||||
|
||||
+100
-9
@@ -168,7 +168,7 @@ scripts/svc.sh ui deploy --kind static --dir ../dist # 直接服务既有
|
||||
| `SVC_<NAME>_ALLOWED_HOSTS` | 逗号分隔的额外主机名,传给 `AGENT_SYSTEM_ALLOWED_HOSTS`,供反向代理的 Host / Origin 校验 |
|
||||
| `--model-base/--model-key/--model-name`、`--light-*`、`--tavily-keys` | 启动健康后播种 |
|
||||
| `--dry-run` | 只解析并打印部署计划(来源、物化路径、产物去向、启动参数) |
|
||||
| `--pick [dev\|release]` | 交互式编号选提交(需 TTY) |
|
||||
| `--pick [dev\|release\|rdm]` | 交互式编号选提交(需 TTY) |
|
||||
| `--config FILE` / `--env FILE` | 配置文件(见 §9) |
|
||||
| `-- <剩余参数>` | 原样透传给 `agent-system serve` |
|
||||
|
||||
@@ -268,7 +268,8 @@ scripts/svc.sh <agent名> config --clear # 移除(回命令行参数
|
||||
|
||||
## 5. 命令参考:部署来源
|
||||
|
||||
`deploy --from` 支持三种来源(**缺省 = `dev` 远端最新**),agent 与 static 共用:
|
||||
`deploy --from` 支持四种来源(**缺省 = `dev` 远端最新**):dev/local/release 通用于
|
||||
agent 与 static,rdm 仅限 agent。
|
||||
|
||||
### 5.1 dev(缺省)—— 远端最新 / 指定提交
|
||||
|
||||
@@ -315,12 +316,44 @@ dev 源的提交解析、`commits` 列举与构建 worktree 物化全部基于
|
||||
仓库定位:`SVC_RELEASES_DIR` > `../Agent-System-Releases`。制品缺失时提示先在
|
||||
Releases 仓跑 `scripts/build-release.sh <v>`。
|
||||
|
||||
### 5.4 辅助
|
||||
### 5.4 rdm —— 下游组合仓 Agent-System-RDM(agent 专用)
|
||||
|
||||
```
|
||||
--from rdm # RDM 源仓 origin/main 最新
|
||||
--from rdm@<sha> # 指定提交
|
||||
```
|
||||
|
||||
部署**下游组合二进制**(基座 + RDM 领域插件:两树一网与 `rdm.*` 追溯审计命令面)。
|
||||
与 dev 源同构的独立源仓机制:默认 `~/.cache/agent-system-svc/rdm-src`
|
||||
(`SVC_RDM_SRC_DIR` 覆盖路径;克隆来源 `SVC_RDM_ORIGIN` 覆盖,缺省为 RDM 远端 ssh
|
||||
地址),流程 = fetch → `worktree add --detach runtime/.worktrees/rdm-<sha>` →
|
||||
`git submodule update --init --recursive`(物化 `pins/` 基座钉入组合)→ `make build`
|
||||
(纯 Go,无需 npm)→ `bin/agent-system-rdm` 复制为环境二进制 → worktree 回收。
|
||||
|
||||
与基座部署的差异:
|
||||
|
||||
- **插件目录按空集安装**:RDM 组合插件编译进二进制(`extension.RegisterCompiled`),
|
||||
无声明式插件资产;管理页「插件管理」列表只反映目录插件,组合插件经
|
||||
`/api/registry` 与命令面可见;
|
||||
- **web/ 工作台**:RDM 自带零构建静态工作台(直连后端 API),deploy 时复制到
|
||||
`runtime/<name>/htdocs/`,用伴生 static 服务伺服
|
||||
(`scripts/svc.sh <name>web deploy --kind static --dir runtime/<name>/htdocs`;
|
||||
注意该目录随本环境 `rm --purge` 一并删除);
|
||||
- **启动兼容**:组合二进制容忍基座风格的首位 `serve` 子命令并提供 `version`
|
||||
子命令;svc 的 `version`/`serve -h` 探测带 timeout 护栏;
|
||||
- **不支持** `--mock` 与 `--config`(组合 CLI 无此旗标);模型设置播种
|
||||
(`--model-*`/`--tavily-keys`)仅基座产物支持,rdm 源跳过(登记态
|
||||
`source=rdm@*` 即识别形态,跨裸 start/restart 稳定);
|
||||
- **PG 档**:`db <pg实例>` 绑定同样走 `AGENT_SYSTEM_DSN` 环境交付;RDM CLI 在
|
||||
`-dsn` 为空时回退读该环境变量(无 `config explain` 子命令时,svc 以 ELF 内静态
|
||||
引用作能力证据)。
|
||||
|
||||
### 5.5 辅助
|
||||
|
||||
```bash
|
||||
scripts/svc.sh commits [dev|release] [N] # 可部署提交表(默认 20 条)
|
||||
scripts/svc.sh <name> deploy --pick # 交互式编号选择
|
||||
scripts/svc.sh <name> deploy --dry-run # 只打印计划
|
||||
scripts/svc.sh commits [dev|release|rdm] [N] # 可部署提交表(默认 20 条)
|
||||
scripts/svc.sh <name> deploy --pick # 交互式编号选择
|
||||
scripts/svc.sh <name> deploy --dry-run # 只打印计划
|
||||
```
|
||||
|
||||
`commits` 表格:dev 源标注 `[最新]`(源仓 origin/main 顶)与 `[已部署:<服务名>]`
|
||||
@@ -422,18 +455,76 @@ scripts/svc.sh update [name...] [--check] [--all] [--no-fetch]
|
||||
|---|---|
|
||||
| `local@<sha>` / `dev@<sha>` | 一律 `deploy --from dev@<远端 main 最新>`(独立部署源仓 + 隔离 worktree 构建;不 pull 主检出、不看工作树状态) |
|
||||
| `release@<版本>` | `deploy --from release@<Releases 最新版本>`(sha256 校验) |
|
||||
| `rdm@<sha>` | `deploy --from rdm@<RDM 远端 main 最新>`(组合二进制;见 §5.4) |
|
||||
|
||||
任一服务失败不中断其余服务;最终汇总 `成功/失败/跳过`。退出码:0 全部成功或最新;
|
||||
1 有失败;2 用法错误。
|
||||
|
||||
### 6.5 help
|
||||
### 6.5 recover(自恢复:拉回死亡服务)
|
||||
|
||||
```bash
|
||||
scripts/svc.sh recover # 扫描全部已登记服务
|
||||
scripts/svc.sh recover dev pg1 # 点名恢复
|
||||
scripts/svc.sh recover --quiet # 静默(watchdog timer 用)
|
||||
```
|
||||
|
||||
svc 服务是裸后台进程(nohup):进程崩溃、被 OOM killer 杀掉或机器重启后,
|
||||
**没有人把它拉回来**——recover 补上这一环。
|
||||
|
||||
判定依据是 svc.env 的 **autostart 期望态键**:
|
||||
|
||||
| 动作 | autostart |
|
||||
|---|---|
|
||||
| start / restart / deploy / update 健康检查通过 | 置 `1`(期望在运行) |
|
||||
| 显式 `stop`(含 deploy/update 的前置停机) | 移除(保持停止是明确意图) |
|
||||
| 进程自行死亡(崩溃 / OOM 被杀 / 重启后) | 保留 → recover 据此拉回 |
|
||||
| `rm` 注销 | 键随 svc.env 消失,不再触碰 |
|
||||
|
||||
行为细节:
|
||||
|
||||
- 扫描顺序 postgres 在前、agent/static 在后(绑定实例先起,agent 重建 DSN 才有效);
|
||||
- 只对 `autostart=1` 且 pidfile+cmdline 校验已死的服务执行**裸 start**(复用持久化
|
||||
`exec=` 与数据库绑定,不改任何配置、不重建产物);
|
||||
- 逐服务持 `runtime/<name>/.start.lock`(非阻塞):deploy(含构建窗口)/手动 start
|
||||
持锁期间跳过本轮,不抢跑;
|
||||
- 每次尝试写 `recover ok|fail` 事件(`history` 可追溯);进程反复即退时自然限频
|
||||
约 1 次/分钟(健康探测 20s + timer 周期);
|
||||
- deploy/update **失败**(构建失败、预检拒绝)→ 服务保持停止等人工介入,watchdog
|
||||
不盲目复活(前置停机已清 autostart);
|
||||
- 端口被外部进程占用时 start 报错放弃(不抢不杀),下轮重试。
|
||||
|
||||
### 6.6 watchdog(自恢复看护 timer)
|
||||
|
||||
```bash
|
||||
scripts/svc.sh watchdog install # 安装并启用 systemd 用户 timer(每分钟 recover)
|
||||
scripts/svc.sh watchdog install --print # 只打印单元文件与计划
|
||||
scripts/svc.sh watchdog remove # 停用并删除 timer
|
||||
scripts/svc.sh watchdog status # timer 状态 + autostart=1 的服务清单
|
||||
```
|
||||
|
||||
- 单元写入 `~/.config/systemd/user/agent-system-svc-recover.{service,timer}`
|
||||
(`OnBootSec=1min` + `OnUnitActiveSec=60s`),`ExecStart=<本仓>/scripts/svc.sh recover --quiet`;
|
||||
- 开机自启依赖 linger(`sudo loginctl enable-linger <user>`);未开时安装会提示,
|
||||
登出不受影响(KillUserProcesses 默认关,进程本就不随登出死);
|
||||
- 换检出目录后重装一次即可(status 会提示 timer 指向的 ROOT 与当前不一致);
|
||||
- 无 systemd 用户会话的环境(如某些容器)install 会拒绝并给出 cron 降级命令:
|
||||
`* * * * * <ROOT>/scripts/svc.sh recover --quiet >/dev/null 2>&1`。
|
||||
|
||||
一键自恢复配方:
|
||||
|
||||
```bash
|
||||
scripts/svc.sh dev deploy # 启动成功 → autostart=1
|
||||
scripts/svc.sh watchdog install # 之后 kill -9 / OOM / 重启,约 1 分钟内自动拉回
|
||||
```
|
||||
|
||||
### 6.7 help
|
||||
|
||||
```bash
|
||||
scripts/svc.sh # 一屏速览(短帮助)
|
||||
scripts/svc.sh -h # 同上
|
||||
scripts/svc.sh --help # 完整帮助(全部命令/参数/约束)
|
||||
scripts/svc.sh help # 分主题清单
|
||||
scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle db dbops config examples safety
|
||||
scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle db dbops config recovery examples safety
|
||||
```
|
||||
|
||||
## 7. 命令参考:数据库
|
||||
@@ -615,7 +706,7 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
|
||||
| `pid` | 最近启动 pid |
|
||||
| `addr` | 监听地址(持久化,下次 start 复用) |
|
||||
| `tags` | 标签(逗号分隔) |
|
||||
| `source` | 部署来源:`local@<sha>` / `dev@<sha>` / `release@<版本>` / `dir@<路径>` / `postgres@local` / `clone@<名>` |
|
||||
| `source` | 部署来源:`local@<sha>` / `dev@<sha>` / `release@<版本>` / `rdm@<sha>` / `dir@<路径>` / `postgres@local` / `clone@<名>` |
|
||||
| `version` | 产物版本串(`agent-system version` 输出) |
|
||||
| `started` | 最近启动时间 |
|
||||
| `pidcheck` | 进程判定标记(见 §6.1) |
|
||||
|
||||
+1
-1
Submodule modules/backend updated: 25fbea8522...e49f165d0b
+1
-1
Submodule modules/frontend updated: ed9845f8e4...7acfef0a1f
+1
-1
Submodule modules/ui updated: f2331b02d6...bfecf8b51f
Executable
+223
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e.sh —— U4 运行验收一条龙(真实浏览器:Playwright × RDM 审计工作区)。
|
||||
#
|
||||
# 动线(与 frontend/e2e/rdm-audit.spec.ts 的环境契约一一对应):
|
||||
# 1. 构建:RDM 二进制(RDM 仓 origin/main 临时 worktree,pins 物化)
|
||||
# + 基座 CLI(本 worktree modules/backend,建号用)
|
||||
# + frontend dist(本 worktree modules/frontend)
|
||||
# 2. 种子:RDM -bootstrap-admin(域管理员)→ 基座 user create(登录账号)
|
||||
# → serve(auth on)→ API rdm.project.create×2 + rdm.ingest(主项目)
|
||||
# 3. 验收:vite preview(dist+/api 代理)→ npx playwright test
|
||||
# 4. 收尾:停 serve/preview,保存认证/失败工件到 $E2E_OUT
|
||||
#
|
||||
# 用法:bash scripts/e2e.sh [--seed-only]
|
||||
# --seed-only 只做构建+种子+起服务(留端口与路径在 stdout,供人工/调试)
|
||||
# 环境变量:
|
||||
# RDM_REPO RDM 仓路径(默认 <本仓上级>/Agent-System-RDM)
|
||||
# E2E_API_PORT/E2E_UI_PORT 服务端口(默认 18791/4173)
|
||||
# E2E_OWNER/E2E_PASSWORD 登录账号(默认 e2e-auditor/E2eAudit#2026)
|
||||
# E2E_PROJECT_MAIN/E2E_PROJECT_ISOLATED 项目名(默认 审计演示项目/隔离检查项目)
|
||||
# E2E_OUT 工件目录(默认 .tmp/e2e/<ts>;.gitignore 已覆盖 .tmp)
|
||||
# 依赖:go、node/npm、curl;首次运行会 npx playwright install chromium。
|
||||
set -euo pipefail
|
||||
|
||||
DEV_ROOT=$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel)
|
||||
cd "$DEV_ROOT"
|
||||
|
||||
SEED_ONLY=0
|
||||
[ "${1:-}" = "--seed-only" ] && SEED_ONLY=1
|
||||
|
||||
RDM_REPO=${RDM_REPO:-"$DEV_ROOT/../Agent-System-RDM"}
|
||||
API_PORT=${E2E_API_PORT:-18791}
|
||||
UI_PORT=${E2E_UI_PORT:-4173}
|
||||
OWNER=${E2E_OWNER:-e2e-auditor}
|
||||
PASSWORD=${E2E_PASSWORD:-E2eAudit#2026}
|
||||
PROJ_MAIN=${E2E_PROJECT_MAIN:-审计演示项目}
|
||||
PROJ_ISO=${E2E_PROJECT_ISOLATED:-隔离检查项目}
|
||||
API_BASE="http://127.0.0.1:$API_PORT"
|
||||
# 工件目录一律绝对路径(git -C / 后台进程的 cwd 解析不能依赖调用方位置)。
|
||||
OUT=${E2E_OUT:-"$DEV_ROOT/.tmp/e2e/$(date +%Y%m%d-%H%M%S)"}
|
||||
case "$OUT" in /*) ;; *) OUT="$PWD/$OUT" ;; esac
|
||||
mkdir -p "$OUT"
|
||||
|
||||
log() { printf '\033[36m[e2e]\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31m[e2e][FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v go >/dev/null || die "go not found"
|
||||
command -v node >/dev/null || die "node not found"
|
||||
command -v curl >/dev/null || die "curl not found"
|
||||
[ -d "$RDM_REPO/.git" ] || die "RDM repo not found at $RDM_REPO (set RDM_REPO)"
|
||||
|
||||
for p in "$API_PORT" "$UI_PORT"; do
|
||||
if timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/$p" 2>/dev/null; then
|
||||
die "port $p already in use (set E2E_API_PORT/E2E_UI_PORT)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 1. 构建 ─────────────────────────────────────────────
|
||||
# RDM 源码经 git archive 从本地对象导出(不建 worktree、不触远端——Gitea 抖动时
|
||||
# submodule 克隆会挂死);pins/* 从 RDM 主检出的已物化 submodule 快照拷贝(cp 排除
|
||||
# .git,39MB 级),构建输入由此固定为 RDM origin/main + 其 gitlink 指向的本地内容。
|
||||
log "build RDM binary (archive origin/main + snapshot pins from local checkout)"
|
||||
RDM_WT="$OUT/rdm-src"
|
||||
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main in RDM repo"
|
||||
mkdir -p "$RDM_WT"
|
||||
git -C "$RDM_REPO" archive "$RDM_SHA" | tar -x -C "$RDM_WT"
|
||||
# 主检出 pins 对齐 gitlink(本地对象已有时秒回;此时才需要网络 fetch 一次)。
|
||||
git -C "$RDM_REPO" submodule update --init --recursive --quiet \
|
||||
|| die "materialize RDM pins failed (is the RDM checkout reachable?)"
|
||||
PIN_SHA_MAIN=$(git -C "$RDM_REPO/pins/agent-system-backend" rev-parse HEAD)
|
||||
PIN_SHA_WANT=$(git -C "$RDM_REPO" ls-tree "$RDM_SHA" pins/agent-system-backend | awk '{print $3}')
|
||||
[ "$PIN_SHA_MAIN" = "$PIN_SHA_WANT" ] \
|
||||
|| die "RDM checkout pins/backend ($PIN_SHA_MAIN) != origin/main pin ($PIN_SHA_WANT); git -C $RDM_REPO submodule update first"
|
||||
mkdir -p "$RDM_WT/pins"
|
||||
tar -C "$RDM_REPO/pins" --exclude=.git -cf - agent-system-backend | tar -C "$RDM_WT/pins" -xf -
|
||||
cleanup() {
|
||||
[ -n "${API_PID:-}" ] && kill "$API_PID" 2>/dev/null || true
|
||||
# npx→node 的进程树用单 PID 杀不净:按命令行匹配兜底(本脚本专用端口)。
|
||||
[ -n "${UI_PID:-}" ] && kill "$UI_PID" 2>/dev/null || true
|
||||
pkill -f "vite preview --port $UI_PORT" 2>/dev/null || true
|
||||
pkill -f "agent-system-rdm -data.*e2e" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
|
||||
log "rdm binary: $OUT/agent-system-rdm (RDM@${RDM_SHA:0:12}, pins@${PIN_SHA_WANT:0:12})"
|
||||
|
||||
log "build base CLI (user create) from the same pinned backend as the RDM binary"
|
||||
# 版本一致性:建号 CLI 必须与被测 RDM 二进制共用同一基座 pin——用 Dev 顶层
|
||||
# backend(main 最新)建号会把库迁到更高 schema,RDM pins(较旧)打不开。
|
||||
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) \
|
||||
|| die "backend cli build failed"
|
||||
|
||||
log "build frontend dist (contracts + ui deps + bundle, via make frontend)"
|
||||
make -C "$DEV_ROOT" frontend >/dev/null || die "frontend build failed"
|
||||
|
||||
# ── 2. 种子 ─────────────────────────────────────────────
|
||||
DATA="$OUT/data"
|
||||
mkdir -p "$DATA"
|
||||
RDM="$OUT/agent-system-rdm -data $DATA"
|
||||
|
||||
log "bootstrap: RDM domain admin = $OWNER"
|
||||
$RDM -bootstrap-admin "$OWNER" >/dev/null
|
||||
|
||||
log "bootstrap: base account $OWNER (admin)"
|
||||
"$OUT/agent-system" user create --data "$DATA" --owner "$OWNER" --name "E2E 审计员" \
|
||||
--password "$PASSWORD" --admin >/dev/null
|
||||
|
||||
log "serve RDM at $API_BASE (auth on)"
|
||||
start_serve() {
|
||||
"$OUT/agent-system-rdm" -data "$DATA" -addr "127.0.0.1:$API_PORT" -auth on \
|
||||
>"$OUT/serve.log" 2>&1 &
|
||||
API_PID=$!
|
||||
# auth on 时未登录 /api/me 返回 401——服务已就绪的信号是「有 HTTP 应答」而非 2xx。
|
||||
for i in $(seq 1 40); do
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/api/me" || echo 000)
|
||||
[ "$CODE" != "000" ] && return 0
|
||||
sleep 0.25
|
||||
[ "$i" = 40 ] && { tail -5 "$OUT/serve.log"; die "api not ready"; }
|
||||
done
|
||||
}
|
||||
stop_serve() {
|
||||
[ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; API_PID=""; }
|
||||
}
|
||||
login() {
|
||||
curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"owner\":\"$OWNER\",\"password\":\"$PASSWORD\"}" >/dev/null || die "login failed"
|
||||
}
|
||||
|
||||
cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容忍部分成功)
|
||||
local id=$1 params=${2:-{\}}
|
||||
curl -sf -b "$JAR" -X POST "$API_BASE/api/commands/$id" \
|
||||
-H 'Content-Type: application/json' -d "{\"params\":$params}" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); sys.exit(1) if r.get("state")=="rejected" else None; print(json.dumps(d.get("payload", d), ensure_ascii=False))' \
|
||||
|| die "command $id rejected"
|
||||
}
|
||||
|
||||
# 阶段①:建项目与需求台账(API;需 serve)。
|
||||
start_serve
|
||||
JAR="$OUT/cookies.txt"
|
||||
login
|
||||
log "seed: create projects"
|
||||
PID_MAIN=$(cmd rdm.project.create "{\"name\":\"$PROJ_MAIN\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
||||
PID_ISO=$(cmd rdm.project.create "{\"name\":\"$PROJ_ISO\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
||||
log "seed: projects main=$PID_MAIN iso=$PID_ISO"
|
||||
|
||||
log "seed: import requirement ledger into $PID_MAIN"
|
||||
cmd rdm.reqs.import "{\"projectId\":\"$PID_MAIN\",\"items\":$(
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
rows = [
|
||||
("UR", "UR-01 离线可用", "断网时已有会话可继续浏览"),
|
||||
("UR", "UR-02 权限收敛", "普通用户无审计入口,接口拒绝"),
|
||||
("UR", "UR-03 取证效率", "追溯取证不超过三次主要点击"),
|
||||
]
|
||||
print(json.dumps([
|
||||
{"kind": k, "title": t, "statement": s, "source": "01-用户需求与系统需求.md"}
|
||||
for k, t, s in rows
|
||||
], ensure_ascii=False))
|
||||
PY
|
||||
)}" >/dev/null
|
||||
|
||||
# 阶段②:CLI 直写库(-structure 项目归属装载;与 serve 不并发写 SQLite)。
|
||||
stop_serve
|
||||
log "seed: load doc-tree structure into $PID_MAIN"
|
||||
"$OUT/agent-system-rdm" -data "$DATA" \
|
||||
-structure "$RDM_WT/docs/structure/rdm-self.yaml" -project "$PID_MAIN" \
|
||||
>"$OUT/structure.log" 2>&1 || { tail -5 "$OUT/structure.log"; die "structure load failed"; }
|
||||
grep -o '装载(.*)' "$OUT/structure.log" | head -1 || true
|
||||
|
||||
# 阶段③:重启服务,切片入库挂载到已装载骨架(API)。
|
||||
start_serve
|
||||
login
|
||||
|
||||
SEED_MD='# 用户需求与系统需求
|
||||
|
||||
| UR-01 | 离线可用:断网时已有会话可继续浏览 |
|
||||
| UR-02 | 权限收敛:普通用户无审计入口 |
|
||||
| UR-03 | 追溯取证不超过三次主要点击 |
|
||||
|
||||
## 背景说明
|
||||
|
||||
审计工作区按项目隔离读取;管理员可跨项目目录进入。事件按操作归组,
|
||||
历史模式按 seq 还原,缺失事实明确标注不可恢复。'
|
||||
log "seed: ingest corpus into $PID_MAIN"
|
||||
cmd rdm.ingest "{\"projectId\":\"$PID_MAIN\",\"source\":\"01-用户需求与系统需求.md\",\"stage\":\"BD\",\"text\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<<"$SEED_MD")}" >/dev/null
|
||||
log "seed done"
|
||||
|
||||
# ── 3. 验收 ─────────────────────────────────────────────
|
||||
if [ "$SEED_ONLY" = 1 ]; then
|
||||
log "seed-only mode: api=$API_BASE ui=next;cookie jar=$JAR;数据=$DATA"
|
||||
log "preview: (cd modules/frontend && PLAYWRIGHT_API_TARGET=$API_BASE npx vite preview --port $UI_PORT)"
|
||||
log "inspect: curl -b $JAR $API_BASE/api/commands/rdm.inspect.summary -X POST -H 'Content-Type: application/json' -d '{\"params\":{\"projectId\":\"$PID_MAIN\"}}'"
|
||||
wait
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "start vite preview at http://127.0.0.1:$UI_PORT"
|
||||
(cd "$DEV_ROOT/modules/frontend" && \
|
||||
PLAYWRIGHT_API_TARGET="$API_BASE" npx vite preview --port "$UI_PORT" --strictPort \
|
||||
>"$OUT/preview.log" 2>&1 &
|
||||
echo $! > "$OUT/preview.pid")
|
||||
UI_PID=$(cat "$OUT/preview.pid")
|
||||
for i in $(seq 1 40); do
|
||||
curl -sf -o /dev/null "http://127.0.0.1:$UI_PORT/" && break
|
||||
sleep 0.25
|
||||
[ "$i" = 40 ] && { tail -5 "$OUT/preview.log"; die "preview not ready"; }
|
||||
done
|
||||
|
||||
log "playwright (install chromium if missing)"
|
||||
(cd "$DEV_ROOT/modules/frontend" && npx playwright install chromium >/dev/null 2>&1 || true)
|
||||
|
||||
log "run specs"
|
||||
cd "$DEV_ROOT/modules/frontend"
|
||||
export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" E2E_PASSWORD="$PASSWORD" \
|
||||
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
|
||||
set +e
|
||||
npx playwright test --reporter=list >"$OUT/playwright.log" 2>&1
|
||||
PW_EXIT=$?
|
||||
set -e
|
||||
cat "$OUT/playwright.log"
|
||||
[ "$PW_EXIT" = 0 ] && log "PASS" || die "playwright exit=$PW_EXIT (log: $OUT/playwright.log, trace: $OUT/frontend/test-results/)"
|
||||
|
||||
log "done; artifacts in $OUT"
|
||||
+509
-31
@@ -20,9 +20,12 @@
|
||||
# scripts/svc.sh db copy <src> <dst> [--db X] [--force] 跨实例搬库(双方须运行)
|
||||
# scripts/svc.sh db ls <name> 实例内数据库列表
|
||||
# scripts/svc.sh db set <name> [...] 等价于 set
|
||||
# scripts/svc.sh commits [dev|release] [N] 可部署提交列表(默认 dev,N=20)
|
||||
# scripts/svc.sh commits [dev|release|rdm] [N] 可部署提交列表(默认 dev,N=20)
|
||||
# scripts/svc.sh history [name] [N] 事件历史(默认全部,最近 20 条)
|
||||
# scripts/svc.sh update [name] [--check] [--all] 检测远端最新并更新过时服务(与当前工作区状态无关)
|
||||
# scripts/svc.sh recover [name] [--quiet] 自恢复:把 autostart=1 但已死的服务重新拉起
|
||||
# (崩溃/OOM 被杀/重启后由 watchdog 定时调用)
|
||||
# scripts/svc.sh watchdog install|remove|status 自恢复看护:systemd 用户级 timer(每分钟 recover)
|
||||
# scripts/svc.sh help [主题] 分主题详细帮助
|
||||
#
|
||||
# ════════════════════ 部署来源(agent/static 的 deploy)════════════════════
|
||||
@@ -32,7 +35,10 @@
|
||||
# 隔离 worktree(runtime/.worktrees/<sha>)进行,不触碰当前工作区
|
||||
# --from local 构建当前工作区(唯一写主检出构建产物的路径,均 gitignore)
|
||||
# --from release[@<版本|sha>] Releases 仓制品(sha256 对 versions/<v>/manifest.json 校验)
|
||||
# --pick [dev|release] 交互式编号选提交;--dry-run 只打印部署计划
|
||||
# --from rdm[@<sha>] 下游组合仓 Agent-System-RDM(独立 RDM 源仓物化 pins/ 后
|
||||
# make build 出 agent-system-rdm 组合二进制;web/ 工作台静态
|
||||
# 资产随环境落 htdocs/,供伴生 static 服务 --dir 使用)
|
||||
# --pick [dev|release|rdm] 交互式编号选提交;--dry-run 只打印部署计划
|
||||
#
|
||||
# ════════════════════ 常用参数(服务级)════════════════════
|
||||
# --config FILE / --env FILE dotenv 配置文件(KEY=VALUE;--env 为兼容别名)
|
||||
@@ -69,6 +75,7 @@
|
||||
# 其余按名字确定性派生(8800-9099,避让已登记/监听端口),SVC_<NAME>_ADDR 可覆盖
|
||||
# - runtime/<name>/:bin|htdocs|data|pgdata|plugins|backups + svc.pid +
|
||||
# svc.log|pg.log + svc.env(登记+持久化配置,600)+ history.jsonl(事件流)
|
||||
# + .start.lock(生命周期互斥锁,recover 与 deploy/手动操作防竞态)
|
||||
# - kind:agent(后端单二进制,内嵌前端)/ static(前端静态服务)/ postgres(数据库);
|
||||
# 无 kind= 的旧登记自动视为 agent;start/restart 使用环境内产物
|
||||
#
|
||||
@@ -91,6 +98,8 @@
|
||||
# scripts/svc.sh db restore pg2 --file <dump> --db app --force
|
||||
# scripts/svc.sh list --tag core && scripts/svc.sh history
|
||||
# scripts/svc.sh info && scripts/svc.sh info pg1 --reveal # 全景报告 / 单服务深度(含掩码值)
|
||||
# scripts/svc.sh watchdog install # 装看护 timer:崩溃/OOM 被杀后自动拉回
|
||||
# kill -9 <pid> && sleep 65 && scripts/svc.sh list # start 成功记 autostart=1,一分钟内自动恢复
|
||||
# scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge
|
||||
# make svc ENV=dev ARGS="--env .env.dev --port 9000"
|
||||
#
|
||||
@@ -119,10 +128,17 @@ SVC_ENVFILE=""
|
||||
# dev 源的解析/列举/worktree 物化全部基于它——更新与部署因此不依赖当前工作区状态
|
||||
# (脏树、任意分支均可);目录可随时删除,下次使用自动重建。
|
||||
SRC_DIR="${SVC_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/src}"
|
||||
# 独立 RDM 源仓(下游组合仓 Agent-System-RDM 的干净克隆;SVC_RDM_SRC_DIR 覆盖路径,
|
||||
# SVC_RDM_ORIGIN 覆盖克隆来源)。rdm 源的解析/列举/物化全部基于它,语义与 SRC_DIR 一致。
|
||||
RDM_SRC_DIR="${SVC_RDM_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/rdm-src}"
|
||||
RDM_ORIGIN_DEFAULT="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git"
|
||||
|
||||
# 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。
|
||||
c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
|
||||
c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
|
||||
c_info() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
|
||||
c_ok() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
|
||||
# c_event:事件级输出,不受 --quiet 抑制——watchdog 静默周期里有实际拉回/失败
|
||||
# 仍要在 journal 留痕(跳过类的噪声才被 --quiet 吞掉)。
|
||||
c_event() { printf '\033[1;36m[svc]\033[0m %s\n' "$*" >&2; }
|
||||
c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; }
|
||||
c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
@@ -150,6 +166,8 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
|
||||
scripts/svc.sh commits [dev|release] [N] 可部署提交
|
||||
scripts/svc.sh history [name] [N] 事件历史
|
||||
scripts/svc.sh update [name] [--check] 刷新过时服务到远端最新(不依赖工作区状态)
|
||||
scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务
|
||||
scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover)
|
||||
|
||||
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle db dbops config examples safety)
|
||||
文档:docs/svc.md
|
||||
@@ -157,7 +175,7 @@ EOF
|
||||
}
|
||||
|
||||
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
|
||||
HELP_TOPICS="model deploy lifecycle db dbops config examples safety"
|
||||
HELP_TOPICS="model deploy lifecycle db dbops config recovery examples safety"
|
||||
|
||||
cmd_help() {
|
||||
local topic="${1:-}"
|
||||
@@ -196,6 +214,7 @@ update deploy stop start restart logs help info version。
|
||||
svc.log | pg.log 日志(postgres 用 pg.log)
|
||||
svc.env 登记 + 持久化配置(dotenv,600)
|
||||
history.jsonl 事件流(append-only,一行一个 JSON)
|
||||
.start.lock 生命周期互斥锁(recover 与 deploy/手动 start 防竞态)
|
||||
|
||||
svc.env 字段:
|
||||
kind agent|static|postgres(旧登记无此键视为 agent)
|
||||
@@ -210,6 +229,8 @@ svc.env 字段:
|
||||
pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory)
|
||||
exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)。
|
||||
只存非敏感参数——DSN 不在其中,数据库配置走 db=/dbref=/dsn= 专键
|
||||
autostart 期望运行态(成功 start 置 1;显式 stop 移除;进程死亡保留——
|
||||
recover/watchdog 据此拉回,见 help recovery)
|
||||
db agent 数据库模式:pg | sqlite(显式解绑)| mock;从未配置则无此键
|
||||
dbref agent 托管数据库绑定 <pg名>/<库名>(启动时由实例登记重建 DSN)
|
||||
dsn agent 外部直连 DSN(受保护,600;未命中本机实例时使用)
|
||||
@@ -220,6 +241,7 @@ history.jsonl 事件(scripts/svc.sh history 查看):
|
||||
create db create / 物理克隆登记
|
||||
deploy begin|ok|fail(含来源)
|
||||
start ok|fail|warn(fail 含原因:进程即退/健康检查超时)
|
||||
recover ok|fail(watchdog/手动 recover 拉回死亡服务)
|
||||
stop ok|fail
|
||||
set 登记变更(addr 旧 → 新)
|
||||
backup ok|fail(文件与大小)
|
||||
@@ -252,7 +274,16 @@ EOF
|
||||
依赖当前工作区状态的来源。
|
||||
release[@<版本|sha>] 取 Releases 仓 build/<v>/agent-system(+plugins),
|
||||
sha256 对 versions/<v>/manifest.json 校验,不符即拒绝。
|
||||
--pick [dev|release] 交互式编号选择(需 TTY;列表与提示走 stderr)
|
||||
rdm[@<sha>] 部署下游组合仓 Agent-System-RDM:独立 RDM 源仓
|
||||
(SVC_RDM_SRC_DIR,默认 ~/.cache/agent-system-svc/rdm-src,
|
||||
首次按 SVC_RDM_ORIGIN——缺省 RDM 远端——克隆)worktree 物化
|
||||
pins/ 子模块后 make build(纯 Go,无需 npm),产物
|
||||
bin/agent-system-rdm 复制为环境二进制;插件目录按空集安装
|
||||
(组合插件编译进二进制),web/ 工作台静态资产随环境落
|
||||
htdocs/(伴生 static 服务用 --dir 指向该目录)。
|
||||
注意:rdm 源不支持 --mock 与 --config;模型设置播种
|
||||
(--model-*/--tavily-keys)仅基座产物支持。
|
||||
--pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr)
|
||||
--dry-run 只打印部署计划不执行
|
||||
|
||||
行为细节:
|
||||
@@ -308,6 +339,10 @@ EOF
|
||||
scripts/svc.sh rm <name> 停止并移除登记(目录/数据/历史保留)
|
||||
scripts/svc.sh rm <name> --purge 连 runtime/<name>/ 整个目录一起删除
|
||||
|
||||
自动恢复(崩溃 / OOM 被杀 / 机器重启后自动拉回;详见 help recovery):
|
||||
scripts/svc.sh recover [name] [--quiet] 把 autostart=1 但已死的服务重新拉起
|
||||
scripts/svc.sh watchdog install systemd 用户 timer 每分钟执行 recover
|
||||
|
||||
自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关):
|
||||
scripts/svc.sh update --check 只报告:每服务 当前来源 vs 最新(fetch 后比较)
|
||||
scripts/svc.sh update 更新所有"过时且运行中"的服务
|
||||
@@ -435,6 +470,39 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只
|
||||
scripts/svc.sh <name> config [file|--clear] 更换/移除/查看(见 help lifecycle);
|
||||
切换前 schema 校验(agent-system config explain -config <file>),运行中自动重启
|
||||
即时生效;登记于 svc.env conffile=,info/status 可见。
|
||||
EOF
|
||||
return 0 ;;
|
||||
recovery)
|
||||
cat >&2 <<'EOF'
|
||||
【recovery】自恢复:autostart 期望态 + recover + watchdog
|
||||
|
||||
期望运行态(autostart 键,svc.env):
|
||||
- start / restart / deploy / update 成功启动(健康检查通过)→ autostart=1
|
||||
- 显式 stop(含 deploy/update 前置停机)→ 移除 autostart(保持停止是明确意图)
|
||||
- 进程自行死亡(崩溃、OOM 被杀、机器重启后)→ 键保留,等待恢复
|
||||
- rm 注销后无 svc.env,recover 不再触碰
|
||||
|
||||
scripts/svc.sh recover [name] [--quiet]
|
||||
扫描已登记服务(postgres 优先于 agent/static,保证绑定实例先起),
|
||||
对 autostart=1 且 pidfile+cmdline 校验已死的服务执行裸 start
|
||||
(复用持久化 exec= 与数据库绑定);每次尝试写 recover ok/fail 事件。
|
||||
逐服务持 .start.lock(非阻塞,被 deploy/手动 start 持有时跳过本轮)。
|
||||
进程反复即退时自然限频为约 1 次/分钟(健康探测 20s + timer 周期)。
|
||||
|
||||
scripts/svc.sh watchdog install 安装 systemd 用户级 timer(每分钟 recover):
|
||||
~/.config/systemd/user/agent-system-svc-recover.{service,timer}
|
||||
(OnBootSec=1min + OnUnitActiveSec=60s;开机自启依赖
|
||||
loginctl enable-linger,未开时安装会提示)
|
||||
scripts/svc.sh watchdog remove 停用并移除 timer
|
||||
scripts/svc.sh watchdog status timer 状态 + autostart=1 的服务清单
|
||||
无 systemd 用户会话的环境可退化为 cron:
|
||||
* * * * * <ROOT>/scripts/svc.sh recover --quiet >/dev/null 2>&1
|
||||
|
||||
语义边界:
|
||||
- recover 只用既有登记拉起,不重建产物、不改变任何配置;
|
||||
- deploy/update 失败(构建失败、预检拒绝)→ 服务保持停止等人工介入,
|
||||
watchdog 不盲目复活(前置停机已清 autostart);
|
||||
- 端口被外部进程占用时 start 报错放弃(不抢不杀,watchdog 每轮重试无害)。
|
||||
EOF
|
||||
return 0 ;;
|
||||
examples)
|
||||
@@ -539,7 +607,7 @@ CLI_SEED_OWNER=""
|
||||
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0
|
||||
CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0
|
||||
CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT=""
|
||||
CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0
|
||||
CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0; CLI_QUIET=0
|
||||
CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH=""
|
||||
CLI_TAGS=()
|
||||
|
||||
@@ -566,7 +634,7 @@ parse_args() {
|
||||
--seed-owner) need_value "$@"; CLI_SEED_OWNER="$2"; shift 2 ;;
|
||||
--from) need_value "$@"; CLI_FROM="$2"; shift 2 ;;
|
||||
--pick) CLI_PICK="dev"
|
||||
case "${2:-}" in dev|release) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;;
|
||||
case "${2:-}" in dev|release|rdm) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;;
|
||||
--dry-run) CLI_DRYRUN=1; shift ;;
|
||||
--kind) need_value "$@"; CLI_KIND="$2"; shift 2 ;;
|
||||
--dir) need_value "$@"; CLI_DIR="$2"; shift 2 ;;
|
||||
@@ -600,7 +668,7 @@ addr_compose() {
|
||||
}
|
||||
|
||||
# ---- 名称与端口 ----
|
||||
RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version "
|
||||
RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog "
|
||||
|
||||
# valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。
|
||||
valid_env() {
|
||||
@@ -626,10 +694,16 @@ ports_recorded() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# port_listening PORT:端口是否被监听。优先 ss;不可用或无命中时退化为 bash 内建
|
||||
# /dev/tcp 连接探测(CI 最小容器无 iproute2 时端口检查会失明——2026-10-11 实测:
|
||||
# thief 占口,recover 未识别照常启动,健康探测打到 thief 上误报已启动)。
|
||||
port_listening() {
|
||||
local p="$1"
|
||||
command -v ss >/dev/null 2>&1 || return 1
|
||||
ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}'
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' && return 0
|
||||
fi
|
||||
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# derive_port NAME:由服务名确定性派生端口(8800+hash%300),冲突时在 8800..9099 递增。
|
||||
@@ -783,6 +857,36 @@ port_owner() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
|
||||
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
|
||||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失时降级为无锁直通(尽力而为)。
|
||||
# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。
|
||||
SVC_LOCK_HELD=0
|
||||
svc_lock() {
|
||||
[ "$SVC_LOCK_HELD" = 1 ] && return 0
|
||||
command -v flock >/dev/null 2>&1 || { SVC_LOCK_HELD=1; return 0; }
|
||||
mkdir -p "$ENV_DIR" 2>/dev/null || return 1
|
||||
exec 9>>"$ENV_DIR/.start.lock" || return 1
|
||||
if [ "$1" = "-n" ]; then
|
||||
flock -n 9 || { exec 9>&-; return 1; }
|
||||
else
|
||||
flock -w 300 9 || c_warn "[$ENV_NAME] 生命周期锁等待超时(继续执行,注意并发)"
|
||||
fi
|
||||
SVC_LOCK_HELD=1
|
||||
return 0
|
||||
}
|
||||
svc_unlock() {
|
||||
[ "$SVC_LOCK_HELD" = 1 ] || return 0
|
||||
exec 9>&- || true
|
||||
SVC_LOCK_HELD=0
|
||||
return 0
|
||||
}
|
||||
|
||||
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 1,
|
||||
# 显式 stop 移除;进程自行死亡不动——recover 据此判断是否拉回)。
|
||||
svc_mark_autostart() { svc_set_key autostart 1 >/dev/null 2>&1 || true; }
|
||||
svc_clear_autostart() { svc_del_key autostart 2>/dev/null || true; }
|
||||
|
||||
check_port_free() {
|
||||
local owner; owner="$(port_owner)"
|
||||
if [ -n "$owner" ]; then
|
||||
@@ -790,6 +894,13 @@ check_port_free() {
|
||||
c_err "处理:停掉该进程,或换端口(--port / --addr)"
|
||||
return 1
|
||||
fi
|
||||
# ss 不可用时 port_owner 为空——再经 port_listening(含 /dev/tcp 兜底)确认,
|
||||
# 否则无 iproute2 的环境里启动守卫完全失明(CI 容器实测)。
|
||||
if port_listening "${ENV_ADDR##*:}"; then
|
||||
c_err "[$ENV_NAME] 端口 ${ENV_ADDR##*:} 正被监听(未能识别占用者;无 ss 时仅能探测不能归因)"
|
||||
c_err "处理:停掉该进程,或换端口(--port / --addr)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 服务配置解析(CLI > 配置文件/环境变量 > svc.env 持久化 > 默认)----
|
||||
@@ -867,7 +978,8 @@ agent_has_explicit_cfg() {
|
||||
# 旧实现 grep -- '--dsn' 永不命中,把现役二进制的 --dsn/--auth 误当不支持而丢弃)。
|
||||
adjust_args_for_bin() {
|
||||
local bin="$1" args="$2" out
|
||||
out="$("$bin" serve -h 2>&1 || true)"
|
||||
# timeout 护栏:组合二进制若把 serve 当位置参数忽略而直接进入服务态,探测会挂死。
|
||||
out="$(timeout 5 "$bin" serve -h 2>&1 || true)"
|
||||
local filtered="" w drop_next=0 flag
|
||||
for w in $args; do
|
||||
case "$w" in
|
||||
@@ -924,6 +1036,29 @@ svc_src_ready() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# rdm_src_ready:确保独立 RDM 源仓就绪(缺省从 RDM 远端克隆,SVC_RDM_ORIGIN 覆盖来源、
|
||||
# SVC_RDM_SRC_DIR 覆盖路径)并 fetch;--no-fetch 跳过网络。与 svc_src_ready 同构,
|
||||
# 面向下游组合仓 Agent-System-RDM(构建组合二进制 agent-system-rdm)。
|
||||
RDM_SRC_READY_DONE=0
|
||||
rdm_src_ready() {
|
||||
[ "$RDM_SRC_READY_DONE" = 1 ] && return 0
|
||||
if [ ! -d "$RDM_SRC_DIR/.git" ]; then
|
||||
local url="${SVC_RDM_ORIGIN:-$RDM_ORIGIN_DEFAULT}"
|
||||
c_info "初始化独立 RDM 源仓:$RDM_SRC_DIR(← $url;与当前工作区无关,可随时删除重建)"
|
||||
mkdir -p "$(dirname "$RDM_SRC_DIR")"
|
||||
if ! git clone --quiet "$url" "$RDM_SRC_DIR" >&2; then
|
||||
c_err "RDM 源仓克隆失败:$url → $RDM_SRC_DIR(可用 SVC_RDM_ORIGIN 指定克隆来源)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ "${1:-}" != "--no-fetch" ]; then
|
||||
c_info "git fetch(独立 RDM 源仓:$RDM_SRC_DIR)"
|
||||
git -C "$RDM_SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "RDM 源仓 fetch 失败——基于本地缓存($RDM_SRC_DIR)"
|
||||
fi
|
||||
RDM_SRC_READY_DONE=1
|
||||
return 0
|
||||
}
|
||||
|
||||
deployed_envs() {
|
||||
local key="$1" d e s out=""
|
||||
for d in "$RUNTIME"/*/; do
|
||||
@@ -955,6 +1090,22 @@ print_commit_list() { # SRC N PICK:提交表到 stdout;PICK=1 时填充 PICK
|
||||
printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks"
|
||||
[ "$pick" = 1 ] && PICK_OPTS+=("$h")
|
||||
done < <(git -C "$SRC_DIR" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short origin/main)
|
||||
elif [ "$src" = rdm ]; then
|
||||
rdm_src_ready || { c_err "RDM 源仓不可用,无法列出可部署提交"; return 1; }
|
||||
c_info "RDM 仓提交(独立 RDM 源仓 $RDM_SRC_DIR 的 origin/main;倒序取 $count 条)——部署:scripts/svc.sh <name> deploy --from rdm@<sha>"
|
||||
printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题'
|
||||
local latest_short; latest_short="$(git -C "$RDM_SRC_DIR" rev-parse --short origin/main 2>/dev/null || true)"
|
||||
local n=0 h date subject marks envs
|
||||
while IFS="$sep" read -r h date subject; do
|
||||
[ -z "$h" ] && continue
|
||||
n=$((n + 1))
|
||||
marks=""
|
||||
[ "$h" = "$latest_short" ] && marks="${marks}[最新]"
|
||||
envs="$(deployed_envs "rdm@$h")"
|
||||
[ -n "$envs" ] && marks="${marks}[已部署:$envs]"
|
||||
printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks"
|
||||
[ "$pick" = 1 ] && PICK_OPTS+=("$h")
|
||||
done < <(git -C "$RDM_SRC_DIR" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short origin/main)
|
||||
else
|
||||
c_info "Releases 仓提交($REL_ROOT;倒序取 $count 条)——部署:scripts/svc.sh <name> deploy --from release@<版本>"
|
||||
printf ' %3s %-9s %-10s %-8s %-9s %s\n' '#' 'sha' '日期' '版本' 'dev提交' '主题'
|
||||
@@ -981,8 +1132,8 @@ print_commit_list() { # SRC N PICK:提交表到 stdout;PICK=1 时填充 PICK
|
||||
cmd_commits() {
|
||||
local src="${1:-dev}" count="${2:-20}"
|
||||
case "$src" in
|
||||
dev|release) ;;
|
||||
*) c_err "用法:scripts/svc.sh commits [dev|release] [N]"; exit 2 ;;
|
||||
dev|release|rdm) ;;
|
||||
*) c_err "用法:scripts/svc.sh commits [dev|release|rdm] [N]"; exit 2 ;;
|
||||
esac
|
||||
case "$count" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac
|
||||
[ "$src" = release ] && releases_root
|
||||
@@ -1059,8 +1210,23 @@ resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/
|
||||
SRC_KINDSRC=release
|
||||
SRC_LABEL="release@$REL_VERSION"
|
||||
;;
|
||||
rdm|rdm@*)
|
||||
# 缺省 ref=origin/main(独立 RDM 源仓远端最新);显式 @<sha> 亦可。
|
||||
local ref="origin/main"
|
||||
case "$spec" in rdm@*) ref="${spec#rdm@}" ;; esac
|
||||
rdm_src_ready || { c_err "独立 RDM 源仓不可用——rdm 源依赖它(可用 SVC_RDM_ORIGIN 指定克隆来源、SVC_RDM_SRC_DIR 指定路径)"; exit 2; }
|
||||
local full
|
||||
full="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet "${ref}^{commit}")" || {
|
||||
c_err "RDM 源仓无此提交:$ref(已自动 fetch;可用项:scripts/svc.sh commits rdm)"
|
||||
exit 2
|
||||
}
|
||||
SRC_KINDSRC=rdm
|
||||
SRC_FULL="$full"
|
||||
SRC_SHA="$(git -C "$RDM_SRC_DIR" rev-parse --short "$full")"
|
||||
SRC_LABEL="rdm@$SRC_SHA"
|
||||
;;
|
||||
*)
|
||||
c_err "无法解析 --from $spec(可用:local | dev[@<sha>] | release[@<版本|sha>])"
|
||||
c_err "无法解析 --from $spec(可用:local | dev[@<sha>] | release[@<版本|sha>] | rdm[@<sha>])"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1102,12 +1268,39 @@ materialize_dev_commit() {
|
||||
echo "$wt"
|
||||
}
|
||||
|
||||
# recycle_worktree SHA:回收构建 worktree(尽力而为)。
|
||||
# recycle_worktree SHA [REPO]:回收构建 worktree(尽力而为)。REPO 缺省为 Dev 源仓。
|
||||
recycle_worktree() {
|
||||
local repo="${2:-$SRC_DIR}"
|
||||
local wt="$RUNTIME/.worktrees/$1"
|
||||
c_info "[$ENV_NAME] 回收构建 worktree"
|
||||
git -C "$SRC_DIR" worktree remove --force "$wt" >/dev/null 2>&1 \
|
||||
|| c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $SRC_DIR worktree remove --force $wt"
|
||||
git -C "$repo" worktree remove --force "$wt" >/dev/null 2>&1 \
|
||||
|| c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $repo worktree remove --force $wt"
|
||||
}
|
||||
|
||||
# materialize_rdm_commit FULL SHA:RDM 源的隔离 worktree 物化+构建;stdout 只回显
|
||||
# worktree 路径。构建 = make build(纯 Go:pins/ 子模块经 go.mod replace 就位后无需
|
||||
# npm 与网络);产物固定为 bin/agent-system-rdm(缺失即失败,绝不装半成品)。
|
||||
materialize_rdm_commit() {
|
||||
local full="$1" sha="$2"
|
||||
local wt="$RUNTIME/.worktrees/rdm-$sha"
|
||||
if [ -d "$wt" ]; then
|
||||
c_info "[$ENV_NAME] 复用已有构建 worktree:$wt"
|
||||
else
|
||||
c_info "[$ENV_NAME] 物化 RDM 提交 $sha → 隔离 worktree(源:独立 RDM 源仓 $RDM_SRC_DIR,不触碰当前工作区)"
|
||||
git -C "$RDM_SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1
|
||||
fi
|
||||
c_info "[$ENV_NAME] 物化基座 pins(git submodule update --init --recursive;缺对象时需网络)"
|
||||
git -C "$wt" submodule update --init --recursive >&2 || {
|
||||
c_err "[$ENV_NAME] pins 子模块物化失败(网络或对象缺失;重试会自动 fetch RDM 源仓)"
|
||||
return 1
|
||||
}
|
||||
c_info "[$ENV_NAME] 构建(make build:纯 Go,无需 npm)"
|
||||
make -C "$wt" build >&2 || return 1
|
||||
[ -x "$wt/bin/agent-system-rdm" ] || {
|
||||
c_err "[$ENV_NAME] 构建产物缺失:$wt/bin/agent-system-rdm(RDM Makefile 的 build 目标应产出该名称)"
|
||||
return 1
|
||||
}
|
||||
echo "$wt"
|
||||
}
|
||||
|
||||
materialize_release() {
|
||||
@@ -1165,6 +1358,25 @@ agent_do_deploy() {
|
||||
release)
|
||||
materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; }
|
||||
;;
|
||||
rdm)
|
||||
wt="$(materialize_rdm_commit "$SRC_FULL" "$SRC_SHA")" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||||
# RDM 组合插件编译进二进制(provides 无声明式资产)——插件目录按空集安装。
|
||||
mkdir -p "$wt/.svc-empty-plugins"
|
||||
install_artifacts "$wt/bin/agent-system-rdm" "$wt/.svc-empty-plugins" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||||
# RDM 自带零构建工作台(web/,直连后端 API 的静态资产)随环境落 htdocs/,
|
||||
# 供伴生 static 服务直接 --dir 使用(注意:随本环境 rm --purge 一并删除)。
|
||||
if [ -d "$wt/web" ]; then
|
||||
rm -rf "$ENV_DIR/htdocs"
|
||||
if cp -r "$wt/web" "$ENV_DIR/htdocs"; then
|
||||
c_ok "[$ENV_NAME] RDM 工作台就位:$ENV_DIR/htdocs(伴生服务:scripts/svc.sh ${ENV_NAME}web deploy --kind static --dir $ENV_DIR/htdocs)"
|
||||
else
|
||||
c_warn "[$ENV_NAME] RDM web/ 工作台复制失败(不影响 API 服务启动)"
|
||||
fi
|
||||
fi
|
||||
recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR"
|
||||
;;
|
||||
esac
|
||||
# 新产物承接能力复核(环境 DSN 交付):不支持即失败——绑定保持可恢复,不降级启动。
|
||||
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && ! bin_env_dsn_supported "$ENV_BIN"; then
|
||||
@@ -1194,6 +1406,11 @@ print_agent_plan() {
|
||||
local art="$REL_ROOT/build/$REL_VERSION/agent-system"
|
||||
if [ -f "$art" ]; then printf ' 制品:%s(存在,将做 sha256 校验)\n' "$art"
|
||||
else printf ' 制品:%s(缺失!需先在 Releases 仓构建,或换版本)\n' "$art"; fi ;;
|
||||
rdm)
|
||||
printf ' 来源:%s(完整提交 %s;独立 RDM 源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$RDM_SRC_DIR"
|
||||
printf ' 物化:worktree %s(pins submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/rdm-$SRC_SHA"
|
||||
printf ' 产物:%s ← bin/agent-system-rdm + 空插件目录(组合插件编译进二进制)\n' "$ENV_BIN"
|
||||
printf ' 工作台:web/ → %s/htdocs(伴生 static:--kind static --dir 该目录)\n' "$ENV_DIR" ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
@@ -1535,6 +1752,13 @@ bin_env_dsn_supported() {
|
||||
local out
|
||||
out="$(AGENT_SYSTEM_DSN='postgresql://svcprobe:svcprobe@127.0.0.1:9/svcprobe' "$1" config explain 2>/dev/null || true)"
|
||||
case "$out" in *svcprobe*) return 0 ;; esac
|
||||
# 无 config explain 子命令的组合二进制(下游仓产物):静态证据兜底——ELF 二进制内
|
||||
# 引用 AGENT_SYSTEM_DSN 即视为消费环境层 DSN(下游经基座 extension.Main 的 env
|
||||
# 回退生效)。仅对 ELF 生效:脚本文本必然含该字符串字样,会把「不支持」误判为
|
||||
# 「支持」(回归教训:test_svc 的 legacy 桩即脚本形态)。
|
||||
[ "$(head -c 4 "$1" 2>/dev/null)" = $'\x7fELF' ] || return 1
|
||||
command -v strings >/dev/null 2>&1 || return 1
|
||||
strings "$1" 2>/dev/null | grep -q 'AGENT_SYSTEM_DSN' && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -1563,10 +1787,11 @@ agent_do_start() {
|
||||
check_port_free || return 1
|
||||
if [ ! -x "$ENV_BIN" ]; then
|
||||
c_err "[$ENV_NAME] 环境产物缺失:$ENV_BIN"
|
||||
c_err "请先部署:scripts/svc.sh $ENV_NAME deploy [--from local|dev[@<sha>]|release[@<版本>]]"
|
||||
c_err "请先部署:scripts/svc.sh $ENV_NAME deploy [--from local|dev[@<sha>]|release[@<版本>]|rdm[@<sha>]]"
|
||||
return 1
|
||||
fi
|
||||
SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)"
|
||||
# timeout 护栏:组合二进制若不识别子命令可能直接进入服务态阻塞探测(旧产物形态)。
|
||||
SVC_VERSION="$(timeout 5 "$ENV_BIN" version 2>/dev/null || true)"
|
||||
# 统一解析数据库配置(复用与重建两路共用;失败拒绝启动,绝不静默回退 SQLite)。
|
||||
# 2026-10-11 修复:复用 exec= 分支此前不查 dbref——G1-7 迁移把 --dsn 剥入环境后
|
||||
# record_state 写回不含 DSN 的 exec=,重新绑定后的第二次裸 restart 即丢库。
|
||||
@@ -1641,10 +1866,12 @@ agent_do_start() {
|
||||
fi
|
||||
guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN")
|
||||
fi
|
||||
# 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续
|
||||
# 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。
|
||||
if [ "${#guard_env[@]}" -gt 0 ];then
|
||||
nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
|
||||
nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||||
else
|
||||
nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
|
||||
nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||||
fi
|
||||
echo $! > "$ENV_PID"
|
||||
# 健康探测:取 exec 里的 --addr(复用持久化配置时可能与当前派生值不同)。
|
||||
@@ -1659,9 +1886,15 @@ agent_do_start() {
|
||||
# 健康通过才写 svc.env——启动失败不再用新参数覆盖上次的可用配置
|
||||
#(2026-10-05 dev 事故:失败后坏 exec= 持久化,裸 start 复用导致二次失败)。
|
||||
record_state
|
||||
svc_mark_autostart
|
||||
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
|
||||
hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}"
|
||||
seed_settings "$phost" "${probe##*:}"
|
||||
# rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅
|
||||
# 基座产物支持;记录态 svc.env 的 source=rdm@* 跨裸 start/restart 稳定标识形态。
|
||||
case "${SVC_SOURCE:-}" in
|
||||
rdm@*) ;;
|
||||
*) seed_settings "$phost" "${probe##*:}" ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
pid_alive "$(read_pid)" || {
|
||||
@@ -1673,6 +1906,7 @@ agent_do_start() {
|
||||
done
|
||||
# 进程存活但未响应:按实际运行参数登记(进程确以新参数在跑,登记与现实一致)。
|
||||
record_state
|
||||
svc_mark_autostart
|
||||
c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log"
|
||||
hist_event start fail "$(read_pid)" "健康检查超时"
|
||||
return 1
|
||||
@@ -1709,6 +1943,12 @@ static_do_deploy() {
|
||||
hist_event deploy fail "$(read_pid)" "release 源不支持 static"
|
||||
return 1
|
||||
;;
|
||||
rdm)
|
||||
c_err "[$ENV_NAME] rdm 源不支持 static 构建(RDM 工作台为 agent 环境内 htdocs/ 的零构建资产)"
|
||||
c_err " 用法:先 <agent环境> deploy --from rdm,再 scripts/svc.sh <name> deploy --kind static --dir <agent环境>/htdocs"
|
||||
hist_event deploy fail "$(read_pid)" "rdm 源不支持 static"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
[ -d "$src_dist" ] || { c_err "[$ENV_NAME] 前端产物缺失:$src_dist"; hist_event deploy fail "$(read_pid)" "dist 缺失"; return 1; }
|
||||
mkdir -p "$ENV_DIR"
|
||||
@@ -1781,13 +2021,14 @@ static_do_start() {
|
||||
SVC_PIDCHECK="--directory $htdocs"
|
||||
SVC_VERSION=""
|
||||
c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs"
|
||||
nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 &
|
||||
nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||||
echo $! > "$ENV_PID"
|
||||
local probe_host="$host"
|
||||
[ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1"
|
||||
for _ in $(seq 1 50); do
|
||||
if curl -s -o /dev/null -m 2 "http://$probe_host:$port/" 2>/dev/null; then
|
||||
record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规)
|
||||
svc_mark_autostart
|
||||
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)"
|
||||
hist_event start ok "$(read_pid)" "http://$probe_host:$port/"
|
||||
return 0
|
||||
@@ -1800,6 +2041,7 @@ static_do_start() {
|
||||
sleep 0.2
|
||||
done
|
||||
record_state "htdocs=$htdocs"
|
||||
svc_mark_autostart
|
||||
c_err "[$ENV_NAME] 10s 健康检查未通过"
|
||||
hist_event start fail "$(read_pid)" "健康检查超时"
|
||||
return 1
|
||||
@@ -1918,7 +2160,7 @@ pg_do_start() {
|
||||
[ "$listen" = "0.0.0.0" ] || [ "$listen" = "::" ] && listen="'*'"
|
||||
c_info "[$ENV_NAME] 启动 postgres:$pgdata(addr $ENV_ADDR)"
|
||||
if ! "$pgbin/pg_ctl" -D "$pgdata" -w -t 30 -l "$ENV_DIR/pg.log" \
|
||||
-o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1; then
|
||||
-o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1 9>&-; then
|
||||
c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log"
|
||||
hist_event start fail "" "pg_ctl start 失败"
|
||||
return 1
|
||||
@@ -1941,6 +2183,7 @@ pg_do_start() {
|
||||
fi
|
||||
record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \
|
||||
"authmode=$(svc_get authmode)" "pgdata=$pgdata"
|
||||
svc_mark_autostart
|
||||
if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then
|
||||
c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))"
|
||||
hist_event start ok "$(read_pid)" "pg_isready ok"
|
||||
@@ -1959,11 +2202,13 @@ pg_do_stop() {
|
||||
if [ -z "$pid" ] || ! pid_alive "$pid"; then
|
||||
c_info "[$ENV_NAME] postgres 未在运行"
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)"
|
||||
if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
c_ok "[$ENV_NAME] postgres 已停止"
|
||||
hist_event stop ok "$pid" "pg_ctl fast"
|
||||
return 0
|
||||
@@ -1971,7 +2216,7 @@ pg_do_stop() {
|
||||
c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)"
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 100); do
|
||||
pid_alive "$pid" || { rm -f "$ENV_PID"; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; }
|
||||
pid_alive "$pid" || { rm -f "$ENV_PID"; svc_clear_autostart; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; }
|
||||
sleep 0.1
|
||||
done
|
||||
c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)"
|
||||
@@ -2697,6 +2942,7 @@ do_stop() {
|
||||
if ! pid_alive "$pid"; then
|
||||
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止
|
||||
return 0
|
||||
fi
|
||||
case "$SVC_KIND" in
|
||||
@@ -2705,6 +2951,7 @@ do_stop() {
|
||||
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
|
||||
proc_stop "$pid"
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
c_ok "[$ENV_NAME] 已停止"
|
||||
hist_event stop ok "$pid" "SIGTERM"
|
||||
;;
|
||||
@@ -2712,12 +2959,14 @@ do_stop() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# do_stop_quiet:deploy 前置——未运行时静默返回。
|
||||
# do_stop_quiet:deploy 前置——未运行时静默返回(同时清除期望态:
|
||||
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
|
||||
do_stop_quiet() {
|
||||
local pid; pid="$(read_pid)"
|
||||
if [ -z "$pid" ]; then return 0; fi
|
||||
if [ -z "$pid" ]; then svc_clear_autostart; return 0; fi
|
||||
if ! pid_alive "$pid"; then
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
case "$SVC_KIND" in
|
||||
@@ -2726,6 +2975,7 @@ do_stop_quiet() {
|
||||
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
|
||||
proc_stop "$pid"
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
c_ok "[$ENV_NAME] 已停止"
|
||||
hist_event stop ok "$pid" "SIGTERM"
|
||||
;;
|
||||
@@ -3206,6 +3456,7 @@ cmd_update() {
|
||||
case "$1" in
|
||||
--check) CLI_CHECK=1; shift ;;
|
||||
--all) CLI_ALL=1; shift ;;
|
||||
--quiet) CLI_QUIET=1; shift ;;
|
||||
--no-fetch) CLI_NOFETCH=1; shift ;;
|
||||
--) shift; break ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -3231,11 +3482,12 @@ cmd_update() {
|
||||
fi
|
||||
[ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; }
|
||||
|
||||
# 按目标来源决定访问哪些仓:local@/dev@ 走独立部署源仓;release@ 走 Releases 仓。
|
||||
local latest_sha="" latest_rel="" need_rel=0 need_dev=0 e_src
|
||||
# 按目标来源决定访问哪些仓:local@/dev@ 走独立部署源仓;release@ 走 Releases 仓;
|
||||
# rdm@ 走独立 RDM 源仓。
|
||||
local latest_sha="" latest_rel="" latest_rdm="" need_rel=0 need_dev=0 need_rdm=0 e_src
|
||||
for e in ${targets[@]+"${targets[@]}"}; do
|
||||
e_src="$(sed -n 's/^source=//p' "$RUNTIME/$e/svc.env" | head -1)"
|
||||
case "$e_src" in release@*) need_rel=1 ;; local@*|dev@*) need_dev=1 ;; esac
|
||||
case "$e_src" in release@*) need_rel=1 ;; local@*|dev@*) need_dev=1 ;; rdm@*) need_rdm=1 ;; esac
|
||||
done
|
||||
local fetch_note="" src_args=""
|
||||
[ "$CLI_NOFETCH" = 1 ] && { fetch_note="--no-fetch,基于本地缓存引用"; src_args="--no-fetch"; }
|
||||
@@ -3257,6 +3509,13 @@ cmd_update() {
|
||||
latest_rel="$(git -C "$REL_ROOT" ls-tree --name-only "$rel_head:versions" 2>/dev/null | sort -V | tail -1 || true)"
|
||||
fi
|
||||
fi
|
||||
if [ "$need_rdm" = 1 ]; then
|
||||
if rdm_src_ready $src_args; then
|
||||
latest_rdm="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet origin/main || true)"
|
||||
else
|
||||
c_warn "独立 RDM 源仓不可用——rdm 源服务本轮无法检测与更新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 逐服务分析:状态判定 + 收集可更新动作。
|
||||
c_info "更新检测($fetch_note;root: $ROOT)"
|
||||
@@ -3313,6 +3572,25 @@ cmd_update() {
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
rdm@*)
|
||||
cur="${src##*@}"
|
||||
if [ -z "$latest_rdm" ]; then
|
||||
state="RDM 远端 main 未知"; latest_disp="-"
|
||||
elif ! git -C "$RDM_SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then
|
||||
state="提交不在 RDM 远端(不自动更新;重新 deploy 可纳入更新轨道)"
|
||||
latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
|
||||
elif [ "${latest_rdm:0:${#cur}}" = "$cur" ]; then
|
||||
state="✓ 最新"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
|
||||
elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$cur" "$latest_rdm" 2>/dev/null; then
|
||||
to="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || true)"
|
||||
latest_disp="$to"
|
||||
state="可更新"
|
||||
elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$latest_rdm" "$cur" 2>/dev/null; then
|
||||
state="领先 RDM 远端 main(不动作)"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
|
||||
else
|
||||
state="与 RDM 远端 main 分叉(不自动更新)"; latest_disp="-"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
state="来源未知(重新 deploy 后可参与更新)" ;;
|
||||
esac
|
||||
@@ -3346,10 +3624,12 @@ cmd_update() {
|
||||
case "$from" in
|
||||
local@*|dev@*) deploy_from="dev@$tov" ;;
|
||||
release@*) deploy_from="release@$tov" ;;
|
||||
rdm@*) deploy_from="rdm@$tov" ;;
|
||||
esac
|
||||
c_info "[$name] 更新:$from → $tov($deploy_from)"
|
||||
hist_event update begin "$(read_pid)" "from=$from to=$tov"
|
||||
CLI_FROM="$deploy_from"
|
||||
svc_lock wait
|
||||
if agent_do_deploy; then
|
||||
hist_event update ok "$(read_pid)" "from=$from to=$tov"
|
||||
c_ok "[$name] 已更新:$from → $tov"
|
||||
@@ -3359,12 +3639,203 @@ cmd_update() {
|
||||
c_err "[$name] 更新失败($from → $tov),其余服务继续"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
svc_unlock
|
||||
CLI_FROM=""
|
||||
done
|
||||
c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用"
|
||||
[ "$failed" -eq 0 ] && return 0 || return 1
|
||||
}
|
||||
|
||||
# ---- 自恢复:recover 扫描拉回 + watchdog(systemd 用户 timer)----
|
||||
|
||||
# recover_targets [NAME...]:postgres 在前(绑定的 agent 依赖实例先起),其余按登记序。
|
||||
recover_targets() {
|
||||
local n
|
||||
while IFS= read -r n; do
|
||||
[ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n"
|
||||
done < <(registered_names)
|
||||
while IFS= read -r n; do
|
||||
[ "$(svc_kind "$n")" != postgres ] && printf '%s\n' "$n"
|
||||
done < <(registered_names)
|
||||
}
|
||||
|
||||
# cmd_recover [NAME...]:把 autostart=1 但已死的服务重新拉起(裸 start 复用持久化
|
||||
# exec= 与数据库绑定;逐服务非阻塞持锁,被 deploy/手动 start 持有时跳过本轮)。
|
||||
# 退出码:0 = 全部成功或无需动作;1 = 有恢复尝试失败(端口被占/启动失败等)。
|
||||
cmd_recover() {
|
||||
local names=() a
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--quiet) CLI_QUIET=1; shift ;;
|
||||
--) shift; break ;;
|
||||
-h|--help) cat >&2 <<'EOF'
|
||||
用法:scripts/svc.sh recover [name...] [--quiet]
|
||||
自恢复:扫描已登记服务(缺省全部;postgres 优先),对 svc.env autostart=1
|
||||
且进程已死(pidfile+cmdline 校验)的服务执行裸 start 拉回。
|
||||
通常由 watchdog timer 每分钟调用;手动执行用于立即恢复。
|
||||
EOF
|
||||
return 0 ;;
|
||||
-*) c_err "recover 未知参数:$1(可用:--quiet)"; return 2 ;;
|
||||
*) names+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
local targets=() n rc=0 recovered=0 failed=0 skipped=0
|
||||
if [ "${#names[@]}" -gt 0 ]; then
|
||||
for a in ${names[@]+"${names[@]}"}; do
|
||||
valid_env "$a" || { c_err "非法服务名:$a"; return 2; }
|
||||
[ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; }
|
||||
done
|
||||
# 点名时保持用户顺序,但 postgres 仍前置(依赖顺序)。
|
||||
local pgs=() others=()
|
||||
for a in ${names[@]+"${names[@]}"}; do
|
||||
[ "$(svc_kind "$a")" = postgres ] && pgs+=("$a") || others+=("$a")
|
||||
done
|
||||
targets=(${pgs[@]+"${pgs[@]}"} ${others[@]+"${others[@]}"})
|
||||
else
|
||||
while IFS= read -r n; do targets+=("$n"); done < <(recover_targets)
|
||||
fi
|
||||
[ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; }
|
||||
for n in ${targets[@]+"${targets[@]}"}; do
|
||||
[ "$(env_kv_of "$n" autostart)" = "1" ] || { skipped=$((skipped + 1)); continue; }
|
||||
local st; st="$(svc_pid_state "$n")"
|
||||
if [ "${st%% *}" = alive ]; then skipped=$((skipped + 1)); continue; fi
|
||||
env_config "$n"
|
||||
if ! svc_lock -n; then
|
||||
c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过"
|
||||
skipped=$((skipped + 1)); continue
|
||||
fi
|
||||
c_info "[$n] recover:autostart=1 且进程已死,重新拉起(复用持久化配置)"
|
||||
if do_start; then
|
||||
hist_event recover ok "$(read_pid)" "autostart revival"
|
||||
recovered=$((recovered + 1))
|
||||
else
|
||||
hist_event recover fail "" "autostart revival 失败(见 start fail 事件与 svc.log)"
|
||||
failed=$((failed + 1)); rc=1
|
||||
fi
|
||||
svc_unlock
|
||||
done
|
||||
if [ "$recovered" -gt 0 ] || [ "$failed" -gt 0 ]; then
|
||||
c_event "recover 汇总:拉回 $recovered / 失败 $failed / 无需动作 $skipped"
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ---- watchdog:systemd 用户级 timer,每分钟执行 recover ----
|
||||
WD_UNIT_BASE="agent-system-svc-recover"
|
||||
WD_UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
# wd_service_text ROOT / wd_timer_text:单元文件内容(纯函数,治理测试直接断言)。
|
||||
wd_service_text() {
|
||||
printf '%s
|
||||
' \
|
||||
"# 由 scripts/svc.sh watchdog install 生成(ROOT=$(printf '%s' "$1" | sed 's/[[:space:]]/\\ /g'));修改请重装或手工维护" \
|
||||
"[Unit]" \
|
||||
"Description=agent-system svc auto-recovery (restart crashed/killed services)" \
|
||||
"" \
|
||||
"[Service]" \
|
||||
"Type=oneshot" \
|
||||
"# KillMode=process:oneshot 结束时只回收脚本自身——默认 control-group 会把" \
|
||||
"# recover 刚拉起的服务一并杀掉(2026-10-11 实测:recover ok 后进程立即消失)。" \
|
||||
"KillMode=process" \
|
||||
"ExecStart=$1/scripts/svc.sh recover --quiet" \
|
||||
"Nice=5"
|
||||
}
|
||||
wd_timer_text() {
|
||||
printf '%s\n' \
|
||||
"[Unit]" \
|
||||
"Description=Run agent-system svc recovery every minute" \
|
||||
"" \
|
||||
"[Timer]" \
|
||||
"OnBootSec=1min" \
|
||||
"OnUnitActiveSec=60s" \
|
||||
"AccuracySec=10s" \
|
||||
"Unit=${WD_UNIT_BASE}.service" \
|
||||
"" \
|
||||
"[Install]" \
|
||||
"WantedBy=timers.target"
|
||||
}
|
||||
|
||||
# wd_systemd_ok:systemd 用户会话是否可用(不可用环境给出 cron 降级指引)。
|
||||
wd_systemd_ok() {
|
||||
command -v systemctl >/dev/null 2>&1 || return 1
|
||||
systemctl --user show-environment >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# cmd_watchdog install|remove|status [--print]:看护 timer 的安装/移除/状态。
|
||||
cmd_watchdog() {
|
||||
local sub="${1:-status}"; [ $# -gt 0 ] && shift || true
|
||||
case "$sub" in
|
||||
install)
|
||||
local print=0
|
||||
case "${1:-}" in --print) print=1 ;; "") ;; *) c_err "watchdog install 未知参数:$1(可用:--print)"; return 2 ;; esac
|
||||
local svc="$WD_UNIT_DIR/${WD_UNIT_BASE}.service" tmr="$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
|
||||
if [ "$print" = 1 ]; then
|
||||
c_info "watchdog install --print:只打印计划,不写文件、不碰 systemd"
|
||||
printf '── %s ──\n%s\n' "$svc" "$(wd_service_text "$ROOT")"
|
||||
printf '── %s ──\n%s\n' "$tmr" "$(wd_timer_text)"
|
||||
wd_systemd_ok || c_warn "当前环境 systemd 用户会话不可用——正式 install 需要它;或退化用 cron(help recovery)"
|
||||
return 0
|
||||
fi
|
||||
wd_systemd_ok || {
|
||||
c_err "systemd 用户会话不可用(systemctl --user 失败)——无法安装 timer"
|
||||
c_err " 降级方案(cron,每分钟):"
|
||||
c_err " (crontab -l 2>/dev/null; echo '* * * * * $ROOT/scripts/svc.sh recover --quiet >/dev/null 2>&1') | crontab -"
|
||||
return 1
|
||||
}
|
||||
mkdir -p "$WD_UNIT_DIR"
|
||||
{ wd_service_text "$ROOT"; printf '\n'; } > "$svc"
|
||||
{ wd_timer_text; printf '\n'; } > "$tmr"
|
||||
systemctl --user daemon-reload || { c_err "daemon-reload 失败"; return 1; }
|
||||
systemctl --user enable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || {
|
||||
c_err "timer enable 失败(systemctl --user status ${WD_UNIT_BASE}.timer 查原因)"; return 1; }
|
||||
c_ok "watchdog timer 已安装并启用:${WD_UNIT_BASE}.timer(每分钟 recover;remove 移除)"
|
||||
[ "$(loginctl show-user "$(id -un)" -p Linger 2>/dev/null)" = "Linger=yes" ] \
|
||||
|| c_warn "用户未开 linger(sudo loginctl enable-linger $(id -un))——开机后 timer 不自启,登出不受影响"
|
||||
c_info "立即自检:systemctl --user list-timers ${WD_UNIT_BASE}.timer;人工触发:systemctl --user start ${WD_UNIT_BASE}.service"
|
||||
return 0
|
||||
;;
|
||||
remove|uninstall)
|
||||
wd_systemd_ok || { c_info "systemd 用户会话不可用——只清理单元文件"; }
|
||||
if wd_systemd_ok; then
|
||||
systemctl --user disable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || true
|
||||
systemctl --user reset-failed "${WD_UNIT_BASE}.service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" "$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
|
||||
if wd_systemd_ok; then systemctl --user daemon-reload || true; fi
|
||||
c_ok "watchdog 已移除(单元文件与 timer;服务登记不受影响)"
|
||||
return 0
|
||||
;;
|
||||
status)
|
||||
local n auto_list=""
|
||||
if wd_systemd_ok; then
|
||||
systemctl --user list-timers --no-pager "${WD_UNIT_BASE}.timer" 2>/dev/null | sed 's/^/ /' >&2 || true
|
||||
local exec_line
|
||||
exec_line="$(sed -n 's/^ExecStart=//p' "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" 2>/dev/null || true)"
|
||||
if [ -n "$exec_line" ] && [ "$exec_line" != "$ROOT/scripts/svc.sh recover --quiet" ]; then
|
||||
c_warn "已安装 timer 指向其他检出:$exec_line(当前 ROOT=$ROOT;重装:watchdog install)"
|
||||
fi
|
||||
else
|
||||
c_warn "systemd 用户会话不可用——timer 未安装(watchdog install 或用 cron,见 help recovery)"
|
||||
fi
|
||||
while IFS= read -r n; do
|
||||
[ "$(env_kv_of "$n" autostart)" = "1" ] && auto_list="${auto_list:+$auto_list }$n"
|
||||
done < <(registered_names)
|
||||
c_info "autostart=1 的服务:${auto_list:-无(start 成功后自动登记)}"
|
||||
return 0
|
||||
;;
|
||||
-h|--help|help)
|
||||
cat >&2 <<'EOF'
|
||||
用法:scripts/svc.sh watchdog install|remove|status [install --print]
|
||||
install 写入并启用 systemd 用户 timer(每分钟 svc.sh recover --quiet)
|
||||
install --print 只打印将写入的单元文件与计划(不落盘)
|
||||
remove 停用并删除 timer(服务登记与运行状态不受影响)
|
||||
status timer 状态 + autostart=1 的服务清单
|
||||
EOF
|
||||
return 0 ;;
|
||||
*) c_err "未知子命令:$sub(可用:install|remove|status)"; return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- main ----
|
||||
|
||||
main() {
|
||||
@@ -3407,6 +3878,8 @@ main() {
|
||||
commits) shift; cmd_commits "$@"; exit 0 ;;
|
||||
history) shift; cmd_history "$@"; exit 0 ;;
|
||||
update) shift; cmd_update "$@" || exit $?; exit 0 ;;
|
||||
recover) shift; cmd_recover "$@" || exit $?; exit 0 ;;
|
||||
watchdog) shift; cmd_watchdog "$@" || exit $?; exit 0 ;;
|
||||
esac
|
||||
ENV_NAME="$1"; shift
|
||||
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
|
||||
@@ -3432,6 +3905,11 @@ main() {
|
||||
exit 2
|
||||
fi
|
||||
env_config "$ENV_NAME"
|
||||
# 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含
|
||||
# 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。
|
||||
case "$action" in
|
||||
deploy|stop|start|restart) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
|
||||
esac
|
||||
case "$action" in
|
||||
deploy)
|
||||
case "$SVC_KIND" in
|
||||
|
||||
@@ -669,5 +669,78 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.assertNotIn("apikey", self.cli_record()) # 认证关闭不铸 key
|
||||
|
||||
|
||||
# ---- 13. rdm 部署源(下游组合仓 Agent-System-RDM):物化/产物/工作台/播种跳过 ----
|
||||
|
||||
def make_rdm_repo(self):
|
||||
"""构造下游组合仓夹具:Makefile build 产出桩 agent-system-rdm + web/ 工作台。"""
|
||||
remote = self.root / "rdmremote.git"
|
||||
work = self.root / "rdmwork"
|
||||
run = lambda cwd, *args: subprocess.run(
|
||||
["git", *args], cwd=cwd, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
|
||||
check=True)
|
||||
run(self.root, "init", "--bare", "--initial-branch=main", str(remote))
|
||||
run(self.root, "init", "--initial-branch=main", str(work))
|
||||
(work / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system-rdm\n" % self.stub)
|
||||
(work / "web").mkdir()
|
||||
(work / "web" / "index.html").write_text("<html>rdm workbench</html>\n")
|
||||
run(work, "add", ".")
|
||||
run(work, "commit", "-m", "rdm v1")
|
||||
run(work, "remote", "add", "origin", str(remote))
|
||||
run(work, "push", "-u", "origin", "main")
|
||||
return remote, work
|
||||
|
||||
def rdm_env(self, remote):
|
||||
env = dict(self.env)
|
||||
env.update({
|
||||
"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"),
|
||||
"SVC_RDM_ORIGIN": str(remote),
|
||||
"SVC_MODEL_BASE": "https://stub.example/v1",
|
||||
"SVC_MODEL_KEY": "sk-stub-model",
|
||||
"SVC_MODEL_NAME": "stub-model",
|
||||
})
|
||||
return env
|
||||
|
||||
def test_rdm_source_deploy_end_to_end(self):
|
||||
remote, work = self.make_rdm_repo()
|
||||
env = self.rdm_env(remote)
|
||||
result = self.svc("rdm1", "deploy", "--from", "rdm", "--port", "18841", env=env)
|
||||
d = self.runtime("rdm1")
|
||||
# 组合二进制复制改名落位、插件目录空集、工作台静态资产落 htdocs/。
|
||||
self.assertTrue((d / "bin" / "agent-system").exists())
|
||||
self.assertTrue((d / "bin" / "agent-system").stat().st_mode & 0o111)
|
||||
self.assertEqual(self.kv("rdm1", "kind"), "agent")
|
||||
self.assertTrue(self.kv("rdm1", "source").startswith("rdm@"))
|
||||
self.assertEqual(list((d / "plugins").iterdir()), [])
|
||||
self.assertIn("rdm workbench", (d / "htdocs" / "index.html").read_text())
|
||||
self.assertIn("args:", self.serve_record()) # 健康通过即登记启动参数
|
||||
# rdm 形态跳过设置播种(无 user/apikey 子命令):给了模型键也不产生 PUT/CLI。
|
||||
self.assertFalse((self.trace / "put.txt").exists())
|
||||
self.assertNotIn("user list", self.cli_record())
|
||||
|
||||
def test_rdm_source_commits_and_update_track(self):
|
||||
remote, work = self.make_rdm_repo()
|
||||
env = self.rdm_env(remote)
|
||||
# commits rdm 列出来自独立 RDM 源仓的可部署提交。
|
||||
result = self.svc("commits", "rdm", env=env)
|
||||
self.assertIn("RDM 仓提交", result.stdout)
|
||||
self.assertIn("rdm v1", result.stdout)
|
||||
# 部署 v1 → 远端推进 v2 → update 检测可更新并真更新(source 保持 rdm@ 轨道)。
|
||||
self.svc("rdm1", "deploy", "--from", "rdm", "--port", "18842", env=env)
|
||||
old = self.kv("rdm1", "source")
|
||||
(work / "marker").write_text("v2\n")
|
||||
subprocess.run(["git", "add", "."], cwd=work, env=self.env, timeout=60, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "rdm v2"], cwd=work, env=self.env,
|
||||
timeout=60, check=True)
|
||||
subprocess.run(["git", "push"], cwd=work, env=self.env, timeout=60, check=True)
|
||||
self.svc("update", "rdm1", "--check", env=env)
|
||||
self.svc("update", "rdm1", env=env)
|
||||
new = self.kv("rdm1", "source")
|
||||
self.assertTrue(new.startswith("rdm@"))
|
||||
self.assertNotEqual(new, old)
|
||||
self.assertIn("args:", self.serve_record()) # 更新后健康在跑
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""svc.sh 自恢复回归:autostart 期望态 + recover + watchdog(2026-10-11)。
|
||||
|
||||
验证崩溃(SIGKILL,模拟 OOM 被杀)后 recover 按持久化登记拉回服务、显式 stop
|
||||
不被复活、并发生命周期锁防抢跑、watchdog 单元生成正确。全部使用临时目录 +
|
||||
桩二进制(bash + python http.server 模拟 agent-system;HTTP 子进程随父进程
|
||||
退出——真实产物是单进程二进制,被杀即端口释放,桩须对齐该语义)。
|
||||
"""
|
||||
import fcntl
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
|
||||
|
||||
# 桩 agent-system:version / config explain(环境层 DSN 感知)/ serve(记录参数后
|
||||
# 用 http.server 应答健康探测)。TERM 转发给子进程保证 stop 干净;bash 自身不
|
||||
# exec——pidcheck(二进制路径)须留在 cmdline。HTTP 子进程轮询 ppid:父进程被
|
||||
# SIGKILL 后 0.5s 内自行退出(对齐单进程产物“进程死=端口释放”的语义)。
|
||||
STUB = r"""#!/usr/bin/env bash
|
||||
set -u
|
||||
trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
|
||||
mkdir -p "$trace_dir"
|
||||
case "${1:-}" in
|
||||
version)
|
||||
echo "stub-agent-system 1.0"
|
||||
exit 0 ;;
|
||||
config)
|
||||
if [ "${2:-}" = "explain" ]; then
|
||||
echo "config file: (none)"
|
||||
echo "field value source"
|
||||
if [ -n "${AGENT_SYSTEM_DSN:-}" ]; then
|
||||
printf 'dsn postgresql://%s env\n' "$AGENT_SYSTEM_DSN"
|
||||
else
|
||||
echo "dsn - default"
|
||||
fi
|
||||
fi
|
||||
exit 0 ;;
|
||||
serve)
|
||||
shift
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
echo "Usage: agent-system serve -addr H:P -data DIR -plugins DIR -auth MODE -config FILE"
|
||||
exit 0 ;;
|
||||
esac
|
||||
printf 'args: %s\n' "$*" > "$trace_dir/serve.txt"
|
||||
addr=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
port="${addr##*:}"; host="${addr%:*}"
|
||||
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
|
||||
python3 - "$port" "$host" <<'PYSRV' >/dev/null 2>&1 &
|
||||
import os, sys, threading, time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
port, host = int(sys.argv[1]), sys.argv[2]
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def _ok(self, body=b'{"ok":true}'):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
if self.command != 'HEAD':
|
||||
self.wfile.write(body)
|
||||
def do_GET(self):
|
||||
self._ok()
|
||||
def do_HEAD(self):
|
||||
self._ok(b'')
|
||||
def do_PUT(self):
|
||||
n = int(self.headers.get('Content-Length') or 0)
|
||||
self.rfile.read(n)
|
||||
self._ok()
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
ppid = os.getppid()
|
||||
def watch():
|
||||
while True:
|
||||
try:
|
||||
if os.getppid() != ppid:
|
||||
os._exit(0)
|
||||
except Exception:
|
||||
os._exit(0)
|
||||
time.sleep(0.3)
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
try:
|
||||
HTTPServer((host, port), H).serve_forever()
|
||||
except Exception:
|
||||
os._exit(1)
|
||||
PYSRV
|
||||
child=$!
|
||||
wait "$child"
|
||||
;;
|
||||
*)
|
||||
exit 0 ;;
|
||||
esac
|
||||
"""
|
||||
|
||||
|
||||
class SvcAutoRecoverTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.mkdtemp(prefix="svc-autorecover-")
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
self.root = Path(tmp)
|
||||
(self.root / "scripts").mkdir(parents=True)
|
||||
shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh")
|
||||
self.trace = self.root / "trace"
|
||||
self.stub = self.root / "stub-agent-system"
|
||||
self.stub.write_text(STUB)
|
||||
self.stub.chmod(0o755)
|
||||
(self.root / "plugins").mkdir()
|
||||
self.env = os.environ.copy()
|
||||
self.env.update({
|
||||
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_AUTHOR_NAME": "svc recover", "GIT_COMMITTER_NAME": "svc recover",
|
||||
"GIT_AUTHOR_EMAIL": "svc-test@example.invalid",
|
||||
"GIT_COMMITTER_EMAIL": "svc-test@example.invalid",
|
||||
"SVC_STUB_TRACE": str(self.trace),
|
||||
"SVC_SRC_DIR": str(self.root / "srccache"),
|
||||
# 隔离 watchdog 单元目录(--print 不落盘,但断言要在受控目录下做)。
|
||||
"XDG_CONFIG_HOME": str(self.root / "xdg"),
|
||||
})
|
||||
for key in list(self.env):
|
||||
if key.startswith("SVC_") and key not in ("SVC_STUB_TRACE", "SVC_SRC_DIR"):
|
||||
self.env.pop(key)
|
||||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
||||
self.env.pop(key, None)
|
||||
|
||||
def tearDown(self):
|
||||
# 兜底清理:停掉本测试遗留的服务进程,避免端口跨用例泄漏。
|
||||
runtime = self.root / "runtime"
|
||||
if runtime.is_dir():
|
||||
for d in sorted(runtime.iterdir()):
|
||||
if (d / "svc.pid").exists():
|
||||
try:
|
||||
subprocess.run(
|
||||
["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"],
|
||||
cwd=self.root, env=self.env, timeout=30,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # 兜底强杀(见下)
|
||||
# 直接 SIGKILL 兜底:确保桩进程(及其锁 fd/端口)不跨用例泄漏。
|
||||
try:
|
||||
pidf = d / "svc.pid"
|
||||
if pidf.exists():
|
||||
pid = int(pidf.read_text().strip() or 0)
|
||||
if pid:
|
||||
os.kill(pid, 9)
|
||||
except (ValueError, ProcessLookupError, PermissionError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
def svc(self, *args, check=True):
|
||||
proc = subprocess.run(
|
||||
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
|
||||
cwd=self.root, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
|
||||
if check:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
return proc
|
||||
|
||||
def runtime(self, name):
|
||||
return self.root / "runtime" / name
|
||||
|
||||
def kv(self, name, key):
|
||||
envfile = self.runtime(name) / "svc.env"
|
||||
if not envfile.exists():
|
||||
return None
|
||||
for line in envfile.read_text().splitlines():
|
||||
if line.startswith(key + "="):
|
||||
return line[len(key) + 1:]
|
||||
return None
|
||||
|
||||
def seed_agent(self, name, port):
|
||||
d = self.runtime(name)
|
||||
(d / "bin").mkdir(parents=True, exist_ok=True)
|
||||
(d / "data").mkdir(exist_ok=True)
|
||||
(d / "plugins").mkdir(exist_ok=True)
|
||||
shutil.copy2(self.stub, d / "bin" / "agent-system")
|
||||
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [
|
||||
("kind", "agent"), ("env", name), ("pid", ""),
|
||||
("addr", "127.0.0.1:%d" % port), ("tags", ""),
|
||||
("source", "local@deadbee"), ("version", "stub"), ("started", ""),
|
||||
("pidcheck", str(d / "bin" / "agent-system")),
|
||||
("exec", "--addr 127.0.0.1:%d --data %s --plugins %s"
|
||||
% (port, d / "data", d / "plugins")),
|
||||
]))
|
||||
|
||||
def start_ok(self, name):
|
||||
result = self.svc(name, "start")
|
||||
self.assertIn("已启动", result.stdout)
|
||||
return result
|
||||
|
||||
def pid_of(self, name):
|
||||
pidf = self.runtime(name) / "svc.pid"
|
||||
if not pidf.exists():
|
||||
return None
|
||||
text = pidf.read_text().strip()
|
||||
return int(text) if text else None
|
||||
|
||||
def pid_alive(self, pid):
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
# SIGKILL 后的孤儿在 CI 容器里可能成僵尸(PID 1 不收割)——僵尸按死处理,
|
||||
# 与 svc.sh pid_alive_mark 的口径一致(僵尸 cmdline 为空 = 不匹配 = 已死)。
|
||||
try:
|
||||
state = Path("/proc/%d/stat" % pid).read_text().rsplit(")", 1)[1].split()[0]
|
||||
if state == "Z":
|
||||
return False
|
||||
except (OSError, IndexError):
|
||||
pass
|
||||
return True
|
||||
|
||||
def kill9(self, name):
|
||||
"""SIGKILL 服务主进程(模拟 OOM killer),保留 pidfile 与登记。"""
|
||||
pid = self.pid_of(name)
|
||||
self.assertTrue(self.pid_alive(pid), "服务应先在运行")
|
||||
os.kill(pid, 9)
|
||||
for _ in range(100): # 等 bash 主进程消失(HTTP 子进程由 ppid 看护自杀)
|
||||
if not self.pid_alive(pid):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertFalse(self.pid_alive(pid))
|
||||
time.sleep(0.6)
|
||||
return pid
|
||||
|
||||
def history_events(self, name, event):
|
||||
f = self.runtime(name) / "history.jsonl"
|
||||
if not f.exists():
|
||||
return []
|
||||
out = []
|
||||
for line in f.read_text().splitlines():
|
||||
if ('"event":"%s"' % event) in line:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
# ---- 1. 期望态:start 成功置位 / 显式 stop 清除(含进程已死的 stop)----
|
||||
|
||||
def test_start_records_autostart_and_stop_clears(self):
|
||||
self.seed_agent("dev", 18801)
|
||||
self.start_ok("dev")
|
||||
self.assertEqual(self.kv("dev", "autostart"), "1")
|
||||
self.svc("dev", "stop")
|
||||
self.assertIsNone(self.kv("dev", "autostart"))
|
||||
|
||||
def test_stop_on_dead_process_clears_autostart(self):
|
||||
self.seed_agent("dev", 18802)
|
||||
self.start_ok("dev")
|
||||
self.kill9("dev")
|
||||
# 进程已死但 autostart 保留(等待 recover);显式 stop 表示"保持停止"。
|
||||
self.assertEqual(self.kv("dev", "autostart"), "1")
|
||||
self.svc("dev", "stop")
|
||||
self.assertIsNone(self.kv("dev", "autostart"))
|
||||
|
||||
# ---- 2. recover:崩溃拉回 / 运行中不动 / 显式停止不复活 / 未启动不碰 ----
|
||||
|
||||
def test_recover_restarts_sigkilled_service(self):
|
||||
self.seed_agent("dev", 18803)
|
||||
self.start_ok("dev")
|
||||
old_pid = self.kill9("dev")
|
||||
result = self.svc("recover", "--quiet")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("recover 汇总:拉回 1", result.stdout) # c_info 在 --quiet 下仍输出
|
||||
new_pid = self.pid_of("dev")
|
||||
self.assertTrue(self.pid_alive(new_pid))
|
||||
self.assertNotEqual(new_pid, old_pid)
|
||||
self.assertEqual(self.kv("dev", "autostart"), "1")
|
||||
ok_events = self.history_events("dev", "recover")
|
||||
self.assertTrue(any('"result":"ok"' in l for l in ok_events), ok_events)
|
||||
# 恢复用的仍是持久化 exec=(svc.env 登记未漂移)。
|
||||
self.assertIn("--addr 127.0.0.1:18803", self.kv("dev", "exec"))
|
||||
|
||||
def test_recover_noop_while_running(self):
|
||||
self.seed_agent("dev", 18804)
|
||||
self.start_ok("dev")
|
||||
pid = self.pid_of("dev")
|
||||
result = self.svc("recover")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.pid_of("dev"), pid) # 未重启
|
||||
self.assertEqual(self.history_events("dev", "recover"), [])
|
||||
|
||||
def test_recover_respects_explicit_stop(self):
|
||||
self.seed_agent("dev", 18805)
|
||||
self.start_ok("dev")
|
||||
self.svc("dev", "stop")
|
||||
result = self.svc("recover")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse(self.pid_alive(self.pid_of("dev")))
|
||||
self.assertEqual(self.history_events("dev", "recover"), [])
|
||||
|
||||
def test_recover_ignores_never_started_service(self):
|
||||
self.seed_agent("dev", 18806) # 从未 start:无 autostart 键
|
||||
result = self.svc("recover")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse(self.pid_alive(self.pid_of("dev")))
|
||||
self.assertEqual(self.history_events("dev", "recover"), [])
|
||||
|
||||
# ---- 3. 并发防护:生命周期锁被持有时 recover 跳过 ----
|
||||
|
||||
def test_recover_skips_when_lock_held(self):
|
||||
self.seed_agent("dev", 18807)
|
||||
self.start_ok("dev")
|
||||
self.kill9("dev")
|
||||
lock = self.runtime("dev") / ".start.lock"
|
||||
with open(lock, "w") as fh:
|
||||
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
result = self.svc("recover")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("锁被占用", result.stdout)
|
||||
self.assertFalse(self.pid_alive(self.pid_of("dev")))
|
||||
self.assertEqual(self.history_events("dev", "recover"), [])
|
||||
fcntl.flock(fh, fcntl.LOCK_UN)
|
||||
# 锁释放后同一命令即可拉回。
|
||||
result = self.svc("recover")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(self.pid_alive(self.pid_of("dev")))
|
||||
|
||||
# ---- 4. 恢复失败路径:端口被外部进程占用 → fail 事件 + 非零退出 ----
|
||||
|
||||
def test_recover_fails_when_port_stolen(self):
|
||||
self.seed_agent("dev", 18808)
|
||||
self.start_ok("dev")
|
||||
self.kill9("dev")
|
||||
thief = subprocess.Popen(
|
||||
["python3", "-m", "http.server", "18808", "--bind", "127.0.0.1"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.addCleanup(thief.terminate)
|
||||
# 等 thief 真正完成 bind 再 recover——慢环境里 Popen 返回时端口尚未监听,
|
||||
# recover 会抢先起服务,把“端口被抢”测成竞态(CI 首轮实测)。
|
||||
deadline = time.monotonic() + 5.0
|
||||
bound = False
|
||||
while time.monotonic() < deadline:
|
||||
s = socket.socket()
|
||||
s.settimeout(0.2)
|
||||
try:
|
||||
s.connect(("127.0.0.1", 18808))
|
||||
bound = True
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
s.close()
|
||||
if bound:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(bound, "thief 未能在 5s 内监听 18808")
|
||||
try:
|
||||
result = self.svc("recover", check=False)
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
fail_events = self.history_events("dev", "recover")
|
||||
self.assertTrue(any('"result":"fail"' in l for l in fail_events), fail_events)
|
||||
self.assertFalse(self.pid_alive(self.pid_of("dev"))) # 未强抢端口
|
||||
finally:
|
||||
thief.terminate()
|
||||
thief.wait(timeout=10)
|
||||
|
||||
# ---- 5. watchdog:单元文件生成(--print 走 main 分发全链路)----
|
||||
|
||||
def test_watchdog_print_plan(self):
|
||||
result = self.svc("watchdog", "install", "--print")
|
||||
self.assertIn(
|
||||
"ExecStart=%s/scripts/svc.sh recover --quiet" % self.root, result.stdout)
|
||||
self.assertIn("OnUnitActiveSec=60s", result.stdout)
|
||||
self.assertIn("OnBootSec=1min", result.stdout)
|
||||
self.assertIn("WantedBy=timers.target", result.stdout)
|
||||
# oneshot 结束不得连带杀掉刚拉起的服务(cgroup 陷阱)。
|
||||
self.assertIn("KillMode=process", result.stdout)
|
||||
# 只打印,不落盘(受控 XDG_CONFIG_HOME 下无单元文件)。
|
||||
unit_dir = self.root / "xdg" / "systemd" / "user"
|
||||
self.assertFalse((unit_dir / "agent-system-svc-recover.timer").exists())
|
||||
self.assertFalse((unit_dir / "agent-system-svc-recover.service").exists())
|
||||
|
||||
def test_recover_reserved_name_rejected(self):
|
||||
self.seed_agent("recover", 18809) # 保留字不可作服务名
|
||||
result = self.svc("recover", "recover", check=False)
|
||||
self.assertEqual(result.returncode, 2, result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user