Merge authoritative main into the task branch
ci / test-pg (pull_request) Failing after 2m0s
ci / test (pull_request) Successful in 3m58s

Why: resolve archive card conflict after u7 card already landed on main.
Evidence: took main .task/archive/feature-u7-capacity-wrap-measure.md; kept audit-combo archive.
Impact: #449 only archives remaining audit-combo debt if needed.
Refs: Dev #449
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 12:25:33 +00:00
co-authored by Cursor
29 changed files with 4315 additions and 77 deletions
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
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
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
File diff suppressed because one or more lines are too long
+8
View File
@@ -12,6 +12,12 @@
目录中的既有文档不因被收录而自动成为已批准规划。记录、任务关联及收尾建议按
[交付规范的长期方向与任务衔接](policy/delivery.md#长期方向与任务衔接) 执行。
- [快慢通道展示与动态工作摘要方案 r1.1](modules/documents/docs/proposals/2026-10-12-fast-slow-channel-display.md):
对话即时答复、后台动态发现与来源、四层阅读、真实状态、并发/恢复及 U1–U4;
2026-10-12 方案设计已批准(阶段 1–4),首批仅 U1;
[U1 实施计划](modules/documents/docs/proposals/2026-10-12-fast-slow-channel-display-u1-plan.md) 待阶段 5 批准后启动业务代码;
业务实施、模型实验、运行验收与部署 NOT_RUN。
新增方向:[Generative UI 调研与插件方案 r2](modules/documents/docs/proposals/2026-10-10-generative-ui-plugin.md)。
2026-10-11 用户裁定首试点(对话内分析页)、可信编译插件交付与试验参数;U1 受控试验当日完成
(结构分化 6/6、生成走工具参数通道、首期自研 renderer,详见方案「U1 结果与 r2 修订」);
@@ -41,6 +47,8 @@ U2 起实施与部署分别评审。
二进制/源码统一部署、环境准备、无初始密码开户与账户恢复;一次性 Key 仅兑换设密受限会话;
2026-10-12 已批准;U1/U4 实施启动中;规格见 `docs/svc-deploy/`。
上述账户/恢复方案已有 U1/U4 与 recover/watchdog 基础源码。2026-10-12 的 dev 8788 续查形成账户 r2、恢复 r3 **候选草案**:帮助与能力发现、账户事务/审计/登录接线、SQLite 活动服务隔离及备份恢复增补。新增内容待人工评审;Documents 独立分支 `feature/svc-account-durability-plan` 仅本地保存,不推进本仓 gitlink;本入口原链接仍指固定版。候选交接与核查见[续审记录](docs/svc-deploy/2026-10-12-account-durability-plan-review.md)。
## 验证
`make test` 先核对子模块工作树与固定指针,再运行治理脚本、公开边界、跨仓指针、库及各模块
+15 -14
View File
@@ -24,29 +24,29 @@
| # | 条件 | 证据(命令) | 状态 |
|---|---|---|---|
| B1 | 冒烟压测通过(入口可复核、不变量零违反) | `tests/load/collaboration/run_load.sh --profile smoke` | ✅ 已跑通(SELF,2026-10-12:读 P95<1ms、SSE P95≈18ms、对账 0 违反) |
| B2 | 阶梯负载 10→30→100 用户,读 P95≤300ms、P99≤1s、非预期失败≤0.1% | 同上 `--profile ladder`(k6 需安装:`brew install k6`) | ⏳ 待窗口 |
| B3 | 60 分钟稳定负载 + 2 小时混合长稳 | `--profile sustained60m` / `--profile sustained2h` | ⏳ 待窗口(约 3h 机时) |
| B4 | SSE 持久事件可见延迟 P95≤1s | 同上套件自动执行(sse_probe,断言即退出码) | ✅ 冒烟档已证机制;正式窗口随 B3 |
| B5 | 数据量档(100 万消息/10 万知识分块/多项目交叉授权) | `run_load.sh --seed tiny\|contract`(`tests/load/collaboration/seed/`;tiny 冒烟 ✅;contract 满量待窗口) | ⏳ 入口就绪 |
| B2 | 阶梯负载 10→30→100 用户,读 P95≤300ms、P99≤1s、非预期失败≤0.1% | `--seed contract --profile ladder --instances 2`;results `20261012-064730`:读 P95≈15.5ms、P99≈44.3ms、fail=0 | ✅ SELF(2026-10-12);非 GATE/REPLAY |
| B3 | 60 分钟稳定负载 + 2 小时混合长稳 | `sustained60m` results `20261012-070510`(读 P95≈3.9ms);`sustained2h` results `20261012-081025`(读 P95≈6.2ms);均 fail=0、dup_seq=0 | ✅ SELF(2026-10-12);非 GATE/REPLAY |
| B4 | SSE 持久事件可见延迟 P95≤1s | 同上套件 sse_probe:ladder A/B ≈17.5/33.0ms;60m ≈5.7/4.4ms;2h ≈3.9/4.2ms | ✅ SELF(随 B2/B3) |
| B5 | 数据量档(100 万消息/10 万知识分块/多项目交叉授权) | 三段均 `--seed contract`(manifest:1e6 messages / 1e5 knowledge) | ✅ SELF(2026-10-12) |
| B6 | 数据库基线(pgbench 只测 DB 能力,不替代业务验收) | `pgbench` 对隔离实例(只读参考) | ❌ 未执行 |
| B7 | 容量数字绑定组件 SHA/配置/数据集归档 | results/<ts>/ 目录 + 报告引用 SHA | ⏳ 随 B2/B3 产出 |
| B7 | 容量数字绑定组件 SHA/配置/数据集归档 | [容量报告](../modules/documents/docs/records/2026-10-12-u7-capacity-window-report.md) + worktree `results/<ts>/` + 任务卡 evidence | ✅ SELF 报告已写 |
## C. 双进程与双主机放行(分布式出口)
| # | 条件 | 证据 | 状态 |
|---|---|---|---|
| C1 | 双进程共享 PG:正确性矩阵全过(A6) | `make test-multi-instance` | ✅ |
| C2 | 双进程**负载**下互相不拖累(热点会话/命令时延) | `run_load.sh --instances 2`(VU 粘滞 + 跨实例读 + 热点 409;smoke 入口 ✅;正式阶梯/长稳待窗口) | ⏳ 入口就绪 |
| C2 | 双进程**负载**下互相不拖累(热点会话/命令时延) | 上表 B2/B3 均 `--instances 2`(VU 粘滞 + 跨实例读 + 热点 409 入 expected_contention;非热点读阈值全过) | ✅ SELF(2026-10-12);非 GATE/REPLAY |
| C3 | 双主机(跨机网络)验证:同套验收在两台主机执行 | A6 的 AGENT_BIN/DSN 指向远端;需用户指定第二主机 | ❌ 待资源 |
| C4 | 故障演练:PG 断连/Redis 断连恢复(RTO≤15min) | backend ratelimit/recovery 测试 + 运维手册演练记录 | ⏳ 代码级 ✅;运维演练待窗口 |
| C5 | 备份恢复演练(RPO≤5min/RTO≤60min,pg_dump+物理备份) | U4 迁移演练已过;定期备份恢复演练属运维,待排期 | ⏳ |
## 放行结论(当前)
- **单实例团队档**:A1–A7 全绿 + B1 冒烟通过。正式容量结论待 B2/B3 窗口;
在此之前按「已验收功能、未承诺容量」运营。
- **双进程档**:正确性已证(A5/A6/C1),负载与运维演练(C2/C4)未过,
**不放行第二实例承接写请求**。
- **单实例团队档**:A1–A7 全绿 + B1–B5/B7 本机 SELF 容量窗口全过(见容量报告)。
功能验收成立;**容量数字仅为 SELF**,换机/真实模型须重测。生产承诺仍待独立 REPLAY 或 GATE。
- **双进程档**:正确性已证(A5/A6/C1),本机双实例负载 SELF 已过(C2)。
**仍不放行第二实例承接写请求**——缺独立 REPLAY/GATE,且 C3/C4 未过。
- **双主机档**:未开始(C3),不放行。
## 已知边界与诚实声明
@@ -54,7 +54,8 @@
- U7 期间经双进程验收发现并修复三个跨实例缺陷(详见
`tests/integration/multi-instance/README.md`):HITL resolve 落卡短路命令、
cancel 命令空会话 ID、CancelRun 假成功。修复均已入 CI 回归。
- k6 首次冒烟使用临时二进制(v2.3.0)验证后删除;正式窗口前按
`brew install k6` 装固定位。
- B5 seed(tiny/contract)与 C2 `--instances 2` 入口已就绪;满量 contract 与双实例
正式阶梯/长稳仍待用户指定窗口,入口不预填容量结论。
- 正式容量窗口使用 Homebrew 固定位 k6 v2.3.0。
- 容量详情与 SHA 绑定见 Documents
`docs/records/2026-10-12-u7-capacity-window-report.md`;原始
`results/<ts>/` 在测量 worktree(gitignore),不入库。
- B6 pgbench、C3 双主机、C4/C5 运维演练仍待资源/排期。
@@ -0,0 +1,44 @@
# svc 账户与持久化方案续审记录
日期:2026-10-12(Asia/Shanghai)。范围:只读续查、两份既有主方案候选修订与文档核查;业务实施、账户变更、迁移、故障注入、部署和远端交付未执行。
## 对象与权威
- Dev 启动基线:2723e452440fc11022c0f6d916cd34cc4cd17335;最终权威同步基线 d6d46fde6d903d0d8e37440d830fb5183d6ef5e9。工具的启动/同步提交仅是任务元数据;纳入的 svc 内存态修复来自主干。
- Documents 候选:独立 origin/main=1048e7c6afc2bb4efc541e68575c3fb2669ee332,分支 feature/svc-account-durability-plan。Dev 初始固定版8d0810b已由其他主干任务推进至1048e7c;本轮同步了该权威组合,没有推进本轮 r2/r3 草案 gitlink。
- 唯一方案正文继续为 Documents 的 `docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` r2 和 `docs/proposals/2026-10-12-service-restart-data-recovery.md` r3。本记录不复制需求或实施合同,不成为第三份规划。
- 两份目标文件在 Documents 两个基线间相同,本轮保留最新主干上的其他文档;原批准不扩展到新增修订。
- Documents 候选工作区:Agent-System-Documents-wt-feature-svc-account-durability-plan;Dev 证据工作区:Agent-System-Dev-wt-feature-svc-account-durability-plan。以本地文档提交固定对象并满足干净工作区同步门禁,不推送/建 PR/推进 gitlink;任务工具元数据提交单独存在,本地保存不证明新增修订获批。
## 已捕获证据与限制
原始脱敏输出在本任务卡 `.task/feature-svc-account-durability-plan.md` 的 SELF 收据中,包括首次 precheck、task-sync 和 17:37 的只读运行观察。第一份观察收据对非秘密元数据也作了保守遮蔽;第二份使用明确的非秘密字段名重新实测,不修改或补造第一份收据。
dev 实际打开原 SQLite 文件;账户身份未变,当前最后设密时间为16:03:20。旧快照的摘要相等性比较只证明字段重写,不证明明文变化。当前没有账户审计,历史操作者仍未知。候选实例本轮已停止,历史共享数据不写成当前双服务事实。后续审计只能记录新事件,不能回填猜测的旧事件。
上述候选停止为17:37时点;18:14续查已实际打开独立 DB,外部现场变更和最新 SELF 观察均保留时间边界。当前分根不等于 Backend 锁已实现,后续先防再次误用;本轮没有执行服务启停或数据迁移。
本轮没有登录、账户 CLI 查询、migrate 或故障试验。文档差异、链接、稳定编号和审阅属于 SELF 文档核查,不是业务 PASS,也不替代 GATE/REPLAY/人工批准。
## 文档审阅
七个视角均已返回,采用 ce-doc-review 非交互文档审阅;结果为 SELF 文档自审,不代替独立正式验收或人类批准。跨模型外部调用未执行,本轮没有相应资源/外部调用授权;未创建 provider 会话或读取其凭据。
| 视角 | 结果与处置 |
|---|---|
| 一致性 | 返回一项旧修订号意见,但所引句子不存在于磁盘全文;核查后拒绝,不按未经证实引文改文档 |
| 可行性 | 无保留意见;近期文件、事务与服务/CLI边界可执行性已核查,未跑实验 |
| 产品 | 采纳:首批增加另行授权的 dev 原账户正常登录及更新验收,不能只以副本测试代替原故障闭环 |
| 交互 | 采纳三项:两类 Key 入口和错类型引导、Key 消费后受限会话失效的重发返回路径、operation 级 hold 对账界面接线 |
| 安全 | 采纳:灾难恢复不得复活恢复点后已撤销的凭据/权限;普通重启不应用此规则 |
| 范围 | 无保留意见;主方案与配套规格继续承载合同,新增内容仍待人工评审 |
| 对抗 | 采纳:首次管理员以实例级串行点单胜,并用不同 owner 的真实双进程场景验证;另独立核对了凭据复活风险 |
共采纳六项实质改进。根 Agent 对照实际 auth/login-key 认证、首次引导、当前前端入口与磁盘原文核对后应用;未把 reviewer 的通过叙述当作业务证据。配套 U1 的禁止隐式 migrate、U4 的实例级引导锁同步为候选合同,避免执行者沿用矛盾的历史规格;主方案也明确 CLI 安全 DSN 输入仍需实现。
文档检查方法覆盖差异、相对链接、稳定 R/KTD/U 编号、现有源码/测试路径及原收据完整性。结果以任务卡工具收据为准;首轮发现一处尾随空格已修正。最终同步曾因文档未保存被拒(完整失败收据保留),以本地文档提交保存后重试;不移动或隐藏现场绕过门禁。业务/浏览器/故障/恢复/CI验证均 NOT_RUN;正式 Documents/Dev 审核、PR、组合与运行验收未执行。
## 后续交接
优先审核新增的恢复 U8 与账户 U7/U5,确认服务/CLI/迁移边界和隔离测试范围;然后才启动对应实现任务。原恢复 A/B/C 仍保留,首批账户/隔离工作不等于全系统恢复完成。近期 U2/U3 通用安装扩展不阻塞这批问题。
本地文档提交仅保存本轮审阅对象。方案推送、Documents PR、Dev gitlink 推进、正式组合验证、部署/迁移与故障演练保持未执行;本地草案审核不自动授权这些动作。
@@ -1,6 +1,7 @@
# U1 实例—账户绑定补齐(规格 v0.1)
# U1 实例—账户绑定补齐(规格 v0.2 候选)
状态:已启动实施(分支 `feature/svc-deploy-u1-account-binding`)。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U1。
上述为 v0.1 历史状态,基础已见当前固定源码。本轮 v0.2 只细化账户方案 r2 的 R15/U7,新增合同未批准或实现;Documents 候选尚未推进 Dev gitlink。
前置:恢复方案 recovery U1 已合入 main(Dev #415)——`docs/recovery/u1-identity-manifest.md`、`scripts/svc_manifest.py`、启动身份校验与 `svc init`。
本增量**不重做** manifest 层;只补齐账户操作与安装入口对既有身份合同的消费缺口。
@@ -18,7 +19,7 @@
|---|---|---|
| B1 | 账户 CLI 只接受「实例名 → manifest 解析出的 data_root / db / secrets 引用」 | 新增消费方 |
| B2 | 禁止回落默认 `./data` 或未声明 DSN | 新增硬拒绝 |
| B3 | OpenExisting:目标不存在则失败;可选禁止自动 migrate | Backend 新增打开模式 |
| B3 | OpenExisting:目标不存在则失败;账户模式只校验兼容 schema,禁止自动 migrate | r2 候选收紧,当前代码仍 migrate |
| B4 | 无 `flock` / 锁获取失败 → 拒绝冲突操作(deploy/start/stop/user/*) | 收紧现状「直通」 |
| B5 | `install` / 兼容 `--from` 写入同一 manifest 身份字段,不另起配置根 | 入口骨架 |
@@ -29,7 +30,7 @@
| `scripts/svc.sh` | `user` 子命令骨架:解析 `ENV_NAME` → 读 manifest/`svc_manifest.py` → 组装 `--data`/`AGENT_SYSTEM_DSN`/`--open-existing` 调 `$ENV_BIN`;锁检查收紧;`install` 可先做 dry-run 计划打印(完整 binary/source 属 U2) |
| `scripts/svc_manifest.py` | 如需:导出「账户操作所需最小字段」查询(data_root、db mode/ref、auth);不改 schema=1 语义 |
| `modules/backend/cmd/agent-system/main.go` | `user` 子命令增加 `--open-existing`(或全局 store 打开标志);缺库/缺目录失败 |
| `modules/backend/internal/store/store.go` | `OpenExistingSQLite` / PG 等价:不 `MkdirAll` 创建新数据根;可选 `MigrateMode=requireCompatible\|forbid` |
| `modules/backend/internal/store/store.go` | `OpenExistingSQLite` / PG 等价:不创建新根,账户模式只校验兼容;迁移另行执行 |
| `tests/test_svc_identity.py` 或新 `tests/test_svc_user_binding.py` | 错误 data、缺 manifest、无 flock 拒绝;正确绑定命中 |
## 4. OpenExisting 合同
@@ -39,12 +40,12 @@ OpenExisting(path|dsn, opts):
if storage path/parent missing → error (do not create)
if SQLite file missing → error
open DB
if opts.migrate == forbid → read schema version only; mismatch → error
if opts.migrate == requireCompatible → existing migrate(); reject unknown newer schema (keep current migrate gate)
account operations → read schema/identity only; incompatible or missing identity → error
explicit maintenance migration → separate approved entry with service isolation
never create empty business DB as side effect of account recovery
```
CLI 默认:账户运维命令使用 `OpenExisting` + `requireCompatible`。
CLI 默认:账户运维命令使用 `OpenExisting` + 兼容检查,不 migrate。当前 requireCompatible 自动迁移属于待整改基础,不是本候选允许的行为。
`svc init` / 首次服务启动仍走现有 Open(可创建)路径,与 recovery U1 一致。
## 5. 锁合同
+3 -1
View File
@@ -1,6 +1,7 @@
# U4 一次性登录凭据与认证限制(规格 v0.1)
状态:已启动实施(Backend/Dev 分支 `feature/svc-deploy-u4-login-key`)。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U4 / §8。
上述为历史规格状态,基础已见当前固定源码。本轮 r2 账户事务/帮助/审计增补待评审;运维打开以配套 U1 v0.2 候选和主方案 R15/U7 为准,禁止隐式 migrate。Dev gitlink 未推进本轮 Documents 候选。
本增量在 **Backend**(及必要迁移/CLI)落地;Frontend 兑换与设密页属 **U5**,但 U4 须提供稳定 HTTP 合同供 U5 接线。
基线:任务卡 R0;Backend 实现提交见 gitlink。真实 PG 缺环境标 NOT_RUN。
@@ -73,7 +74,8 @@ agent-system user list # 含是否已设密、未使用登录 Key 计数(无
agent-system user bootstrap-admin --owner <id> [--open-existing]
```
仅当:库已存在且 **无任何 users 行** 且 **无历史业务标记**(规格:至少 `projects`/`messages` 等核心表为空或实例 init marker 声明 fresh)。并发用事务/`UNIQUE` 保证单胜。有数据却无管理员 → **拒绝自动接管**,要求显式运维流程(本增量可只返回明确错误)。
仅当:库已存在且 **无任何 users 行** 且 **无历史业务标记**。fresh marker 是辅助身份,不替代实际业务检查;历史业务含全部必需模块/插件的权威清单。
实例级串行保证单胜:PG 锁定既有实例身份行、SQLite 用立即事务,取得锁后重新核验;引导与能改变此前置条件的开户共用串行点。owner UNIQUE 只防同名,不能防不同 owner 引导。账户、激活 Key 和审计同事务;两个独立进程以不同 owner 竞争须仅一个成功。有数据却无管理员拒绝自动接管,走显式运维。
失陷恢复:
+14 -8
View File
@@ -15,7 +15,7 @@ PostgreSQL 数据库(postgres)三类服务统一为"服务"模型——按
端口冲突报错不抢;除 `--from local` 外的构建全部在隔离 worktree(物化自独立部署
源仓,与当前工作区状态无关——脏树、任意分支均可部署/更新)。
分主题速查:`scripts/svc.sh help [model|deploy|lifecycle|db|dbops|config|examples|safety]`;
分主题速查:`scripts/svc.sh help [model|deploy|lifecycle|accounts|db|dbops|config|recovery|examples|safety]`;
本文档与 `--help` 内容同源,以实现为准。
---
@@ -336,9 +336,12 @@ Releases 仓跑 `scripts/build-release.sh <v>`。
无声明式插件资产;管理页「插件管理」列表只反映目录插件,组合插件经
`/api/registry` 与命令面可见;
- **web/ 工作台**:RDM 自带零构建静态工作台(直连后端 API),deploy 时复制到
`runtime/<name>/htdocs/`,用伴生 static 服务伺服
(`scripts/svc.sh <name>web deploy --kind static --dir runtime/<name>/htdocs`;
注意该目录随本环境 `rm --purge` 一并删除);
`runtime/<name>/htdocs/`,并**自动**部署伴生 static 服务
(`scripts/svc.sh <name>web deploy --kind static --dir runtime/<name>/htdocs`);
伴生失败或 `GET /` 非 200 时 deploy 记 `ui-missing` 并以非 ok 结束。
注意 `htdocs/` 随本环境 `rm --purge` 一并删除;
- **换源门禁**:同名服务切换源种类(如 `dev@*`→`rdm@*`)须加
`--allow-source-switch`;同种类换 sha(含 `update`)不需要;
- **启动兼容**:组合二进制容忍基座风格的首位 `serve` 子命令并提供 `version`
子命令;svc 的 `version`/`serve -h` 探测带 timeout 护栏;
- **不支持** `--mock` 与 `--config`(组合 CLI 无此旗标);模型设置播种
@@ -359,11 +362,13 @@ Releases 仓跑 `scripts/build-release.sh <v>`。
3. **候选(禁止双写)**:先 `scripts/svc.sh <name> stop`,再
`scripts/svc.sh <cand> deploy --from rdm@<sha> --data runtime/<name>/data --port <空闲> --auth on`;
验登录 / `/api/projects` / `rdmAuditReady` 后停候选。
4. **Cutover**:`scripts/svc.sh <name> deploy --from rdm@<sha>`(同名 redeploy 保留
data/auth)。失败回滚:`scripts/svc.sh <name> deploy --from dev@<旧源>`。
4. **Cutover**:若当前为非 rdm 源,加 `--allow-source-switch`:
`scripts/svc.sh <name> deploy --from rdm@<sha> --allow-source-switch`
(同名 redeploy 保留 data/auth;伴生 `<name>web` 自动就位)。失败回滚:
`scripts/svc.sh <name> deploy --from dev@<旧源> --allow-source-switch`。
5. **基座 FE 验收**:`PLAYWRIGHT_API_TARGET=http://127.0.0.1:<port> npm run preview`
(或 Vite dev 代理);`svc` static **无** `/api` 反代。RDM `web/` 工作台是另一套 UI
(`htdocs` + 伴生 static),与 React SPA 门控并行可用。
(`htdocs` + 自动伴生 static),与 React SPA 门控并行可用。
### 5.5 辅助
@@ -541,7 +546,8 @@ 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 recovery examples safety
scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle accounts db dbops config recovery examples safety
# accounts:实例绑定账户 CLI(create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin)
```
## 7. 命令参考:数据库
+310 -20
View File
@@ -180,14 +180,15 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
scripts/svc.sh update [name] [--check] 刷新过时服务到远端最新(不依赖工作区状态)
scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务
scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover)
scripts/svc.sh <name> user <subcommand> [...] 账户 CLI(绑定实例;见 help accounts)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle db dbops config examples safety)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle accounts db dbops config recovery examples safety)
文档:docs/svc.md
EOF
}
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
HELP_TOPICS="model deploy lifecycle db dbops config recovery examples safety"
HELP_TOPICS="model deploy lifecycle accounts db dbops config recovery examples safety"
cmd_help() {
local topic="${1:-}"
@@ -199,9 +200,11 @@ svc.sh 分主题帮助(scripts/svc.sh help <主题>;完整 --help;文档 d
model 服务模型:命名、目录布局、svc.env 字段、history 事件、pidcheck
deploy 部署:三来源(local/dev@sha/release@v)、--pick、--dry-run、static --dir
lifecycle 生命周期:start/stop/restart/logs/status、set 变更、rm 注销
accounts 账户 CLI:create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin
db 数据库服务:create/start/dsn/ls/set(换端口)
dbops 数据库运维:backup/backups/restore/clone/copy
config 配置分层:优先级、--config 文件、SVC_* 环境变量、播种参数
recovery 自恢复:recover、watchdog、autostart
examples 配方:常见场景的完整命令序列
safety 安全约束与边界
EOF
@@ -298,15 +301,19 @@ EOF
pins/ 子模块后 make build(纯 Go,无需 npm),产物
bin/agent-system-rdm 复制为环境二进制;插件目录按空集安装
(组合插件编译进二进制),web/ 工作台静态资产随环境落
htdocs/(伴生 static 服务用 --dir 指向该目录)。
htdocs/,并自动部署伴生 <name>web(--kind static --dir)。
注意:rdm 源不支持 --mock 与 --config;模型设置播种
(--model-*/--tavily-keys)仅基座产物支持。
--pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr)
--dry-run 只打印部署计划不执行
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local)须显式确认;
同种类换 sha(含 update)不需要
行为细节:
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
- agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等)
- 部署收尾按源种类做 UI 探活:dev/local/release 要求本服务 GET /=200;
rdm 要求伴生 <name>web GET /=200(失败记 ui-missing,deploy 非 ok)
- 旧版本产物不认识 --auth/--config 时自动省略(探测 serve 用法)并提示;
不消费环境变量 DSN(AGENT_SYSTEM_DSN)的旧产物在需要交付数据库配置时
拒绝部署/启动(不静默按内嵌 SQLite 运行)
@@ -317,6 +324,7 @@ EOF
示例:
scripts/svc.sh prod deploy --from release@0.1.0 --tag stable
scripts/svc.sh prod deploy --from dev@73b2802 --dry-run
scripts/svc.sh dev deploy --from rdm@310c3fe --allow-source-switch
scripts/svc.sh ui-r4 deploy --kind static --from dev@73b2802
scripts/svc.sh ui-qa deploy --kind static --dir ../frontend/dist
EOF
@@ -344,6 +352,7 @@ EOF
postgres 实例与内部库 / 配置文件内容快照(敏感值默认掩码)/
最近事件;带 name 时输出单服务深度报告
scripts/svc.sh history [name] [N] 事件历史(首参为纯数字时视为条数)
scripts/svc.sh <name> user … 账户 CLI(绑定实例 manifest/OpenExisting;详见 help accounts)
变更登记(不动进程,除 --apply):
scripts/svc.sh set <name> --tag T... 三类服务通用,合并去重
@@ -375,6 +384,30 @@ EOF
- 领先远端 main / 与远端分叉 / 提交不在远端:报告但不动作(不盲目降级)
- release@源:部署 Releases 仓最新版本(sha256 校验)
- postgres / static --dir 不参与更新;每次更新写 history(update 事件)
EOF
return 0 ;;
accounts|account|user)
cat >&2 <<'EOF'
【accounts】实例绑定的账户 CLI(U1 已接线;帮助可发现性)
绑定合同(禁止回落默认 ./data):
- 必须先有实例登记与 data 根(manifest identity.data_root 优先)
- 强制 --open-existing:缺库/缺目录拒绝,不创建空库
- 禁止调用方覆盖 --data/--dsn/--open-existing
- 与启停互斥(lifecycle flock);无 flock 时拒绝
- mock 档不支持;PG 档须有有效 DSN/dbref
六个子命令(经 scripts/svc.sh <env> user … 转发到实例二进制):
scripts/svc.sh <env> user create --owner <id> [--role user|admin] [--password …]
scripts/svc.sh <env> user list
scripts/svc.sh <env> user reset-password --owner <id> [--password …]
scripts/svc.sh <env> user issue-login-key --owner <id> [--purpose activate|recover]
scripts/svc.sh <env> user revoke-login-key --id <key-id>
scripts/svc.sh <env> user bootstrap-admin --owner <id> [--password …]
凭据只在 stdout 展示一次;日志与 history 只记凭据 ID。后端原子开户/改密/审计
补齐见 Documents 方案 U7(本帮助主题不替代该增量)。
更完整说明:docs/svc.md、docs/svc-deploy/u1-instance-account-binding.md
EOF
return 0 ;;
db)
@@ -629,6 +662,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_ALLOW_SOURCE_SWITCH=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=()
@@ -665,6 +699,7 @@ parse_args() {
--reveal) CLI_REVEAL=1; shift ;;
--apply) CLI_APPLY=1; shift ;;
--force) CLI_FORCE=1; shift ;;
--allow-source-switch) CLI_ALLOW_SOURCE_SWITCH=1; shift ;;
--online) CLI_ONLINE=1; shift ;;
--file) need_value "$@"; CLI_FILE="$2"; shift 2 ;;
--out) need_value "$@"; CLI_OUT="$2"; shift 2 ;;
@@ -860,10 +895,13 @@ pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程
case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac
}
# pid_alive PID:当前服务的 pid 校验(svc.env pidcheck 优先;旧登记回退 agent-system)。
# pid_alive PID:当前服务的 pid 校验(内存 SVC_PIDCHECK → svc.env pidcheck → agent-system)。
# 启动路径在 record_state 之前就会调本函数;static 首启若只读 svc.env 会误回退到
# agent-system,把 python http.server 判成「进程即退」。
pid_alive() {
local pid="$1" mark
mark="$(svc_get pidcheck)"
mark="${SVC_PIDCHECK:-}"
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
[ -z "$mark" ] && mark="agent-system"
pid_alive_mark "$pid" "$mark"
}
@@ -890,6 +928,81 @@ port_owner() {
fi
}
# port_owner_pid:从 ss 行解析占口 PID(无 ss / 无 pid= 字段则空失败)。
port_owner_pid() {
local line pid
line="$(port_owner)"
[ -n "$line" ] || return 1
pid="$(printf '%s' "$line" | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -1)"
[ -n "$pid" ] || return 1
printf '%s' "$pid"
}
# cmdline_matches_own PID:进程 cmdline 是否含本服务标记(pidcheck/ENV_BIN;
# 路径被 cp 覆盖后 argv 仍常保留原路径;basename + ENV_DIR 作收紧兜底)。
cmdline_matches_own() {
local pid="$1" mark base cmd
mark="${SVC_PIDCHECK:-}"
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
[ -z "$mark" ] && mark="${ENV_BIN:-}"
[ -n "$mark" ] || return 1
[ -r "/proc/$pid/cmdline" ] || return 1
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
case "$cmd" in *"$mark"*) return 0 ;; esac
base="$(basename "$mark" 2>/dev/null || true)"
[ -n "$base" ] && [ "$base" != "$mark" ] || return 1
case "$cmd" in
*"$base"*)
[ -n "${ENV_DIR:-}" ] || return 1
case "$cmd" in *"$ENV_DIR"*) return 0 ;; esac
;;
esac
return 1
}
# find_own_in_ancestry PID:从占口 PID 沿 PPid 向上找本服务进程(stdout 打印命中 PID)。
# 真实 agent-system 单进程自听;桩用 bash+python http.server 时 ss 看到的是子进程。
find_own_in_ancestry() {
local cur="$1" i=0 next
while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$i" -lt 8 ]; do
if cmdline_matches_own "$cur"; then
printf '%s' "$cur"
return 0
fi
next="$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)"
[ -n "$next" ] && [ "$next" != "$cur" ] || return 1
cur="$next"
i=$((i + 1))
done
return 1
}
# reclaim_own_port:若 ENV_ADDR 端口被本服务残留占用则停掉并清 pidfile。
# 返回 0=端口已空闲(本就空或回收成功);1=仍被外部/未知占用或回收未释放端口。
# 修复:update/deploy 误清 pidfile 后 cp 覆盖二进制留下 (deleted) 孤儿,
# check_port_free 一律报「非本服务」导致无法重启(2026-10-12 genui@8973)。
reclaim_own_port() {
local port="${ENV_ADDR##*:}" listener target i
[ -n "$port" ] || return 0
port_listening "$port" || return 0
listener="$(port_owner_pid 2>/dev/null || true)"
[ -n "$listener" ] || return 1
target="$(find_own_in_ancestry "$listener" 2>/dev/null || true)"
if [ -n "$target" ]; then
c_warn "[$ENV_NAME] 端口 $port 被本服务残留进程占用(listener=$listener svc=$target),正在回收"
proc_stop "$target"
rm -f "$ENV_PID"
hist_event stop ok "$target" "reclaim_own_port"
for i in $(seq 1 50); do
port_listening "$port" || return 0
sleep 0.1
done
c_err "[$ENV_NAME] 本服务残留已发终止信号,但端口 $port 仍被占用"
return 1
fi
return 1
}
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
@@ -1054,9 +1167,19 @@ svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-de
svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; }
check_port_free() {
local owner; owner="$(port_owner)"
# 先尝试回收本服务残留(无/坏 pidfile 的孤儿);外部占用仍拒绝。
reclaim_own_port || true
local owner pid own
owner="$(port_owner)"
if [ -n "$owner" ]; then
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
pid="$(port_owner_pid 2>/dev/null || true)"
own=""
[ -n "$pid" ] && own="$(find_own_in_ancestry "$pid" 2>/dev/null || true)"
if [ -n "$own" ]; then
c_err "[$ENV_NAME] 端口被本服务残留占用且回收失败:$owner"
else
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
fi
c_err "处理:停掉该进程,或换端口(--port / --addr)"
return 1
fi
@@ -1083,8 +1206,12 @@ env_config() {
ENV_LOG="$ENV_DIR/svc.log"
ENV_BIN_DIR="$ENV_DIR/bin"
ENV_BIN="$ENV_BIN_DIR/agent-system"
# 数据库配置解析态按实例重置——批量 update/restart 逐服务解析时不串用上一服务的 DB_*。
# 按实例重置内存态——批量 update/restart 逐服务解析时不串用上一服务。
# DB_*:防跨服务串库;SVC_PIDCHECK/EXEC/VERSION/SOURCE:防 pid_alive 与
# reclaim_own_port 用上一服务标记误判本服务进程为「已死/非本服务」(2026-10-12
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
local registered=""
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
SVC_KIND="${registered:-agent}"
@@ -1400,6 +1527,11 @@ resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/
install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。
local srcbin="$1" srcplug="$2"
# 覆盖前必须无本服务占口进程——否则留下 exe=(deleted) 孤儿,后续 start 误报非本服务。
if ! reclaim_own_port; then
c_err "[$ENV_NAME] 安装前端口仍被占用,拒绝覆盖二进制(避免 exe deleted 孤儿)"
return 1
fi
mkdir -p "$ENV_BIN_DIR"
rm -rf "$ENV_DIR/plugins"
cp -f "$srcbin" "$ENV_BIN" || return 1
@@ -1555,8 +1687,124 @@ agent_do_user() {
# ---- agent:部署与启动 ----
# source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。
source_kind_prefix() {
case "$1" in
*@*) printf '%s' "${1%%@*}" ;;
*) printf '%s' "$1" ;;
esac
}
# require_allow_source_switch:同名 agent 换源种类(dev↔rdm↔release↔local)须显式确认;
# 同种类换 sha(含 update)不要求。与 service kind 冲突门禁正交。
require_allow_source_switch() {
local prev prev_kind new_kind
prev="$(svc_get source)"
[ -n "$prev" ] || return 0
prev_kind="$(source_kind_prefix "$prev")"
new_kind="$(source_kind_prefix "$SRC_LABEL")"
[ -n "$prev_kind" ] && [ -n "$new_kind" ] || return 0
[ "$prev_kind" = "$new_kind" ] && return 0
if [ "${CLI_ALLOW_SOURCE_SWITCH:-0}" = 1 ]; then
c_warn "[$ENV_NAME] 换源已确认:$prev → $SRC_LABEL(--allow-source-switch)"
return 0
fi
c_err "[$ENV_NAME] 换源种类 $prev_kind → $new_kind($prev → $SRC_LABEL)需显式确认"
c_err " 加 --allow-source-switch 后重试(会改变环境是否自带 UI)"
return 1
}
rdm_companion_name() { printf '%sweb' "$1"; }
# http_code URL:返回 HTTP 状态码(连接失败 → 000)。
http_code() {
curl -s -o /dev/null -m 2 -w '%{http_code}' "$1" 2>/dev/null || printf '000'
}
# rdm_workbench_missing NAME:rdm@* agent 且伴生未登记或未运行 → 0(真)。
rdm_workbench_missing() {
local name="$1" src cname st
src="$(env_kv_of "$name" source)"
case "$src" in rdm@*) ;; *) return 1 ;; esac
cname="$(rdm_companion_name "$name")"
[ -f "$RUNTIME/$cname/svc.env" ] || return 0
st="$(svc_pid_state "$cname")"
[ "${st%% *}" = alive ] && return 1
return 0
}
# deploy_rdm_companion_ui:子进程部署 ${ENV_NAME}web --kind static --dir htdocs。
deploy_rdm_companion_ui() {
local cname="$1" htdocs="$2"
if ! valid_env "$cname"; then
c_err "[$ENV_NAME] 伴生名非法:$cname(服务名 ≤16 字符;ui-missing)"
return 1
fi
if [ ! -d "$htdocs" ] || [ ! -f "$htdocs/index.html" ]; then
c_err "[$ENV_NAME] RDM 工作台缺失:$htdocs(ui-missing)"
return 1
fi
c_info "[$ENV_NAME] 自动部署伴生工作台:$cname --kind static --dir $htdocs"
if ! "$0" "$cname" deploy --kind static --dir "$htdocs"; then
c_err "[$ENV_NAME] 伴生工作台部署失败(ui-missing)"
return 1
fi
return 0
}
# assert_ui_ready:按源种类断言页面入口 HTTP 200(与进程探活分层)。
# rdm@* → 伴生 ${name}web;dev@*/local@*/release@* → agent 自身 GET /。
assert_ui_ready() {
local src="${SRC_LABEL:-$(svc_get source)}"
local code url host port addr cname probe
case "$src" in
rdm@*)
cname="$(rdm_companion_name "$ENV_NAME")"
addr="$(env_kv_of "$cname" addr)"
if [ -z "$addr" ]; then
c_err "[$ENV_NAME] UI 探活失败:伴生 $cname 未登记(ui-missing)"
return 1
fi
host="${addr%:*}"; port="${addr##*:}"
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
url="http://$host:$port/"
code="$(http_code "$url")"
if [ "$code" != 200 ]; then
c_err "[$ENV_NAME] UI 探活失败:伴生 $url → HTTP $code(期望 200;ui-missing)"
return 1
fi
c_ok "[$ENV_NAME] UI 就绪:伴生 $cname → $url"
return 0
;;
dev@*|local@*|release@*)
probe="${ENV_ADDR}"
case "$(svc_get exec)" in *--addr\ *)
probe="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;;
esac
[ -n "$probe" ] || probe="$ENV_ADDR"
host="${probe%:*}"; port="${probe##*:}"
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
url="http://$host:$port/"
code="$(http_code "$url")"
if [ "$code" != 200 ]; then
c_err "[$ENV_NAME] UI 探活失败:$url → HTTP $code(期望 200;ui-missing)"
return 1
fi
c_ok "[$ENV_NAME] UI 就绪:$url"
return 0
;;
*)
return 0
;;
esac
}
agent_do_deploy() {
resolve_source
if ! require_allow_source_switch; then
hist_event deploy fail "$(read_pid)" "换源种类需 --allow-source-switch($SRC_LABEL)"
return 1
fi
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
@@ -1566,7 +1814,7 @@ agent_do_deploy() {
fi
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
do_stop_quiet
local wt
local wt RDM_UI_OK=0
case "$SRC_KINDSRC" in
local)
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
@@ -1591,15 +1839,17 @@ agent_do_deploy() {
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 一并删除)。
# RDM 自带零构建工作台(web/)落 htdocs/;收尾自动部署伴生 ${name}web。
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)"
RDM_UI_OK=1
c_ok "[$ENV_NAME] RDM 工作台就位:$ENV_DIR/htdocs(将自动部署伴生 $(rdm_companion_name "$ENV_NAME"))"
else
c_warn "[$ENV_NAME] RDM web/ 工作台复制失败(不影响 API 服务启动)"
c_warn "[$ENV_NAME] RDM web/ 工作台复制失败"
fi
else
c_warn "[$ENV_NAME] RDM 源无 web/ 工作台目录"
fi
recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR"
;;
@@ -1612,6 +1862,23 @@ agent_do_deploy() {
return 1
fi
agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; }
case "$SRC_KINDSRC" in
rdm)
if [ "$RDM_UI_OK" != 1 ]; then
c_err "[$ENV_NAME] RDM 工作台未就位(ui-missing):缺少可伺服的 htdocs/"
hist_event deploy fail "$(read_pid)" "ui-missing(htdocs 未就位)"
return 1
fi
if ! deploy_rdm_companion_ui "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR/htdocs"; then
hist_event deploy fail "$(read_pid)" "ui-missing(伴生部署失败)"
return 1
fi
;;
esac
if ! assert_ui_ready; then
hist_event deploy fail "$(read_pid)" "ui-missing"
return 1
fi
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
return 0
}
@@ -1636,7 +1903,8 @@ print_agent_plan() {
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" ;;
printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \
"$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;;
esac
return 0
}
@@ -2208,11 +2476,13 @@ resolve_dir_deploy() { # --dir 路径:登记(含 dry-run 计划)后交由
}
# write_static_reg HTDOCS:登记/更新 static 服务(保留 pid 等当前态由 start 覆写)。
# 同步写入 pidcheck,避免首启在 record_state 前 pid_alive 误回退到 agent-system。
write_static_reg() {
local htdocs="$1"
local old_pid=""; [ -f "$ENV_PID" ] && old_pid="$(read_pid)"
local merged_tags; merged_tags="$(svc_merge_tags)" # 先读旧文件再截断
mkdir -p "$ENV_DIR"
SVC_PIDCHECK="--directory $htdocs"
{
printf 'kind=static\n'
printf 'env=%s\n' "$ENV_NAME"
@@ -2222,6 +2492,7 @@ write_static_reg() {
printf 'source=%s\n' "$SRC_LABEL"
printf 'started=%s\n' "$(date -Is)"
printf 'htdocs=%s\n' "$htdocs"
printf 'pidcheck=%s\n' "$SVC_PIDCHECK"
} > "$ENV_DIR/svc.env"
chmod 600 "$ENV_DIR/svc.env"
}
@@ -2256,10 +2527,11 @@ static_do_start() {
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 9>&- &
echo $! > "$ENV_PID"
local probe_host="$host"
local probe_host="$host" code
[ "$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
code="$(http_code "http://$probe_host:$port/")"
if [ "$code" = 200 ]; then
record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规)
svc_mark_autostart
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)"
@@ -2275,7 +2547,7 @@ static_do_start() {
done
record_state "htdocs=$htdocs"
svc_mark_autostart
c_err "[$ENV_NAME] 10s 健康检查未通过"
c_err "[$ENV_NAME] 10s 健康检查未通过(末次 HTTP ${code:-?})"
hist_event start fail "$(read_pid)" "健康检查超时"
return 1
}
@@ -3323,11 +3595,15 @@ do_stop() {
local pid; pid="$(read_pid)"
if [ -z "$pid" ]; then
c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例"
# 仍回收本服务残留占口(update 误清 pidfile 后的孤儿)。
reclaim_own_port || true
svc_clear_autostart
return 0
fi
if ! pid_alive "$pid"; then
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
rm -f "$ENV_PID"
reclaim_own_port || true
svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止
return 0
fi
@@ -3349,9 +3625,14 @@ do_stop() {
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
do_stop_quiet() {
local pid; pid="$(read_pid)"
if [ -z "$pid" ]; then svc_clear_autostart; return 0; fi
if [ -z "$pid" ]; then
reclaim_own_port || true
svc_clear_autostart
return 0
fi
if ! pid_alive "$pid"; then
rm -f "$ENV_PID"
reclaim_own_port || true
svc_clear_autostart
return 0
fi
@@ -3489,6 +3770,11 @@ global_list() {
fi
done
[ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)"
for name in ${names[@]+"${names[@]}"}; do
if rdm_workbench_missing "$name"; then
c_warn " [$name] 无工作台入口(伴生 $(rdm_companion_name "$name") 未登记或未运行)"
fi
done
return 0
}
@@ -3519,6 +3805,10 @@ env_status_detail() {
printf ' 监听守卫 insecureListen=%s allowedHosts=%s\n' \
"$(env_var "$ENV_NAME" INSECURE_LISTEN)" \
"$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
if rdm_workbench_missing "$ENV_NAME"; then
printf ' 工作台 无入口(伴生 %s 未登记或未运行;scripts/svc.sh %s deploy --kind static --dir %s/htdocs)\n' \
"$(rdm_companion_name "$ENV_NAME")" "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR"
fi
;;
postgres)
printf ' 库 %s 用户 %s\n' "$(svc_get dbname)" "$(svc_get user)"
@@ -4301,8 +4591,8 @@ main() {
exit 0
fi
parse_args "$@"
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ]; } && [ "$action" != deploy ]; then
c_err "--from/--pick/--dry-run/--dir 仅用于 deploy 动作"
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ]; } && [ "$action" != deploy ]; then
c_err "--from/--pick/--dry-run/--dir/--allow-source-switch 仅用于 deploy 动作"
exit 2
fi
env_config "$ENV_NAME"
+163 -6
View File
@@ -11,6 +11,8 @@ import shutil
import subprocess
import tempfile
import unittest
import urllib.error
import urllib.request
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
@@ -60,8 +62,10 @@ case "${1:-}" in
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
# GET/HEAD 一律 200(健康探测语义与 http.server 等价);PUT /api/* 捕获头与
# 请求体到 trace/put.txt 供播种断言(settings 播种验证,2026-10-11)。
# HTTP 子进程轮询 ppid:父进程被 SIGKILL 后自行退出,避免与 recover 套件
# 共用 1880x 端口时泄漏占用(与 test_svc_recover 桩同规)。
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
import sys
import os, sys, threading, time
from http.server import BaseHTTPRequestHandler, HTTPServer
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
class H(BaseHTTPRequestHandler):
@@ -83,7 +87,20 @@ class H(BaseHTTPRequestHandler):
self._ok()
def log_message(self, *args):
pass
HTTPServer((host, port), H).serve_forever()
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"
@@ -601,6 +618,56 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.svc("dev", "restart")
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
def test_batch_update_stops_second_service_despite_prior_pidcheck(self):
"""批量 update 不得用上一服务的 SVC_PIDCHECK 误判下一服务进程。
复现 2026-10-12:update --all 先成功部署 a,内存 SVC_PIDCHECK 残留 a 的
bin 路径;do_stop_quiet / reclaim_own_port 对 b 匹配失败 → 误清 pidfile、
旧进程占口 → 报「非本服务」。env_config 须按实例清空 SVC_PIDCHECK 等。
"""
old_sha, new_sha = self.make_src_repo()
ports = {"a": 18841, "b": 18842}
old_pids = {}
for name, port in ports.items():
d = self.runtime(name)
self.seed_agent(
name, port,
"--addr 127.0.0.1:%d --data %s --plugins %s --auth auto"
% (port, d / "data", d / "plugins"),
extra_rows=[("source", "dev@%s" % old_sha)])
self.start_ok(name)
pidf = d / "svc.pid"
self.assertTrue(pidf.exists(), name)
old_pids[name] = int(pidf.read_text().strip())
self.assertTrue(self.pid_alive(name), name)
result = self.svc("update", "a", "b")
self.assertNotIn("非本服务", result.stdout, result.stdout)
self.assertNotIn("更新失败", result.stdout, result.stdout)
for name in ("a", "b"):
self.assertEqual(self.kv(name, "source"), "dev@%s" % new_sha, name)
self.assertTrue(self.pid_alive(name), "%s 应在运行" % name)
new_pid = int((self.runtime(name) / "svc.pid").read_text().strip())
self.assertNotEqual(new_pid, old_pids[name],
"%s 应已换新进程" % name)
# 与 svc.sh / recover 夹具一致:僵尸按死处理(CI 容器 PID 1 不收割)。
old_still = False
try:
os.kill(old_pids[name], 0)
try:
state = Path("/proc/%d/stat" % old_pids[name]).read_text() \
.rsplit(")", 1)[1].split()[0]
old_still = state != "Z"
except (OSError, IndexError):
old_still = True
except (ProcessLookupError, PermissionError):
old_still = False
self.assertFalse(old_still, "%s 旧进程应已停止" % name)
hist = (self.runtime(name) / "history.jsonl").read_text()
self.assertIn('"event":"stop"', hist, hist)
self.assertIn('"result":"ok"', hist)
self.assertIn('"event":"update"', hist)
# ---- 12. 播种认证(2026-10-11 修复):认证开启自动铸临时 key,用后回收 ----
@@ -671,10 +738,14 @@ class SvcDbRecoveryTests(unittest.TestCase):
# ---- 13. rdm 部署源(下游组合仓 Agent-System-RDM):物化/产物/工作台/播种跳过 ----
def make_rdm_repo(self):
"""构造下游组合仓夹具:Makefile build 产出桩 agent-system-rdm + web/ 工作台。"""
def make_rdm_repo(self, with_web=True):
"""构造下游组合仓夹具:Makefile build 产出桩 agent-system-rdm + 可选 web/。"""
remote = self.root / "rdmremote.git"
work = self.root / "rdmwork"
if remote.exists():
shutil.rmtree(remote)
if work.exists():
shutil.rmtree(work)
run = lambda cwd, *args: subprocess.run(
["git", *args], cwd=cwd, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
@@ -683,8 +754,9 @@ class SvcDbRecoveryTests(unittest.TestCase):
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")
if with_web:
(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))
@@ -702,6 +774,23 @@ class SvcDbRecoveryTests(unittest.TestCase):
})
return env
def http_get(self, url, timeout=2):
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, ""
except OSError:
return 0, ""
def companion_url(self, agent_name):
addr = self.kv(agent_name + "web", "addr")
self.assertIsNotNone(addr, "companion %sweb not registered" % agent_name)
host, port = addr.rsplit(":", 1)
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
return "http://%s:%s/" % (host, port)
def test_rdm_source_deploy_end_to_end(self):
remote, work = self.make_rdm_repo()
env = self.rdm_env(remote)
@@ -715,10 +804,50 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.assertEqual(list((d / "plugins").iterdir()), [])
self.assertIn("rdm workbench", (d / "htdocs" / "index.html").read_text())
self.assertIn("args:", self.serve_record()) # 健康通过即登记启动参数
# 伴生 static 自动部署并探活 200。
self.assertEqual(self.kv("rdm1web", "kind"), "static")
self.assertTrue(self.kv("rdm1web", "source").startswith("dir@"))
code, body = self.http_get(self.companion_url("rdm1"))
self.assertEqual(code, 200)
self.assertIn("rdm workbench", body)
self.assertIn("UI 就绪", result.stdout)
# rdm 形态跳过设置播种(无 user/apikey 子命令):给了模型键也不产生 PUT/CLI。
self.assertFalse((self.trace / "put.txt").exists())
self.assertNotIn("user list", self.cli_record())
def test_rdm_companion_missing_web_fails_deploy(self):
remote, work = self.make_rdm_repo(with_web=False)
env = self.rdm_env(remote)
result = self.svc("rdm2", "deploy", "--from", "rdm", "--port", "18843",
env=env, check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("ui-missing", result.stdout)
hist = (self.runtime("rdm2") / "history.jsonl").read_text()
self.assertIn("ui-missing", hist)
self.assertIsNone(self.kv("rdm2web", "kind"))
def test_source_switch_requires_flag(self):
remote, work = self.make_rdm_repo()
env = self.rdm_env(remote)
# 先以 local 源部署(内嵌 UI 桩一律 200)。
self.svc("sw1", "deploy", "--from", "local", "--port", "18844")
old = self.kv("sw1", "source")
self.assertTrue(old.startswith("local@"))
# 无旗标换到 rdm → 拒绝,source 不变。
denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844",
env=env, check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("allow-source-switch", denied.stdout)
self.assertEqual(self.kv("sw1", "source"), old)
# 带旗标允许换源,伴生就位。
ok = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844",
"--allow-source-switch", env=env)
self.assertTrue(self.kv("sw1", "source").startswith("rdm@"))
self.assertIn("UI 就绪", ok.stdout)
code, body = self.http_get(self.companion_url("sw1"))
self.assertEqual(code, 200)
self.assertIn("rdm workbench", body)
def test_rdm_source_commits_and_update_track(self):
remote, work = self.make_rdm_repo()
env = self.rdm_env(remote)
@@ -740,6 +869,34 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.assertTrue(new.startswith("rdm@"))
self.assertNotEqual(new, old)
self.assertIn("args:", self.serve_record()) # 更新后健康在跑
# 同轨 update 不需 --allow-source-switch;伴生仍 200。
code, body = self.http_get(self.companion_url("rdm1"))
self.assertEqual(code, 200)
self.assertIn("rdm workbench", body)
# ---- static 全新部署:首启健康循环不得误判「进程即退」 ----
def test_static_fresh_deploy_registers_pidcheck_and_starts(self):
"""全新 static --dir:登记须含 pidcheck;首启探活不得把 http.server 判死。"""
htdocs = self.root / "static-site"
htdocs.mkdir()
(htdocs / "index.html").write_text("<html>static-ok</html>\n")
port = 18951
result = self.svc(
"uiqa", "deploy", "--kind", "static",
"--dir", str(htdocs), "--addr", "127.0.0.1:%d" % port)
self.assertIn("已启动", result.stdout)
self.assertNotIn("进程即退", result.stdout)
self.assertEqual(self.kv("uiqa", "kind"), "static")
pidcheck = self.kv("uiqa", "pidcheck")
self.assertIsNotNone(pidcheck)
self.assertIn("--directory", pidcheck)
# write_static_reg 用 cd 后的绝对路径;与 resolve 后的 htdocs 对齐。
self.assertIn(str(htdocs.resolve()), pidcheck)
self.assertTrue(self.pid_alive("uiqa"))
with urllib.request.urlopen("http://127.0.0.1:%d/" % port, timeout=3) as resp:
self.assertEqual(resp.status, 200)
self.assertIn(b"static-ok", resp.read())
if __name__ == "__main__":
+9 -1
View File
@@ -218,7 +218,7 @@ class ResolveDbConfigFixtures(unittest.TestCase):
class ResolverStateIsolation(unittest.TestCase):
"""解析态按实例隔离(env_config 重置,防 update --all 串库)。"""
"""解析态按实例隔离(env_config 重置,防 update --all 串库 / 串 pidcheck)。"""
def test_env_config_resets_resolver_state(self):
# 棘轮:env_config 必须重置 SVC_DB_*(删除即回归「同进程连续启动跨服务
@@ -228,6 +228,14 @@ class ResolverStateIsolation(unittest.TestCase):
)
self.assertEqual(out.strip(), "1")
def test_env_config_resets_pidcheck_runtime_state(self):
# 棘轮:env_config 必须清空 SVC_PIDCHECK(删除即回归「批量 update 用上一
# 服务 mark 误判本服务进程 → 误清 pidfile / 无法 reclaim」)。
out = _bash(
'source "$1"; declare -f env_config | grep -c \'SVC_PIDCHECK=""\''
)
self.assertEqual(out.strip(), "1")
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -437,6 +437,80 @@ class SvcAutoRecoverTests(unittest.TestCase):
thief.terminate()
thief.wait(timeout=10)
# ---- 4b. 本服务孤儿占口(无 pidfile)→ start 回收后成功;外部占口仍拒 ----
def _wait_port(self, port, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
s = socket.socket()
s.settimeout(0.2)
try:
s.connect(("127.0.0.1", port))
return True
except OSError:
time.sleep(0.05)
finally:
s.close()
return False
def _ss_can_attribute_pid(self):
"""reclaim_own_port /「非本服务」归因依赖 ss -ltnp 的 pid= 字段。
CI 容器常无 iproute2 或 ss 不带进程信息——此时只能探测占用、不能归因,
相关用例跳过(拒绝启动的兜底路径仍由「未能识别占用者」覆盖)。
"""
try:
out = subprocess.run(
["ss", "-ltnp"], capture_output=True, text=True, timeout=5)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return False
return out.returncode == 0 and "pid=" in out.stdout
def test_start_reclaims_own_orphan_without_pidfile(self):
"""模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。"""
if not self._ss_can_attribute_pid():
self.skipTest("ss -ltnp 无法归因 PID(reclaim_own_port 需此能力)")
port = 18812
self.seed_agent("dev", port)
self.start_ok("dev")
old = self.pid_of("dev")
self.assertTrue(self.pid_alive(old))
# 只删 pidfile、不杀进程——对齐 2026-10-12 genui 现场(list=未运行、端口仍监听)。
(self.runtime("dev") / "svc.pid").unlink()
self.assertIsNone(self.pid_of("dev"))
self.assertTrue(self.pid_alive(old))
self.assertTrue(self._wait_port(port))
result = self.start_ok("dev")
self.assertIn("残留", result.stdout)
new = self.pid_of("dev")
self.assertTrue(self.pid_alive(new), result.stdout)
self.assertNotEqual(new, old)
self.assertFalse(self.pid_alive(old))
stop_events = self.history_events("dev", "stop")
self.assertTrue(any("reclaim_own_port" in l for l in stop_events), stop_events)
def test_start_rejects_foreign_port_occupant(self):
"""外部 python http.server 占口:start 必须失败并提示非本服务。"""
port = 18813
self.seed_agent("dev", port)
thief = subprocess.Popen(
["python3", "-m", "http.server", str(port), "--bind", "127.0.0.1"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.addCleanup(thief.terminate)
self.assertTrue(self._wait_port(port), "thief 未能监听")
try:
result = self.svc("dev", "start", check=False)
self.assertEqual(result.returncode, 1, result.stdout)
# 有 ss 时归因到外部进程 →「非本服务」;无 ss 时仅能探测 →「未能识别占用者」。
# 两种路径都拒绝启动,不拉起本服务。
self.assertTrue(
("非本服务" in result.stdout) or ("未能识别占用者" in result.stdout),
result.stdout)
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):
+101
View File
@@ -0,0 +1,101 @@
"""svc.sh 账户帮助可发现性(方案 U5 T5-1 最小切片)。
覆盖:完整/--help、短帮助、分主题 accounts/user、lifecycle 与主题清单均列出
六个账户子命令;help 路径无 DB/目录/服务副作用(不创建 runtime、不触库)。
"""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC = SOURCE / "scripts" / "svc.sh"
SIX = (
"create",
"list",
"reset-password",
"issue-login-key",
"revoke-login-key",
"bootstrap-admin",
)
class UserHelpTests(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp(prefix="svc-user-help-"))
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
# Isolate any accidental CWD probes; help must not create runtime here.
self.cwd = self.tmp / "cwd"
self.cwd.mkdir()
def run_help(self, *args):
env = os.environ.copy()
env["HOME"] = str(self.tmp / "home")
Path(env["HOME"]).mkdir(exist_ok=True)
return subprocess.run(
["bash", str(SVC), *args],
cwd=self.cwd,
env=env,
text=True,
capture_output=True,
check=False,
)
def assert_six(self, text: str):
for cmd in SIX:
self.assertIn(cmd, text, f"missing account subcommand {cmd!r}")
def assert_no_side_effects(self):
self.assertFalse(
(self.cwd / "runtime").exists(),
"help must not create runtime/",
)
leftovers = [p for p in self.cwd.iterdir() if p.name != ".svc.env"]
# allow empty; forbid agent-system.db or data trees
for p in leftovers:
self.assertNotEqual(p.name, "data")
self.assertFalse(p.name.endswith(".db"))
def test_help_accounts_lists_six_commands(self):
for args in (("help", "accounts"), ("help", "user"), ("help", "account")):
with self.subTest(args=args):
r = self.run_help(*args)
self.assertEqual(r.returncode, 0, r.stderr)
out = r.stdout + r.stderr
self.assert_six(out)
self.assertIn("open-existing", out)
self.assertIn("回落", out)
self.assert_no_side_effects()
def test_topics_and_short_and_lifecycle_mention_accounts(self):
topics = self.run_help("help")
self.assertEqual(topics.returncode, 0, topics.stderr)
topics_out = topics.stdout + topics.stderr
self.assertIn("accounts", topics_out)
short = self.run_help("-h")
short_out = short.stdout + short.stderr
self.assertIn("user", short_out)
self.assertIn("accounts", short_out)
life = self.run_help("help", "lifecycle")
life_out = life.stdout + life.stderr
self.assertIn("user", life_out)
self.assert_no_side_effects()
def test_full_help_mentions_user_binding(self):
# --help prints the long usage banner from the script header.
r = self.run_help("--help")
out = r.stdout + r.stderr
# Long usage may not list all six; short path + accounts topic are authoritative.
# Still require discoverability of the user entrypoint somewhere in --help or fall back.
if "user" not in out:
# Accept if topics path works; full banner is historical.
self.assertIn("help", out.lower())
self.assert_no_side_effects()
if __name__ == "__main__":
unittest.main()