Merge authoritative main into the task branch

Why: include the current integration baseline before verification.
Evidence: fetched origin/main=692735e91d6a9ab07ddd3686feb60a56b270728a.
Impact: integrate upstream changes without rewriting task history.
Refs: task-sync
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-15 05:55:51 +00:00
co-authored by Cursor
21 changed files with 3222 additions and 167 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
+7
View File
@@ -36,6 +36,13 @@ Agent-System-Dev 是桌面优先、Web 兼容的 AI 工作台。界面应**冷
| `--bg-hover` | `rgba(255,255,255,.05)` | `rgba(13,13,13,.05)` | 通用悬停 |
| `--bg-active` | `rgba(255,255,255,.10)` | `rgba(13,13,13,.08)` | 选中态 |
三栏工作台的顶栏与左栏使用独立导航骨架令牌:`--workbench-chrome`、
`--workbench-chrome-text`、`--workbench-chrome-dim`、`--workbench-chrome-faint`、
`--workbench-chrome-hover`、`--workbench-chrome-active`。浅色主题为近黑导航骨架,深色主题取面板阶梯,
墨绿主题取海军蓝侧栏;反色创建按钮的 hover/分隔线使用
`--workbench-chrome-inverse-hover` / `--workbench-chrome-inverse-border`。宿主组件只能消费这些令牌,
不得用 `color-mix()`、rgba 或局部变量再次派生导航颜色。
### 2.2 边框(发丝三档)
`--border-soft: rgba(255,255,255,.06)` / `--border: rgba(255,255,255,.10)` /
+3 -1
View File
@@ -12,6 +12,8 @@
目录中的既有文档不因被收录而自动成为已批准规划。记录、任务关联及收尾建议按
[交付规范的长期方向与任务衔接](policy/delivery.md#长期方向与任务衔接) 执行。
- [svc 插件组合部署(--with)方案 r1](modules/documents/docs/proposals/2026-10-14-svc-plugin-compose-deploy.md):待评审。部署时把登记插件与 Dev 基座编成一个二进制并校验激活。收录不构成批准。
- [用户、职位角色与分层权限统一方案 r1](modules/documents/docs/proposals/2026-10-13-identity-role-permission-plan.md):用户已批准,首批仅 I1(U1–U2);
[权限合同](modules/documents/docs/proposals/2026-10-13-iam-i1-permission-contract.md) 冻结边界,
[I1 实施候选与评审记录](modules/documents/docs/proposals/2026-10-13-iam-i1-review.md) 维护实际结果与剩余限制;后续单元须另行授权。
@@ -57,7 +59,7 @@ U2 起实施与部署分别评审。
## 验证
规范修订:[最终验证与合入调度 r1](policy/merge-queue.md) 及[U1–U6 实施计划](modules/documents/docs/proposals/2026-10-13-merge-queue-implementation-plan.md)已批准。通用规范与计划已分别经 PR 合入,本候选组合固定实际合入 SHA,见[采纳记录](policy/adoption.md)。同步幂等化、显式局部恢复和源码外证据基础已实施;Dev 最新组合验收及平台调度模式启用仍按各自门禁办理。
多个分支向 Dev main 交付时按[并行开发与串行合入](policy/merge-queue.md)执行:等待分支不追 main,轮到后同步一次、固定候选、运行当前 HEAD 的完整 CI,再由智能体合入。实际采纳范围见[采纳记录](policy/adoption.md)。
`make test` 先核对子模块工作树与固定指针,再运行治理脚本、公开边界、跨仓指针、库及各模块
测试。实际目标以根 Makefile 为准。`make test-governance` 可独立检查开发门禁,使用临时
+21
View File
@@ -381,6 +381,27 @@ scripts/svc.sh <name> deploy --dry-run # 只打印计划
`commits` 表格:dev 源标注 `[最新]`(源仓 origin/main 顶)与 `[已部署:<服务名>]`
(从各服务 `svc.env source=` 汇总);release 源额外给出版本号与对应 Dev 提交。
### 5.6 编译插件组合(`--with`)
`plugins/registry.json` 登记可以编进基座的插件。`deploy --with` 在本次 `--from local`
或 `--from dev` 的基座上生成空白导入并一次 `go build -mod=mod`,产物仍是
`agent-system`。插件不自带基座 pin;对不上就构建失败。
```bash
scripts/svc.sh dev deploy --from dev@<sha> --with rdm --dry-run
scripts/svc.sh dev deploy --from local --with rdm@<sha>
scripts/svc.sh dev deploy --with none --allow-source-switch
```
- 终端上没给 `--with` 时按编号勾选,回车沿用上次;非交互沿用 `svc.env` 的 `plugins=`,没有则不编入。
- `--with` 与 `--from rdm`、`--from release` 互斥。旧 `--from rdm` 仍用 RDM 仓自己的 `pins/` 构建。
- 部署后核对 `/api/plugins`、`/api/registry` 的 `compiled` 和 `/api/commands`。未启用且认证开启时,用临时管理员 key 启用。失败走 release 回退。
- `source` 写成 `dev@<基座短提交>+rdm@<插件短提交>`。`plugins=` 记 `id@完整提交`。`update` 原样带上,不再询问。
- 已经在跑的服务要改插件 id 集合,必须加 `--allow-source-switch`。第一次部署不需要。
- 登记了 `web` 的插件最多一个,复用伴生 `<name>web`。
`SVC_COMPOSE_STUB=1` 只给测试跳过真实 `go build`。真实组合构建与 `make test-lifecycle` 不在桩测试里。
## 6. 命令参考:全局命令
### 6.1 list / status(总览)
+2
View File
@@ -1,3 +1,5 @@
# plugins/
示例插件与插件开发参考(P4 起填充;插件开发指南见 modules/documents/docs/guides/)。
`registry.json` 是 `svc.sh deploy --with` 的编译插件登记表。Dev 仓只保存登记,插件源码在各自仓库。说明见 `docs/svc.md` 与 `scripts/svc.sh help plugins`。
+15
View File
@@ -0,0 +1,15 @@
{
"plugins": [
{
"id": "rdm",
"name": "研发记忆 RDM",
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git",
"repoEnv": "SVC_RDM_ORIGIN",
"defaultRef": "origin/main",
"goModule": "agent-system-rdm",
"entryPackage": "agent-system-rdm/plugins/rdm",
"commandPrefix": "rdm.",
"web": "web"
}
]
}
+2 -2
View File
@@ -6,7 +6,7 @@
- 过程文档位置:modules/documents(docs/records/)
- 版本权威:父仓 gitlink
- 批准/生效依据:Dev PR#1(19/19 正负例 + make test);guidelines PR#1
- 本次升级候选:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);本候选父仓固定该源码版本。基础门禁继续执行,合入调度流程单独记 approved/pending-activation;Dev 组合验收、平台正负例和全部写入者覆盖完成前不切换交付模式。
- 合入调度通用版本:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);本仓 gitlink 固定该源码版本。Dev 采用 [manual 串行合入规则](merge-queue.md):并行开发,轮到后才同步、跑最终 CI 并由智能体合入。该规则不要求先完成全写者审计或第二轮试行,也不宣称自动互斥已经实现。
- 过程计划固定:Documents PR #96 实际合入 `129cc3579d9c9e3edfb46c99270d65f3e1dad0a0`,包含既有主干文档及获批 U1–U6 计划;当前进展以任务/PR 记录为准。
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
@@ -22,7 +22,7 @@
| standards/tool-adaptation.md(draft.8) | 为工具创建规则/钩子实例 | 本组合 CLI/git 级实例已登记:policy/tool-adaptation-matrix.md(A1–A7 逐行定级+正负例证据) |
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
| core/baseline.md「规范层级」+ documentation.md「记忆分层/机制化」(draft.9) | 层级效力、记忆权威、经验机制化 | manual:本仓 policy/ 仅收紧不放宽;记忆四层按条文执行(任务卡=任务记忆,documents/records=项目记忆,Dev 仓 AGENTS.md 指针已就位) |
| draft.11 合入调度候选 | approved/pending-activation;本候选组合固定实际合入 SHA,Dev 验收待完成 | CLI/文档已有 SELF、独立 REPLAY 和历史固定对象 GATE;当前新组合须另验。Issue #518 已建且协调者已指定,平台正负例、所有写入者覆盖及真实试行未完成;现行准入条件保留 |
| draft.11 合入调度 | active/manual;Dev main 使用 #518 排序和单一最终窗口 | 等待分支不追 main;窗口任务同步一次、固定 HEAD、跑当前必需 CI、由智能体合入并回读结果。Issue 不是原子锁,平台保护仍承担最终准入 |
## 项目差异
+1 -1
View File
@@ -25,7 +25,7 @@ make task-start NAME=feature/my-task PRECHECK_RECEIPT=/tmp/my-task-precheck.json
默认 `TASK_WORK_MODE=delivery`,继续要求严格 READY 和当前目标一致。
工作区忙或落后时先由 owner 保存现场并同步;远端不可达时只继续不依赖新鲜基线的阅读和分析。
局部恢复接口已具备,流程启用仍以 [合入调度](merge-queue.md#采纳顺序与停止条件) 的采纳条件为准。
局部恢复接口已具备,正式交付仍按[合入调度](merge-queue.md)的单一最终窗口执行。
启用后,既有任务可以显式选择 local,保持原 R0,标记落后,继续已授权局部工作:
```bash
+52 -114
View File
@@ -1,136 +1,74 @@
# 最终验证与合入调度(r1,manual 受控试行)
# 并行开发与串行合入
状态:approved(r1 条文作为实施计划基线);执行模式:manual 受控试行;更新日期:2026-10-14(Asia/Shanghai);规范采纳负责人:weichen。实际启用时间、对象和窗口以调度记录 #518 为准,候选文件不代替当前平台准入。
r1 条文来源提交为 913b214f2826a50b3dab65f79d9ebf83e84f2519;Documents 实施与测试计划 r1@ba4044d57e38d0887dd425d052abf26b20a28bf4 已于 2026-10-13 获用户「批准」。阶段结论与执行进展按 [迭代交付](delivery.md) 记录在任务主记录,候选固定后的结论保存在关联 PR 与源码外收据中;规范及计划批准不等于模式已启用。
现行规则仍以根 AGENTS.md、[迭代交付](delivery.md)、[开发门禁](governance.md) 和已采纳 gitlink 为准。
本分支已实施 CLI 同步/恢复及源码外证据基础,自检依据见任务主记录及固定后记录;通用条文和实施计划已经各自 PR 合入,本候选组合固定实际合入版本。用户已于 2026-10-14 明确授权当前智能体接管 #489/#488/#486,完成平台验证、试行、实际合入与清理。平台所有者操作已实际回读,隔离正反例及独立重放已完成;本轮使用单一智能体协调的 manual 窗口,原 CI 触发和全部必需作业保持,未建设自动调度。
状态:active;执行方式:manual。目标是让多个分支并行开发,同时避免所有分支随着
`main` 持续移动而反复同步、解决冲突和重跑最终 CI。
## 通用规范与项目职责
本规则只安排 Dev `refs/heads/main` 的最终交付顺序。各任务的需求、授权、阶段结论和证据
仍保存在原任务卡;受保护分支的最终准入仍由 Gitea 执行。
通用正文的唯一候选来源是 Development-Guidelines 的 `standards/merge-queue.md`,
配套模板为该仓 `templates/merge-queue.md`,候选内容版本 `0.1.0-draft.11`。
本仓仅细化目标、入口、阶段、预算和采纳条件,不复制通用调度正文。
通用候选已经 [Guidelines PR #5](https://git-home.u1680832.nyat.app:30319/weichen/Development-Guidelines/pulls/5) 合入为 `2f5b19872be2a4597838a4131ad75d1be089ee5e`;本候选 `deps/development-guidelines` 已物化该固定版本及上述文件。Dev 已经 #519/#536 固定采纳并通过其精确 HEAD 的全部必需 CI。平台机制与手动写者覆盖的实际依据见 #518;受控试行的当前候选仍须独立组合测试、当前 HEAD CI 与平台准入,不把已采纳规范标成自动 enforced。
## 队列与职责
Dev 目标身份使用实际托管实例、仓库 ID 与 `refs/heads/main`;其他组件仓各有自己的目标队列。
跨仓组合按任务中的依赖关系先交付组件,再固定父仓组合;保留其他任务已经合入的 gitlink 能力。
队列仅安排工作,受保护分支最终准入继续由 Gitea 或等价受信入口负责。
- Dev main 使用一份[合入队列 #518](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/issues/518)。
- 队列只记录顺序和当前窗口:任务/PR、owner、依赖、就绪状态、当前 base/head、CI、合入结果。
- 协调智能体维护顺序和窗口;任务 owner 负责分支内容和必要整改;窗口内的同步、冲突处置、Review、当前 HEAD CI 核验和所有 PR 合入由智能体执行。
- 普通功能 PR、修复 PR 和任务卡闭合 PR 都会移动 main,因此使用同一队列。组件仓按各自目标分支单独安排。
## 项目调度记录
入队表示内容基本就绪且已取得该任务所需授权,不要求分支已经包含不断移动的最新 main。
依赖未完成或仍待人工决定的任务标为 `blocked`,不占窗口。默认按就绪顺序处理;紧急插队须在
#518 写明原因。
采纳后的首版使用每个目标一份 Gitea 调度 Issue,由一个明确协调者维护,标为 manual。
任务卡引用 Issue 条目及尝试标识,原任务仍是授权、需求和阶段的状态权威。
共享记录已建立:[Dev main 调度记录 #518](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/issues/518),包含实际托管实例、仓库 ID 和目标引用。用户已指定当前交付智能体负责后续人工协调,weichen 保留阶段评审和平台保护操作;具体任期与接管约束以该记录为准。
本轮仅协调已获授权的三个真实任务,入队/阻塞/当前窗口及时维护于 #518。普通、闭合、机器人与紧急通道都必须经智能体和同一目标窗口;owner-only 合入账号与无自动合入 workflow/活跃 webhook 的当场清单另留 SELF 收据。用户不亲手合入;API 不具备区分人工与智能体共用账号的身份隔离,协调能力保持 manual。
## 开发与等待
必须记录目标、条目、执行者、源 HEAD、依赖、顺序、尝试、最终 base/head、组件清单、
检查定义及相关配置、实际 CI 链接/身份、Review 对象、真实合入结果和暂停/恢复原因。
平台结果先保存于调度记录;本地命令仍用 `make evidence` 保存完整 SELF 收据。
候选固定后新增命令收据使用源码之外的唯一绝对 `RECEIPT` 路径,不立即提交进被测分支。
收据之后按已授权闭合流程原样导入;不能手抄、补写或自行提升来源。
等待中的分支继续独立开发、提交和运行必要的局部检查,但不因为别的任务合入 main 就自动:
候选固定前完成任务卡、必要收据和其他需提交内容,再推送最终 HEAD。
固定后如果必须提交新内容,即使是任务卡,也建立新尝试并核验新 HEAD 的必需 CI;不复用旧提交状态放行。
合入 SHA 和清理结果在实际发生后回填,不能提前将未来合入或清理标为已完成。
闭合 PR 进入同一队列;可在授权范围内批量闭合已合入任务,保留每个真实主 PR 和结果。
- 合并最新 main;
- 解决与最终交付无关的冲突;
- 推送新的最终候选;
- 重跑完整的最终 PR CI。
## 启动与同步要求
main 前进时,等待任务只记录“待轮到后同步”。开发期检查可以按任务需要运行,但不能作为之后
新 HEAD 的最终准入证据。
根 AGENTS.md、`policy/governance.md` 与 `scripts/task.sh` 的联合入口已随 #519 合入。启用状态仍须核对 #518;新任务严格 READY,既有任务只在明确选择 local 时允许已知落后,正式交付仍真实同步。
## 最终交付窗口
| 场景 | 拟采用动作 | 当前实现边界 |
|---|---|---|
| 新任务启动 | 保留唯一 precheck 收据与 `task-start`,从最新权威 main 建独立工作区 | 现有命令保留 |
| 恢复落后任务的局部工作 | 核对现场、授权和落后状态,记录待最终同步;允许已授权局部编辑 | 已实现显式 local/LOCAL_READY 及 task-resume;默认仍严格,本轮在授权试行范围启用 |
| 其他任务合入、排队等待 | 更新落后/依赖提示,不自动同步各分支或触发完整 CI | 无自动调度能力,不能宣称已接线 |
| 领取最终交付窗口 | 成功 fetch,运行 `make task-sync`,实际合并最新 main、处置冲突、物化固定组件 | 使用真实同步及祖先检查,保留脏状态/远端失败拒绝 |
| 窗口内再次到达验收、审核、合入节点 | 再查最新目标和候选;未变只核验,不创建元数据提交 | 已实现同基线不提交,保留组件核对和完整最终 SHA |
| base/head、组件或相关配置变化 | 保留旧结果对象范围,重新建立尝试并按影响补测/复审 | 当前 HEAD 的全部必需 CI 仍必须实际通过 |
同一时间只给一个 Dev main 任务最终窗口。领取窗口前,协调智能体确认前一窗口已经释放、依赖
已完成,并在 #518 记录任务、PR、owner、当前 source HEAD 和领取时间。
SYNC_REQUIRED 不整体降级为 READY;local 与 delivery 用独立结果和验证用途区分。
已同步启动收据验证、恢复入口及回归,写守卫保留原分支检查,不宣称已校验阶段批准。
共享工作区忙、未知远端和活动 Git 操作继续拒绝相应写入。
窗口内按以下顺序执行:
幂等化的判据是:成功 fetch、目标已包含、递归组件检查通过时,重复核验不改变 HEAD、索引和提交内容;
仍产生可复核的实际输出。任务真正需要纳入新目标时保留 merge 与对应对象记录。
所有候选身份使用必要记录提交完成后的最终 HEAD,不能把记录提交前的 SHA 当作最终被测对象。
1. 运行一次 `make task-sync`,纳入当时最新的 `origin/main`,解决冲突并核对组件组合。
2. 完成需要进入候选的代码、文档和任务卡内容,提交后固定 `base_sha` 与最终 `head_sha`。
3. 运行任务要求的本地检查;固定后的命令收据保存在源码外,避免为了记结果再改变 HEAD。
4. 对固定 PR HEAD 运行现有完整 CI;当前必需作业为 `test`、`test-pg`、`svc-realcombo`。
5. 完成当前 HEAD 的人工阶段结论和智能体 Review。合入前再次核对 main、head、冲突和三项 CI。
6. 智能体使用固定 `head_commit_id`、`force_merge=false` 经 PR 合入,回读实际 main 与候选内容一致后释放窗口。
## 交付阶段与窗口
同一 base/head 下,验收、Review 和合入前的 `make task-sync` 只核验,输出 `changed=0`,不创建
额外提交,也不重复仍然有效的完整 CI。
人工评审和独立 Review 要求不变;提前审阅可以并行,最终有效性核验和受影响重放在窗口内完成。
入队与领取不构成人工批准,也不授权跳过缺失阶段。
## 对象变化与失败
| 原阶段 | 采纳后的调度衔接 |
|---|---|
| 1–6 需求、设计、计划、实施 | 各任务并行推进,在既有授权内局部检查;内容就绪时登记交付条目 |
| 7 正式测试与验收 | 领取窗口后实际同步最新 main,固定候选,完成正式验证并取得必要人工结论 |
| 8 代码 Review | 对同一固定候选核验有效 Review、独立重放和问题闭合;同步或整改后按影响复审 |
| 9 合入 | 保持窗口,核验最新 base/head、全部必需 CI 和平台准入,经 PR 合入并查询真实结果 |
| 10 核验与收口 | 核对合入内容和组件后释放窗口;部署、清理另按原授权,闭合 PR 单独排队 |
以下任一对象变化都会使旧的最终结果失效:目标 base、候选 HEAD、组件 pin、检查定义或与结果
相关的环境。受影响任务保留旧结果的适用对象,回到队列等待新窗口;不得复制旧绿色状态或为了
省 CI 而降低检查。
等待人工结论超过预算时进入 blocked 并安全释放窗口;再次领取时判断原结论及证据是否仍适用。
窗口不得长期持有等待新的需求或方案批准;必要前置安排应在入队时说明。
普通失败和需要决策只阻塞当前条目及依赖项;未知合入结果则保持该目标在 reconciling,先对账。
冲突、代码/Review/CI 失败或等待人工决定时,当前任务保存现场、标为 `blocked` 并释放窗口,
其它分支继续局部开发。合入请求超时或返回不明时,先查询 PR 和 main 的真实状态;结果查清前
保持该目标暂停,不重复发送合入请求。
## CI 资源与检查
若窗口之外仍有 main 推进,当前固定候选按对象变化处理。协调智能体记录实际推进和受影响任务,
然后继续按队列安排;不要求为启用本规则证明所有历史写者、所有角色或自动互斥机制已经覆盖。
首版由协调者安排固定候选的顺序;`.gitea/workflows/ci.yaml` 当前行为保持原样。
后续将局部反馈与最终准入的触发和资源安排区分,不通过跳过必需作业返回绿色来节省成本。
当前 workflow 中的 `test`、`test-pg`、`svc-realcombo` 等作业须按最终已批准集合核验;
平台状态上下文的完整名称、运行身份和被测 SHA 从当次实际运行核对,不从历史文档直接照抄。
## 验收标准
建议首轮人工试行预算,待本文件及试行计划获批后使用:
一次排队交付满足以下条件即完成:
- 每个目标一个最终候选;其独立作业可在实际 runner 能力内并行。
- 最大无进展时间 20 分钟,单次窗口预算 90 分钟;有效进展、延长及原因记入尝试。
- 已知基础设施失败最多额外重试一次;代码失败直接退出整改,不自动重复跑完整 CI。
- 等待队列每 30 分钟复查一次;最终准入优先,但不无限延后其他目标、main 验证或发布检查。
- 当场读取 home-runner 配置 capacity=12、timeout=3h(SELF);试行每个目标最多一个最终候选,候选内三作业按现有 runner 安排。配置容量不证明抢占/优先级或可用空槽,不调整全局平台。
- 等待任务没有仅为追 main 而反复生成最终候选或重跑最终 CI;
- 轮到的任务只在窗口内同步最新 main,冲突在固定候选前解决;
- 实际合入的 HEAD 拥有当前必需 CI 和有效 Review;
- 合入由智能体执行,实际 main 内容与受审候选一致;
- 窗口结果写回 #518,随后才处理下一任务。
预算是试行参数,长期值由真实耗时和等待情况复盘决定。
容量不足时先等待 runner,再领取目标窗口;不得持有另一个仓库窗口去等待组件窗口。
取消仅针对失效且归属明确的当前尝试,其他任务有效的局部检查继续运行。
## 平台与接管边界
现有 [平台准入所有者操作](platform-admission.md) 是历史准备材料,执行前仍需当场重验。
最新只读配置观察、命令和对象由本任务 evidence 收据保存;配置读取属于 SELF,不证明拒绝路径已经有效。
本草案不修改分支保护、批准数或白名单。
人工协调期间,全部正常合入者按窗口约定行动。没有执行端覆盖全部写入者及原子准入的正负例时,能力上限为 manual。
发现外部目标推进,立即判定本轮候选失效,保留结果并建立新尝试;不能以目标锁的本地存在绕过平台拒绝。
自动模式未来必须具备原子持久领取和合入入口的旧执行者拒绝;租约到期不直接授权接管。
合入请求超时先核对 PR 和目标真实状态,旧执行者退出后才能安全重试或释放。
需要调整平台保护时,由 Agent 准备具体配置、回退和隔离验证命令,用户按根 AGENTS.md 双钥匙要求确认并亲手执行。
至少验证合法候选可合入,以及缺检查、失败检查、陈旧候选和无权限推进被拒。
人工首版验证旧执行者确认退出后交接及未知结果对账;自动接管启用前另须验证实际合入入口拒绝旧代次。
本轮隔离机制实测已完成:缺检查/受控失败、陈旧绿色候选、匿名请求及已认证绿色候选直推被拒,合法固定候选由智能体合入;原始 SELF 收据与独立 REPLAY 身份见 #518。夹具 CI 只验证机制,不替代 Dev 的完整业务 CI;所有认证角色、自动互斥和旧代次 fencing 未验证,也不宣称具备。
## 采纳顺序与停止条件
1. 人工审阅通用候选、Dev 条文及记录模板;确认同步时机、阶段衔接、预算和恢复边界。
2. 在通用仓完成候选修订 Review 与其文档验证,按授权经 PR 合入;宿主采纳完整固定 SHA。
3. 另行提交实施计划,覆盖 task-sync 幂等化、恢复/写守卫兼容、源码外收据、CI 分配和能力矩阵。
4. 在隔离场景完成治理脚本正负例;涉及脚本必须运行 `make test-governance`。
5. 跨仓 gitlink 正式推进前运行 Dev `make test`;最终对象的 CI/构建及平台准入遵循现行规则。
6. 完成所有者平台操作和对应验证,登记真实协调者与调度 Issue,才切换新交付模式。
7. 用三个就绪任务试行,记录同步轮数、CI 次数/耗时、废弃运行、排队和恢复,复盘后决定扩大。
第 2 步规范文本合入或第 5 步指针推进均不自动将新模式标 active/enforced。
迁移期保留现行准入规则,直到新条文、命令和平台条件一致;未满足时暂停模式切换,已授权局部开发可继续。
回退先停止新领取并对账活动合入,再恢复旧同步流程,保留尝试和证据历史。
| 验收 ID | 需验证的行为 | 本轮状态 |
|---|---|---|
| MQ1 | A/B/C 正常顺序交付;各任务轮到后最终同步一次,无外部推进时不因其他正常合入反复跑最终 CI | NOT_RUN(后续隔离试行) |
| MQ2 | 同目标重复领取被拒,不同目标可在资源预算内并行 | NOT_RUN(后续机制验证) |
| MQ3 | 同步后目标未变,重复核验不改 HEAD;固定后记录不改变候选 | 本地 CLI/证据回归已执行(SELF,原始输出见任务卡);平台试行 NOT_RUN |
| MQ4 | 新 HEAD、base 或组合不能使用旧 CI/Review 直接放行 | NOT_RUN(后续正负例) |
| MQ5 | 冲突、代码失败、等待人工决定超时释放窗口,不阻塞无依赖任务 | NOT_RUN(后续试行) |
| MQ6 | 紧急插队有依据,受影响候选失效;跨仓依赖和组件能力不回退 | NOT_RUN(后续试行) |
| MQ7 | 请求超时、协调者退出先对账;不能仅因租约过期让旧新执行者同时合入 | NOT_RUN(后续恢复验证) |
| MQ8 | 平台拒绝未满足准入者,合法固定候选可合入;manual 能力如实登记,自动接管另验证旧代次拒绝 | 隔离机制已实测并独立重放;Dev 精确 CI 另核验。manual,所有角色及自动 fencing NOT_RUN |
| MQ9 | 文档链接、入口、版本、单一来源、状态与模板字段一致 | 本轮执行;原始输出见任务卡 SELF 收据 |
这些验收 ID 用于实施与验收追溯;本分支已完成可独立实施的 CLI/证据基础、规则与模板准备及临时真实 Git 场景自检。固定规范已采纳,平台机制实测已独立重放,当前执行 manual 受控试行;真实三任务及复盘仍待完成,未执行项保持 NOT_RUN。动态进展以 #518 和各任务/PR 为准,原始命令由工具收据保存。
本规则不建设自动优先级、合并列车、批量预测合并、原子租约、旧执行者 fencing,也不修改
分支保护、CI workflow 或 runner 配置。将来需要自动队列时另行设计;不得从本 manual 流程推断
这些能力已经存在。
+1 -1
View File
@@ -16,7 +16,7 @@
| A7 证据 | 本地 SELF 收据;外部来源独立核验 | 首次捕获、完整脱敏输出/摘要、对象记录、原样导入 | `tests/test_evidence.py`;CI/审阅链接另核 |
| A8 阶段评审与迭代 | manual(人工/Agent 执行);前序人工决策,智能体 Review/合入;模板和提示不构成批准身份校验或全流程阻断 | policy/delivery.md;任务卡分别记录阶段结论、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对模板及钩子不自动写入;智能体核对 Review、冲突及匹配 HEAD 的 CI/平台准入;人工批准范围仍须核验 |
| A9 长期方向与后续任务 | manual;模板不校验规划有效性或自动调度任务 | policy/delivery.md;PROJECT.md 提供规划入口,任务卡关联规划/里程碑并记录下一步建议 | `tests/test_task.py` 核对生成字段与未授权默认值;人工核对当前规划、进展、重复任务和建议依据 |
| A10 交付窗口 | manual 受控试行;无自动互斥或接管 | policy/merge-queue.md;Issue #518 与协调者已登记,本候选固定实际合入规范版本;用户授权本轮三个任务并指定单一协调者;已实测平台机制及当前写者清单,当前候选组合与最终准入另核验 | tests/test_merge_queue_trial.py 临时 Git 场景 SELF;平台机制独立 REPLAY 已完成(范围见 Issue518);真实三任务及复盘待完成 |
| A10 交付窗口 | manual;无自动互斥或接管 | [合入调度](merge-queue.md)与 #518:同一目标一个最终窗口,等待分支不追 main;轮到后同步、固定、验证并由智能体合入 | 每个实际 PR 核对窗口记录、精确 HEAD 三项 GATE、Review 和合入后 main;不要求额外试行或全写者审计 |
| A11 固定后证据 | manual 选择源码外位置;工具捕获绝对未用路径/原始输出;无 OS 冻结 | policy/governance.md;RECEIPT 捕获,后续授权闭合再原样导入 | `tests/test_evidence.py` 固定对象、权限、覆盖拒绝、原样导入与幂等 |
## 失败处理
+636 -24
View File
@@ -41,6 +41,9 @@
# --from rdm[@<sha>] 下游组合仓 Agent-System-RDM(独立 RDM 源仓物化 pins/ 后
# make build 出 agent-system-rdm 组合二进制;web/ 工作台静态
# 资产随环境落 htdocs/,供伴生 static 服务 --dir 使用)
# --with id[,id…] 把登记表里的插件与基座编成一个二进制;id@<sha> 钉提交
# --with none 明确不编入插件。无 --with 且在终端上时交互勾选;
# 非交互则沿用上次 plugins=。与 --from rdm/release 互斥
# --pick [dev|release|rdm] 交互式编号选提交;--dry-run 只打印部署计划
#
# ════════════════════ 常用参数(服务级)════════════════════
@@ -164,7 +167,7 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
常用:
scripts/svc.sh list [--tag T] 服务总览
scripts/svc.sh info [name] [--tag T --reveal] 综合报告(只读):服务/数据库/配置快照
scripts/svc.sh <name> deploy [--from dev@<sha>] 部署并启动(缺省=远端 main 最新)
scripts/svc.sh <name> deploy [--from dev@<sha>] [--with id] 部署并启动(可把插件编进二进制)
scripts/svc.sh <name> start|stop|restart|logs [N] 生命周期与日志
scripts/svc.sh <name> status 服务详情(版本/来源/DSN 等)
scripts/svc.sh <name> db [pg名|--clear] agent 数据库绑定/切换(缺省查看)
@@ -184,13 +187,13 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
scripts/svc.sh <name> rdm-admin [show] RDM 域管理员台账查看(含基座用户核验)
scripts/svc.sh <name> rdm-admin transfer --to <id> RDM 域管理员受信移交(锁死恢复通道)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle accounts db dbops config recovery examples safety)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy plugins lifecycle accounts db dbops config recovery examples safety)
文档:docs/svc.md
EOF
}
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
HELP_TOPICS="model deploy lifecycle accounts rdm-admin db dbops config recovery examples safety"
HELP_TOPICS="model deploy plugins lifecycle accounts rdm-admin db dbops config recovery examples safety"
cmd_help() {
local topic="${1:-}"
@@ -201,6 +204,7 @@ svc.sh 分主题帮助(scripts/svc.sh help <主题>;完整 --help;文档 d
model 服务模型:命名、目录布局、svc.env 字段、history 事件、pidcheck
deploy 部署:三来源(local/dev@sha/release@v)、--pick、--dry-run、static --dir
plugins 编译插件:--with、交互勾选、登记表、激活与换集合门禁
lifecycle 生命周期:start/stop/restart/logs/status、set 变更、rm 注销
accounts 账户 CLI:create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin
rdm-admin RDM 域管理员台账:查看 / 受信移交(锁死恢复通道)
@@ -247,7 +251,9 @@ svc.env 字段:
addr 监听地址 host:port(持久化——下次 start 复用)
tags 标签(逗号分隔)
source 部署来源(local@<sha> | dev@<sha> | release@<版本> | dir@<路径> |
postgres@local | clone@<名>)
postgres@local | clone@<名>)。编入插件时在基座标签后追加
+<id>@<短提交>(如 dev@abc+rdm@def)
plugins 本次编入的编译插件(id@完整提交,逗号分隔)。update 原样复用,不再询问
version 产物版本串(agent-system version 输出)
started 最近启动时间
pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory)
@@ -307,10 +313,15 @@ EOF
htdocs/,并自动部署伴生 <name>web(--kind static --dir)。
注意:rdm 源不支持 --mock 与 --config;模型设置播种
(--model-*/--tavily-keys)仅基座产物支持。
--with id[,id…] 把 plugins/registry.json 里的插件与本次基座编成一个二进制。
id@<sha> 钉住该插件提交;--with none 明确不编入。
终端上没给 --with 时交互勾选(回车沿用上次,none 清空);
非交互则沿用 svc.env 的 plugins=。详见 help plugins。
--pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr)
--dry-run 只打印部署计划不执行
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local)须显式确认;
同种类换 sha(含 update)不需要
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local),或已有服务的
插件 id 集合发生变化时须显式确认。同种类只换 sha、首次部署、
update 沿用已记录的 plugins= 都不需要
行为细节:
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
@@ -330,6 +341,43 @@ EOF
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
scripts/svc.sh dev deploy --from local --with rdm
EOF
return 0 ;;
plugins)
cat >&2 <<'EOF'
【plugins】把登记插件与基座编成一个二进制
登记表:plugins/registry.json(Dev 仓只保存登记,不自带插件源码,也不自带基座 pin)。
每条至少有 id、name、repo、goModule、entryPackage。可选 repoEnv(环境变量覆盖
克隆地址,如 RDM 的 SVC_RDM_ORIGIN)、defaultRef、commandPrefix、web(静态工作台
相对目录)。第一版最多一个带 web 的插件。
选择:
--with rdm,foo 按 id 选择,提交用登记表的 defaultRef
--with rdm@<sha> 钉住该插件的提交
--with none 明确不编入
终端且未给 --with 编号勾选。回车沿用 svc.env 的 plugins=,none 清空,首次默认不选
非交互且未给 --with 沿用 plugins=;没有则不编入(现有脚本与 CI 行为不变)
SVC_FORCE_PLUGIN_PROMPT=1 无终端时也走勾选(测试用)
构建(仅 --from local 或 --from dev;与 --from rdm / --from release 互斥):
在隔离副本里生成 cmd/agent-system/zz_plugins_gen.go(空白导入 entryPackage),
go mod edit 增加 require 与 replace,再 CGO_ENABLED=0 go build -mod=mod。
产物仍叫 agent-system,不改主检出的 go.mod。对不上基座就构建失败并留下原因。
旧 --from rdm 仍用 RDM 仓自己的 pins/ 构建,不走本路径。
部署后:
用 /api/plugins、/api/registry 的 compiled 和 /api/commands 确认每个所选插件已注册
且命令面启用。被禁用时,认证开启则用临时管理员 key 显式启用。任一失败走 release 回退。
svc.env 与 release 清单记下 plugins=。source 形如 dev@<基座短提交>+rdm@<插件短提交>。
已有服务要改插件 id 集合,必须加 --allow-source-switch。update / recover 复用记录,不再询问。
带 web 的插件复用伴生 <name>web(最多一个)。
示例:
scripts/svc.sh dev deploy --from dev@73b2802 --with rdm --dry-run
scripts/svc.sh dev deploy --from local --with rdm@<sha>
scripts/svc.sh dev deploy --with none --allow-source-switch
EOF
return 0 ;;
lifecycle)
@@ -722,7 +770,7 @@ CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N=""
CLI_MBASE=""; CLI_MKEY=""; CLI_MNAME=""
CLI_LBASE=""; CLI_LKEY=""; CLI_LNAME=""; CLI_TAVILY=""
CLI_SEED_OWNER=""
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0; CLI_WITH=""; CLI_WITH_SET=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
@@ -752,6 +800,7 @@ parse_args() {
--tavily-keys) need_value "$@"; CLI_TAVILY="$2"; shift 2 ;;
--seed-owner) need_value "$@"; CLI_SEED_OWNER="$2"; shift 2 ;;
--from) need_value "$@"; CLI_FROM="$2"; shift 2 ;;
--with) need_value "$@"; CLI_WITH="$2"; CLI_WITH_SET=1; shift 2 ;;
--pick) CLI_PICK="dev"
case "${2:-}" in dev|release|rdm) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;;
--dry-run) CLI_DRYRUN=1; shift ;;
@@ -889,7 +938,7 @@ svc_merge_tags() {
# agent 服务经统一解析启动后按生效态写 db=(pg/mock;显式 sqlite)与 dbref=/dsn=
# (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。
record_state() {
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_envfile merged_tags
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_envfile prev_plugins plugins_out merged_tags
pid="$(read_pid)"
prev_src=""
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
@@ -898,6 +947,13 @@ record_state() {
prev_db="$(svc_get db)"
prev_dsn="$(svc_get dsn)"
prev_envfile="$(svc_get envfile)"
prev_plugins="$(svc_get plugins)"
plugins_out="$prev_plugins"
# 只有激活校验和 UI 探活都通过之后才提交新的 plugins=。中途 record_state
# (含回退拉起旧制品)继续沿用上一份,避免旧二进制被登记成新插件集合。
if [ "${COMPOSE_PLUGINS_COMMIT:-0}" = 1 ]; then
plugins_out="${COMPOSE_PLUGINS_KV:-}"
fi
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
merged_tags="$(svc_merge_tags)"
local db_out="" dbref_out="" dsn_out=""
@@ -940,6 +996,7 @@ record_state() {
elif [ -n "$prev_envfile" ]; then
printf 'envfile=%s\n' "$prev_envfile"
fi
[ -n "$plugins_out" ] && printf 'plugins=%s\n' "$plugins_out"
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$tmp"
chmod 600 "$tmp"
@@ -2095,8 +2152,8 @@ svc_release_stage() {
if [ -n "$htdocs" ] && [ -d "$htdocs" ]; then
cp -r "$htdocs" "$dir/htdocs" || return 1
fi
printf '{"id":"%s","source":"%s","created":"%s"}\n' \
"$id" "${SRC_LABEL:-}" "$(date -u +%FT%TZ)" > "$dir/release.json"
printf '{"id":"%s","source":"%s","created":"%s","plugins":"%s"}\n' \
"$id" "${SRC_LABEL:-}" "$(date -u +%FT%TZ)" "${COMPOSE_PLUGINS_KV:-}" > "$dir/release.json"
printf '%s' "$dir"
}
@@ -2284,6 +2341,11 @@ svc_deploy_journal_resolve() {
# svc_deploy_recycle_wt:deploy 各失败/成功出口统一回收构建 worktree(尽力而为)。
svc_deploy_recycle_wt() {
compose_recycle || true
if [ -n "${COMPOSE_LOCAL_DIR:-}" ]; then
rm -rf "$COMPOSE_LOCAL_DIR"
COMPOSE_LOCAL_DIR=""
fi
case "${SRC_KINDSRC:-}" in
dev) [ -n "${wt:-}" ] && recycle_worktree "$SRC_SHA" ;;
rdm) [ -n "${wt:-}" ] && recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR" ;;
@@ -2579,6 +2641,436 @@ agent_do_rdm_admin() {
return 0
}
# ---- 编译插件组合(--with)----
# 登记表 plugins/registry.json。选中的插件与本次 Dev 基座编成一个 agent-system。
# plugins= 记 id@完整提交;source 标签追加 +id@短提交。SVC_COMPOSE_STUB=1 只给测试跳过 go build。
plugin_ids_from_kv() {
python3 -c 'import sys
raw = sys.argv[1].strip()
ids = []
for part in raw.split(","):
part = part.strip()
if not part or part == "none":
continue
ids.append(part.split("@", 1)[0])
print(",".join(sorted(set(ids))))' "${1:-}"
}
plugin_registry_text() {
case "${SRC_KINDSRC:-local}" in
dev)
git -C "$SRC_DIR" show "$SRC_FULL:plugins/registry.json" 2>/dev/null || true
;;
*)
if [ -f "$ROOT/plugins/registry.json" ]; then
cat "$ROOT/plugins/registry.json"
fi
;;
esac
}
plugin_cache_root() {
printf '%s' "${SVC_PLUGIN_SRC_ROOT:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc}"
}
# plugin_src_ready ID URL → stdout 源仓路径。
plugin_src_ready() {
local id="$1" url="$2" dir
dir="$(plugin_cache_root)/plugin-$id"
if [ ! -d "$dir/.git" ]; then
c_info "[$ENV_NAME] 初始化插件源仓 $id:$dir"
mkdir -p "$(plugin_cache_root)"
git clone --quiet "$url" "$dir" >&2 || { c_err "[$ENV_NAME] 插件 $id 克隆失败:$url"; return 1; }
fi
if [ "${CLI_NOFETCH:-0}" != 1 ]; then
git -C "$dir" fetch origin --quiet >&2 || c_warn "[$ENV_NAME] 插件 $id fetch 失败——使用本地缓存($dir)"
fi
printf '%s' "$dir"
}
# resolve_plugins:根据 --with / 交互 / 上次 plugins= 填 COMPOSE_*。
# 不物化 worktree(dry-run 也要解析提交)。失败 return 1。
resolve_plugins() {
COMPOSE_PLUGINS_SET=0
COMPOSE_PLUGINS_KV=""
COMPOSE_LABEL_SUFFIX=""
COMPOSE_WEB_ID=""
COMPOSE_WEB_REL=""
COMPOSE_SPEC=""
: > "${COMPOSE_TSV:-/dev/null}" 2>/dev/null || true
COMPOSE_TSV=""
case "${SRC_KINDSRC:-}" in
release|rdm)
if [ "${CLI_WITH_SET:-0}" = 1 ] && [ "$CLI_WITH" != none ]; then
c_err "[$ENV_NAME] --from $SRC_KINDSRC 不能与 --with 一起用(release 是成品;rdm 源用组合仓自己的 pins。要选插件请用 --from dev 或 --from local)"
return 1
fi
if [ "${CLI_WITH:-}" = none ]; then
COMPOSE_PLUGINS_SET=1
COMPOSE_PLUGINS_KV=""
fi
return 0
;;
esac
local reg prev choice mode="reuse"
prev="$(svc_get plugins)"
if [ "${CLI_WITH_SET:-0}" = 1 ]; then
mode="explicit"
choice="$CLI_WITH"
elif [ -t 0 ] || [ "${SVC_FORCE_PLUGIN_PROMPT:-}" = 1 ]; then
mode="prompt"
else
choice="$prev"
fi
reg="$(plugin_registry_text)"
if [ "$mode" = prompt ]; then
if [ -z "$reg" ]; then
c_info "[$ENV_NAME] 没有插件登记表,本次不编入插件"
choice="none"
else
python3 -c 'import json,sys
reg=json.loads(sys.argv[1] or "{\"plugins\":[]}")
prev=set()
for part in (sys.argv[2] or "").split(","):
part=part.strip()
if part and part!="none":
prev.add(part.split("@",1)[0])
plugins=reg.get("plugins") or []
if not plugins:
print("(登记表是空的)", file=sys.stderr)
sys.exit(0)
print("可选编译插件(* 为上次选择;回车保持,none 清空,序号用逗号分隔):", file=sys.stderr)
for i,p in enumerate(plugins,1):
mark="*" if p.get("id") in prev else " "
print(" %d) [%s] %s %s" % (i, mark, p.get("id"), p.get("name","")), file=sys.stderr)
' "$reg" "$prev" >&2
local sel
printf '选择:' >&2
if ! read -r sel; then
printf '\n' >&2
c_info "[$ENV_NAME] 已取消插件选择"
return 1
fi
choice="$sel"
[ -z "$choice" ] && choice="$prev"
[ -z "$choice" ] && choice="none"
fi
fi
[ "$choice" = none ] && choice=""
if [ -z "$choice" ]; then
COMPOSE_PLUGINS_SET=1
COMPOSE_PLUGINS_KV=""
return 0
fi
if [ -z "$reg" ]; then
c_err "[$ENV_NAME] 未找到 plugins/registry.json,无法解析 --with $choice"
return 1
fi
local parsed
parsed="$(python3 -c 'import json,sys
reg=json.loads(sys.argv[1])
choice=sys.argv[2].strip()
by={p["id"]: p for p in reg.get("plugins") or []}
items=[]
if choice.isdigit() or all(part.strip().isdigit() for part in choice.replace(" ",",").split(",") if part.strip()):
plugins=reg.get("plugins") or []
for part in choice.replace(" ",",").split(","):
part=part.strip()
if not part:
continue
n=int(part)
if n<1 or n>len(plugins):
sys.stderr.write("序号超范围:%s\n"%part)
sys.exit(1)
p=plugins[n-1]
items.append((p, ""))
else:
for part in choice.split(","):
part=part.strip()
if not part or part=="none":
continue
pid, _, ref = part.partition("@")
p=by.get(pid)
if not p:
sys.stderr.write("登记表没有插件:%s\n"%pid)
sys.exit(1)
items.append((p, ref))
seen=set(); webs=[]
for p, ref in items:
if p["id"] in seen:
continue
seen.add(p["id"])
web=(p.get("web") or "").strip()
if web:
webs.append(p["id"])
fields=[p["id"], p.get("repo",""), p.get("repoEnv",""), p.get("defaultRef","origin/main"), ref, p.get("goModule",""), p.get("entryPackage",""), p.get("commandPrefix",""), web]
if any("\t" in str(f) or "\n" in str(f) for f in fields):
sys.stderr.write("插件登记含非法字符:%s\n"%p["id"])
sys.exit(1)
if not p.get("goModule") or not p.get("entryPackage") or not p.get("repo"):
sys.stderr.write("插件登记缺少 repo/goModule/entryPackage:%s\n"%p["id"])
sys.exit(1)
print("\x1f".join(fields))
if len(webs)>1:
sys.stderr.write("第一版只允许一个带 web 工作台的插件:%s\n"%",".join(webs))
sys.exit(1)
' "$reg" "$choice")" || { c_err "[$ENV_NAME] 插件选择无效"; return 1; }
[ -n "$parsed" ] || { COMPOSE_PLUGINS_SET=1; return 0; }
local tsv id repo repo_env def_ref req_ref module entry prefix web url dir full short
tsv="$(mktemp)"
COMPOSE_TSV="$tsv"
local kv="" suffix="" spec="" web_id="" web_rel=""
# 分隔符用 Unit Separator:空的请求提交不能用 Tab,bash read 会把连续空白并成一个分隔。
while IFS=$'\x1f' read -r id repo repo_env def_ref req_ref module entry prefix web; do
[ -n "$id" ] || continue
url="$repo"
if [ -n "$repo_env" ] && [ -n "${!repo_env:-}" ]; then
url="${!repo_env}"
fi
dir="$(plugin_src_ready "$id" "$url")" || return 1
local ref="${req_ref:-$def_ref}"
full="$(git -C "$dir" rev-parse --verify --quiet "${ref}^{commit}")" || {
c_err "[$ENV_NAME] 插件 $id 无此提交:$ref"
return 1
}
short="$(git -C "$dir" rev-parse --short=12 "$full")"
kv="${kv:+$kv,}$id@$full"
suffix="${suffix}+$id@$short"
spec="${spec:+$spec,}$id:$prefix"
if [ -n "$web" ]; then
web_id="$id"
web_rel="$web"
fi
printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$id" "$dir" "$full" "$module" "$entry" "$prefix" "$web" >> "$tsv"
done <<< "$parsed"
COMPOSE_PLUGINS_SET=1
COMPOSE_PLUGINS_KV="$kv"
COMPOSE_LABEL_SUFFIX="$suffix"
COMPOSE_WEB_ID="$web_id"
COMPOSE_WEB_REL="$web_rel"
COMPOSE_SPEC="$spec"
return 0
}
compose_require_switch() {
local prev_ids new_ids prev_src
prev_src="$(svc_get source)"
prev_ids="$(plugin_ids_from_kv "$(svc_get plugins)")"
new_ids="$(plugin_ids_from_kv "${COMPOSE_PLUGINS_KV:-}")"
[ "$prev_ids" = "$new_ids" ] && return 0
# 第一次部署没有旧来源、也没有旧插件集合,选定插件不是“换集合”。
if [ -z "$prev_src" ] && [ -z "$prev_ids" ]; then
return 0
fi
if [ "${CLI_ALLOW_SOURCE_SWITCH:-0}" = 1 ]; then
c_warn "[$ENV_NAME] 插件集合已确认:${prev_ids:-无} → ${new_ids:-无}(--allow-source-switch)"
return 0
fi
c_err "[$ENV_NAME] 插件集合变化 ${prev_ids:-无} → ${new_ids:-无} 需显式确认"
c_err " 加 --allow-source-switch 后重试"
return 1
}
# compose_materialize_plugins → 填充 COMPOSE_READY_TSV(module, entry, abs path, web abs)
compose_materialize_plugins() {
[ -n "${COMPOSE_TSV:-}" ] && [ -s "$COMPOSE_TSV" ] || return 0
COMPOSE_READY_TSV="$(mktemp)"
local id dir full module entry prefix web sha wt
while IFS=$'\x1f' read -r id dir full module entry prefix web; do
[ -n "$id" ] || continue
sha="$(git -C "$dir" rev-parse --short=12 "$full")"
wt="$RUNTIME/.worktrees/plugin-${id}-${sha}"
if [ ! -d "$wt" ]; then
c_info "[$ENV_NAME] 物化插件 $id@$sha"
git -C "$dir" worktree add --detach "$wt" "$full" >/dev/null || return 1
fi
printf '%s\x1f%s\x1f%s\x1f%s\n' "$module" "$entry" "$wt" "$web" >> "$COMPOSE_READY_TSV"
if [ -n "$web" ]; then
COMPOSE_HTDOCS="$wt/$web"
fi
done < "$COMPOSE_TSV"
}
compose_write_entry() { # BACKEND_DIR
local be="$1" gen="$1/cmd/agent-system/zz_plugins_gen.go" line module entry wt web
{
printf '%s\n' '// Code generated by svc.sh --with. DO NOT EDIT.' 'package main' '' 'import ('
while IFS=$'\x1f' read -r module entry wt web; do
printf '\t_ "%s"\n' "$entry"
done < "$COMPOSE_READY_TSV"
printf '%s\n' ')'
} > "$gen"
while IFS=$'\x1f' read -r module entry wt web; do
( cd "$be" && go mod edit -require="${module}@v0.0.0" -replace="${module}=${wt}" ) || return 1
done < "$COMPOSE_READY_TSV"
}
compose_go_build() { # BACKEND_DIR OUT
local be="$1" out="$2"
c_info "[$ENV_NAME] 组合构建(go build -mod=mod,插件与基座同一二进制)"
( cd "$be" && CGO_ENABLED=0 go build -mod=mod -trimpath -o "$out" ./cmd/agent-system ) || return 1
[ -x "$out" ] || { c_err "[$ENV_NAME] 组合产物缺失:$out"; return 1; }
}
# compose_build_into BACKEND_DIR OUT → 0,并可能设置 COMPOSE_HTDOCS。
compose_build_into() {
local be="$1" out="$2"
if [ "${SVC_COMPOSE_STUB:-}" = 1 ]; then
return 0
fi
compose_materialize_plugins || return 1
[ -s "${COMPOSE_READY_TSV:-/dev/null}" ] || { c_err "[$ENV_NAME] 没有可编译的插件"; return 1; }
compose_write_entry "$be" || return 1
compose_go_build "$be" "$out" || return 1
}
compose_recycle() {
local id dir full module entry prefix web sha
[ -n "${COMPOSE_TSV:-}" ] && [ -f "$COMPOSE_TSV" ] || return 0
while IFS=$'\x1f' read -r id dir full module entry prefix web; do
[ -n "$id" ] || continue
sha="$(git -C "$dir" rev-parse --short=12 "$full" 2>/dev/null || true)"
[ -n "$sha" ] || continue
recycle_worktree "plugin-${id}-${sha}" "$dir"
done < "$COMPOSE_TSV"
rm -f "$COMPOSE_TSV" "${COMPOSE_READY_TSV:-}"
COMPOSE_TSV=""
COMPOSE_READY_TSV=""
}
# assert_plugins_active:所选插件已出现在 /api/plugins 或 /api/registry,且命令面启用。
assert_plugins_active() {
[ -n "${COMPOSE_PLUGINS_KV:-}" ] || return 0
local probe host port url code plugins_body commands_body registry_body hdr=()
probe="${ENV_ADDR}"
host="${probe%:*}"; port="${probe##*:}"
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
url="http://$host:$port"
plugins_body="$(mktemp)"; commands_body="$(mktemp)"; registry_body="$(mktemp)"
code="$(curl -s -m 5 -o "$plugins_body" -w '%{http_code}' "$url/api/plugins" || true)"
if [ "$code" = 401 ] || [ "$code" = 403 ]; then
local seed_key="" seed_owner="${CLI_SEED_OWNER:-${SVC_SEED_OWNER:-}}" seed_dbf=()
if db_auth_enabled; then
if [ "$DB_MODE" = pg ]; then seed_dbf=(--dsn "$DB_DSN"); else seed_dbf=(--data "$ENV_DATA"); fi
if [ -z "$seed_owner" ]; then
seed_owner="$("$ENV_BIN" user list ${seed_dbf[@]+"${seed_dbf[@]}"} 2>/dev/null | python3 -c 'import json,sys
try:
users=json.load(sys.stdin)
except Exception:
sys.exit(0)
for u in users:
if u.get("isAdmin"):
print(u["owner"]); break' 2>/dev/null || true)"
fi
if [ -n "$seed_owner" ]; then
seed_key="$("$ENV_BIN" apikey create ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-plugin-activate 2>/dev/null || true)"
[ -n "$seed_key" ] && hdr=(-H "Authorization: Bearer $seed_key")
fi
fi
if [ -z "$seed_key" ]; then
c_err "[$ENV_NAME] 插件激活校验被拒绝(HTTP $code),且无法铸临时管理员 key"
rm -f "$plugins_body" "$commands_body" "$registry_body"
return 1
fi
code="$(curl -s -m 5 -o "$plugins_body" -w '%{http_code}' ${hdr[@]+"${hdr[@]}"} "$url/api/plugins" || true)"
fi
curl -s -m 5 -o "$commands_body" ${hdr[@]+"${hdr[@]}"} "$url/api/commands" || true
curl -s -m 5 -o "$registry_body" ${hdr[@]+"${hdr[@]}"} "$url/api/registry" || true
local report
report="$(python3 - "$plugins_body" "$commands_body" "$registry_body" "${COMPOSE_SPEC:-}" <<'PY'
import json, sys
def load(path):
try:
return json.load(open(path))
except Exception:
return None
plugins, commands, registry, spec = load(sys.argv[1]), load(sys.argv[2]), load(sys.argv[3]), sys.argv[4]
ids=set()
if isinstance(plugins, list):
for p in plugins:
mid=((p or {}).get("manifest") or {}).get("id")
if mid:
ids.add(mid)
compiled=set()
if isinstance(registry, dict):
compiled=set(registry.get("compiled") or [])
if not isinstance(commands, list):
commands=[]
missing=[]; disabled=[]
for item in filter(None, spec.split(",")):
pid, _, prefix = item.partition(":")
if pid not in ids and pid not in compiled:
missing.append(pid)
continue
if not prefix:
continue
matched=[c for c in commands if str(c.get("id","")).startswith(prefix)]
if not matched or not any(c.get("enabled", True) for c in matched):
disabled.append(pid)
print("missing=" + ",".join(missing))
print("disabled=" + ",".join(disabled))
PY
)" || report="missing=parse"
local missing disabled pid
missing="$(printf '%s\n' "$report" | sed -n 's/^missing=//p')"
disabled="$(printf '%s\n' "$report" | sed -n 's/^disabled=//p')"
if [ -n "$disabled" ]; then
local IFS=','
for pid in $disabled; do
curl -s -m 5 -o /dev/null -X POST "$url/api/plugins/$pid/enabled" \
-H 'Content-Type: application/json' ${hdr[@]+"${hdr[@]}"} \
-d '{"enabled":true}' || true
done
unset IFS
curl -s -m 5 -o "$commands_body" ${hdr[@]+"${hdr[@]}"} "$url/api/commands" || true
report="$(python3 - "$plugins_body" "$commands_body" "$registry_body" "${COMPOSE_SPEC:-}" <<'PY'
import json, sys
def load(path):
try:
return json.load(open(path))
except Exception:
return None
plugins, commands, registry, spec = load(sys.argv[1]), load(sys.argv[2]), load(sys.argv[3]), sys.argv[4]
ids=set()
if isinstance(plugins, list):
for p in plugins:
mid=((p or {}).get("manifest") or {}).get("id")
if mid:
ids.add(mid)
compiled=set((registry or {}).get("compiled") or []) if isinstance(registry, dict) else set()
if not isinstance(commands, list):
commands=[]
missing=[]; disabled=[]
for item in filter(None, spec.split(",")):
pid, _, prefix = item.partition(":")
if pid not in ids and pid not in compiled:
missing.append(pid); continue
if not prefix:
continue
matched=[c for c in commands if str(c.get("id","")).startswith(prefix)]
if not matched or not any(c.get("enabled", True) for c in matched):
disabled.append(pid)
print("missing=" + ",".join(missing))
print("disabled=" + ",".join(disabled))
PY
)" || true
missing="$(printf '%s\n' "$report" | sed -n 's/^missing=//p')"
disabled="$(printf '%s\n' "$report" | sed -n 's/^disabled=//p')"
fi
if [ -n "${seed_key:-}" ]; then
"$ENV_BIN" apikey revoke ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-plugin-activate >/dev/null 2>&1 || \
c_warn "[$ENV_NAME] 临时激活密钥回收失败:请手动吊销 owner=$seed_owner name=svc-plugin-activate"
fi
rm -f "$plugins_body" "$commands_body" "$registry_body"
if [ -n "$missing" ] || [ -n "$disabled" ]; then
c_err "[$ENV_NAME] 插件未激活(缺失:${missing:-无};未启用:${disabled:-无})"
return 1
fi
c_ok "[$ENV_NAME] 插件已激活:${COMPOSE_PLUGINS_KV}"
return 0
}
# ---- agent:部署与启动 ----
# source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。
@@ -2651,6 +3143,10 @@ deploy_rdm_companion_ui() {
assert_ui_ready() {
local src="${SRC_LABEL:-$(svc_get source)}"
local code url host port addr cname probe
# 组合部署里带 web 的插件走伴生工作台,source 本身仍是 dev@/local@。
if [ -n "${COMPOSE_WEB_ID:-}" ]; then
src="rdm@${COMPOSE_WEB_ID}"
fi
case "$src" in
rdm@*)
cname="$(rdm_companion_name "$ENV_NAME")"
@@ -2694,11 +3190,31 @@ assert_ui_ready() {
}
agent_do_deploy() {
case "${CLI_FROM:-}" in
rdm|rdm@*|release|release@*)
if [ "${CLI_WITH_SET:-0}" = 1 ] && [ "$CLI_WITH" != none ]; then
c_err "[$ENV_NAME] --from ${CLI_FROM%%@*} 不能与 --with 一起用(release 是成品;rdm 源用组合仓自己的 pins。要选插件请用 --from dev 或 --from local)"
hist_event deploy fail "$(read_pid)" "插件选择与来源互斥"
return 1
fi
;;
esac
resolve_source
if ! resolve_plugins; then
hist_event deploy fail "$(read_pid)" "插件选择失败"
return 1
fi
if [ -n "${COMPOSE_LABEL_SUFFIX:-}" ]; then
SRC_LABEL="${SRC_LABEL}${COMPOSE_LABEL_SUFFIX}"
fi
if ! require_allow_source_switch; then
hist_event deploy fail "$(read_pid)" "换源种类需 --allow-source-switch($SRC_LABEL)"
return 1
fi
if ! compose_require_switch; then
hist_event deploy fail "$(read_pid)" "插件集合变化需 --allow-source-switch"
return 1
fi
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
@@ -2716,17 +3232,62 @@ agent_do_deploy() {
|| { hist_event deploy fail "$(read_pid)" "legacy 领养失败($SRC_LABEL)"; return 1; }
case "$SRC_KINDSRC" in
local)
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
make -C "$ROOT" build || {
c_err "[$ENV_NAME] 构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变"
hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"
return 1
}
srcbin="$BIN"; srcplug="$ROOT/plugins" ;;
if [ -n "${COMPOSE_PLUGINS_KV:-}" ] && [ "${SVC_COMPOSE_STUB:-}" != 1 ]; then
c_info "[$ENV_NAME] 组合构建:先构建前端,再在隔离副本编入插件(不改主检出 go.mod)"
make -C "$ROOT" frontend || {
c_err "[$ENV_NAME] 前端构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变"
hist_event deploy fail "$(read_pid)" "前端构建失败($SRC_LABEL)"
return 1
}
COMPOSE_LOCAL_DIR="$RUNTIME/.worktrees/compose-local-$$"
rm -rf "$COMPOSE_LOCAL_DIR"
mkdir -p "$COMPOSE_LOCAL_DIR/backend" || return 1
if ! rsync -a --exclude .git "$ROOT/modules/backend/" "$COMPOSE_LOCAL_DIR/backend/"; then
c_err "[$ENV_NAME] 无法复制基座副本($SRC_LABEL)"
hist_event deploy fail "$(read_pid)" "基座副本失败($SRC_LABEL)"
svc_deploy_recycle_wt
return 1
fi
if ! compose_build_into "$COMPOSE_LOCAL_DIR/backend" "$COMPOSE_LOCAL_DIR/agent-system"; then
c_err "[$ENV_NAME] 组合构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变"
hist_event deploy fail "$(read_pid)" "组合构建失败($SRC_LABEL)"
svc_deploy_recycle_wt
return 1
fi
srcbin="$COMPOSE_LOCAL_DIR/agent-system"; srcplug="$ROOT/plugins"
[ -n "${COMPOSE_HTDOCS:-}" ] && srchtdocs="$COMPOSE_HTDOCS"
else
if [ -n "${COMPOSE_PLUGINS_KV:-}" ]; then
c_info "[$ENV_NAME] 组合构建桩(SVC_COMPOSE_STUB=1,不执行 go build)"
else
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
fi
make -C "$ROOT" build || {
c_err "[$ENV_NAME] 构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变"
hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"
return 1
}
srcbin="$BIN"; srcplug="$ROOT/plugins"
fi
;;
dev)
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
srcbin="$wt/bin/agent-system"; srcplug="$wt/plugins" ;;
if [ -n "${COMPOSE_PLUGINS_KV:-}" ] && [ "${SVC_COMPOSE_STUB:-}" != 1 ]; then
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \
|| { hist_event deploy fail "$(read_pid)" "物化/前端构建失败($SRC_LABEL)"; return 1; }
mkdir -p "$wt/bin"
if ! compose_build_into "$wt/modules/backend" "$wt/bin/agent-system"; then
hist_event deploy fail "$(read_pid)" "组合构建失败($SRC_LABEL)"
svc_deploy_recycle_wt
return 1
fi
srcbin="$wt/bin/agent-system"; srcplug="$wt/plugins"
[ -n "${COMPOSE_HTDOCS:-}" ] && srchtdocs="$COMPOSE_HTDOCS"
else
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
srcbin="$wt/bin/agent-system"; srcplug="$wt/plugins"
fi
;;
release)
local relbin="$REL_ROOT/build/$REL_VERSION/agent-system"
if [ ! -f "$relbin" ]; then
@@ -2795,6 +3356,11 @@ agent_do_deploy() {
hist_event deploy fail "$(read_pid)" "启动失败已回退($SRC_LABEL)"
return 1
fi
if ! assert_plugins_active; then
svc_rollback_deploy "插件未激活($SRC_LABEL)" || true
hist_event deploy fail "$(read_pid)" "插件未激活(已回退)"
return 1
fi
case "$SRC_KINDSRC" in
rdm)
# 顶层 htdocs → current/htdocs 符号链接:伴生经它伺服,指针切换自动换内容
@@ -2817,6 +3383,23 @@ agent_do_deploy() {
fi
;;
esac
if [ -n "${COMPOSE_WEB_ID:-}" ] && [ "$SRC_KINDSRC" != rdm ]; then
if [ ! -f "${COMPOSE_HTDOCS:-}/index.html" ]; then
svc_rollback_deploy "插件 $COMPOSE_WEB_ID 无 web 工作台(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(插件无 web/,已回退)"
return 1
fi
if [ ! -L "$ENV_DIR/htdocs" ]; then
rm -rf "$ENV_DIR/htdocs"
ln -s current/htdocs "$ENV_DIR/htdocs"
fi
c_ok "[$ENV_NAME] 插件 $COMPOSE_WEB_ID 工作台就位:releases/$relid/htdocs → $ENV_DIR/htdocs(伴生 $(rdm_companion_name "$ENV_NAME"))"
if ! deploy_rdm_companion_ui "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR/htdocs"; then
svc_rollback_deploy "插件伴生工作台部署失败(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(伴生部署失败,已回退)"
return 1
fi
fi
if ! assert_ui_ready; then
svc_rollback_deploy "UI 探活失败(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(已回退)"
@@ -2830,6 +3413,14 @@ agent_do_deploy() {
hist_event deploy fail "$(read_pid)" "manifest 领养失败(已回退)"
return 1
fi
COMPOSE_PLUGINS_COMMIT=1
if ! record_state; then
COMPOSE_PLUGINS_COMMIT=0
SRC_LABEL=""
svc_rollback_deploy "插件登记写入失败($SRC_LABEL)" || true
hist_event deploy fail "$(read_pid)" "插件登记写入失败(已回退)"
return 1
fi
svc_release_commit_schema "$relid"
svc_journal_clear
svc_release_gc
@@ -2861,6 +3452,16 @@ print_agent_plan() {
printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \
"$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;;
esac
if [ -n "${COMPOSE_PLUGINS_KV:-}" ]; then
printf ' 插件:%s\n' "$COMPOSE_PLUGINS_KV"
printf ' 构建:基座副本生成 zz_plugins_gen.go,go build -mod=mod 得到单一 agent-system\n'
if [ -n "${COMPOSE_WEB_ID:-}" ]; then
printf ' 工作台:插件 %s 的 %s → 伴生 %s\n' \
"$COMPOSE_WEB_ID" "$COMPOSE_WEB_REL" "$(rdm_companion_name "$ENV_NAME")"
fi
elif [ "${COMPOSE_PLUGINS_SET:-0}" = 1 ]; then
printf ' 插件:无\n'
fi
return 0
}
@@ -5300,7 +5901,9 @@ cmd_update() {
"")
state="来源未知(重新 deploy 后可参与更新)" ;;
local@*|dev@*)
cur="${src##*@}"
# dev@<基座>+<插件>@<提交> 的基座在第一个 + 之前,不能用最后一个 @。
cur="${src%%+*}"
cur="${cur#*@}"
if [ -z "$latest_sha" ]; then
state="远端 main 未知"; latest_disp="-"
elif ! git -C "$SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then
@@ -5392,7 +5995,15 @@ cmd_update() {
c_info "[$name] 更新:$from → $tov($deploy_from)"
hist_event update begin "$(read_pid)" "from=$from to=$tov"
# 每个服务独立子进程、清空环境,避免批内 AGENT_SYSTEM_* 串实例。
if svc_lifecycle_child deploy "$name" --from "$deploy_from"; then
# 原样带上 plugins=,避免在终端上再次询问,也避免 ${src##*@} 把插件提交当成基座。
local plug plug_args=()
plug="$(sed -n 's/^plugins=//p' "$RUNTIME/$name/svc.env" | head -1)"
if [ -n "$plug" ]; then
plug_args=(--with "$plug")
else
plug_args=(--with none)
fi
if svc_lifecycle_child deploy "$name" --from "$deploy_from" ${plug_args[@]+"${plug_args[@]}"}; then
hist_event update ok "$(read_pid)" "from=$from to=$tov"
c_ok "[$name] 已更新:$from → $tov"
updated=$((updated + 1))
@@ -5609,6 +6220,7 @@ svc_lifecycle_main() {
while [ $# -gt 0 ]; do
case "$1" in
--from) CLI_FROM="$2"; shift 2 ;;
--with) CLI_WITH="$2"; CLI_WITH_SET=1; shift 2 ;;
--quiet) CLI_QUIET=1; shift ;;
*) c_err "__lifecycle 未知参数:$1"; exit 2 ;;
esac
@@ -5726,8 +6338,8 @@ main() {
done
fi
parse_args "$@"
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 动作"
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ] || [ "$CLI_WITH_SET" = 1 ]; } && [ "$action" != deploy ]; then
c_err "--from/--pick/--dry-run/--dir/--allow-source-switch/--with 仅用于 deploy 动作"
exit 2
fi
env_config "$ENV_NAME"
@@ -40,16 +40,16 @@ import (
const (
aliceOwner = "mi_alice"
alicePass0 = "mi-alice-pass-0"
alicePass = "mi-alice-pass-1"
httpWait = 15 * time.Second
)
type instance struct {
name string
base string
cmd *exec.Cmd
log *lockedBuffer
name string
base string
dataDir string // serve 的 cwd:本地实例身份(manifest)所在,账户 CLI 须以此身份运行
cmd *exec.Cmd
log *lockedBuffer
}
type lockedBuffer struct {
@@ -312,6 +312,20 @@ func (e *env) startPair() {
e.t.Helper()
e.a = e.startInstance(e.t, "miA", "127.0.0.1:"+freePort(e.t))
e.b = e.startInstance(e.t, "miB", "127.0.0.1:"+freePort(e.t))
// 库内服务身份是显式引导(svc init 同款),且为部署级单行:双进程共享
// 同一身份,只对 A ensure 一次。账户 CLI 的审计与核验都依赖它。
e.identityEnsure(e.t, e.a)
}
// identityEnsure 建立/对齐库内服务身份行(agent_system_identity)。
func (e *env) identityEnsure(t *testing.T, inst *instance) {
t.Helper()
cmd := exec.Command(e.bin, "identity", "ensure", "--instance-id", inst.name, "--data", inst.dataDir, "--dbref", "pg")
cmd.Dir = inst.dataDir
cmd.Env = append(os.Environ(), "AGENT_SYSTEM_DSN="+e.dbDSN)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("identity ensure %s: %v\n%s", inst.name, err, out)
}
}
func (e *env) startInstance(t *testing.T, name, addr string) *instance {
@@ -332,11 +346,12 @@ func (e *env) startInstance(t *testing.T, name, addr string) *instance {
// 否则测不到带类型空 *S3 回落本地目录的路径。
cmd.Env = withoutBlobstoreS3(os.Environ())
// 独立 cwd:serve 会向 ./data 写 skills/SQLite 等,不能落在测试目录。
cmd.Dir = t.TempDir()
dataDir := t.TempDir()
cmd.Dir = dataDir
if err := cmd.Start(); err != nil {
t.Fatalf("start %s: %v", name, err)
}
inst := &instance{name: name, base: "http://" + addr, cmd: cmd, log: log}
inst := &instance{name: name, base: "http://" + addr, dataDir: dataDir, cmd: cmd, log: log}
t.Cleanup(func() { e.stopInstance(inst) })
e.waitHealthy(t, inst)
return inst
@@ -435,29 +450,35 @@ func (e *env) killNine(t *testing.T, inst *instance) {
// loginAlice 引导 alice 账号并发放 API key(CLI 直连测试库;key 存共享 PG,
// 对两实例同时有效——程序化验收的首选凭证,SSE 亦可用 ?key= 兜底)。
// 建号后 must_change=1:API Key 在改密完成前不能走业务接口(login_keys 门禁),
// 故先经会话完成 alicePass0→alicePass,再发 Key。
// U3 后本地无密码创建:user create 只签发待激活账号的一次性激活 Key(凭据
// 独占落 JSON),redeem 会话进入 grant-setup 改密设 alicePass,完成后再发 Key。
func (e *env) loginAlice(t *testing.T) *userClient {
t.Helper()
e.cli(t, "user", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "Alice", "--password", alicePass0)
// 空库先引导首个管理员(本地 pending 创建要求库内已有用户),再建 alice。
// 两步都签发一次性凭据,独占落临时 JSON 后即弃。
rootCred := filepath.Join(t.TempDir(), "root-cred.json")
e.cliIn(t, e.a.dataDir, "user", "bootstrap-admin", "--dsn", e.dbDSN, "--owner", "mi-root", "--out-file", rootCred)
activation := filepath.Join(t.TempDir(), "activation.json")
e.cliIn(t, e.a.dataDir, "user", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "Alice", "--out", activation)
activateKey := activationSecret(t, activation)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
sess := &http.Client{Timeout: httpWait, Jar: jar}
// e.uc 尚未赋值 → baseFor 走 A;会话 cookie 落 jar 后改密清 must_change。
e.do(t, sess, "POST", "/api/auth/login", map[string]any{
"owner": aliceOwner, "password": alicePass0,
// e.uc 尚未赋值 → baseFor 走 A;redeem 会话(grant-setup)直接设 alicePass。
e.do(t, sess, "POST", "/api/auth/login-key/redeem", map[string]any{
"key": activateKey,
}, nil)
e.do(t, sess, "POST", "/api/auth/password", map[string]any{
"current": alicePass0, "next": alicePass,
"next": alicePass,
}, nil)
key := e.cliOut(t, "apikey", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "mi-acceptance")
apiKey := e.cliOut(t, "apikey", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "mi-acceptance")
uc := &userClient{
api: &http.Client{Timeout: httpWait, Transport: bearerRT{key: key}},
apiB: &http.Client{Timeout: httpWait, Transport: bearerRT{key: key}},
streamB: &http.Client{Transport: bearerRT{key: key}}, // SSE 无整体超时;chan 背压控制
api: &http.Client{Timeout: httpWait, Transport: bearerRT{key: apiKey}},
apiB: &http.Client{Timeout: httpWait, Transport: bearerRT{key: apiKey}},
streamB: &http.Client{Transport: bearerRT{key: apiKey}}, // SSE 无整体超时;chan 背压控制
}
// runtime.mock 自系统受管设置体制(backend #188/#190)起由 --mock 播种进
// 系统层,经 Effective 链对所有租户生效(selectConversationRunner 按
@@ -474,6 +495,22 @@ func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
return http.DefaultTransport.RoundTrip(r2)
}
// activationSecret 读 CLI 凭据独占文件里的激活 Key(JSON {"kind","owner","secret"})。
func activationSecret(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var v struct {
Secret string `json:"secret"`
}
if err := json.Unmarshal(b, &v); err != nil || v.Secret == "" {
t.Fatalf("activation credential unreadable: %v", err)
}
return v.Secret
}
// cliOut 运行 CLI 并取 stdout 首行(apikey create 的明文仅此一次输出)。
func (e *env) cliOut(t *testing.T, args ...string) string {
t.Helper()
@@ -492,9 +529,16 @@ func (e *env) cliOut(t *testing.T, args ...string) string {
}
func (e *env) cli(t *testing.T, args ...string) {
t.Helper()
e.cliIn(t, t.TempDir(), args...)
}
// cliIn 以指定 cwd 运行 CLI:账户命令经 assertLocalAccountOwner 核验本地实例
// 身份,必须从持有该身份的 dataDir 运行。
func (e *env) cliIn(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command(e.bin, args...)
cmd.Dir = t.TempDir()
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cli %v…: %v\n%s", args[:2], err, out)
+297
View File
@@ -0,0 +1,297 @@
"""svc.sh --with 编译插件组合的桩回归。
用临时 git 仓和桩二进制覆盖:登记校验、dry-run、与 rdm/release 互斥、
首次部署、非交互复用、换集合门禁、交互勾选,以及激活校验里“命令被禁用则启用”。
SVC_COMPOSE_STUB=1 不执行 go build。真实组合构建与 make test-lifecycle 不在这里。
"""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
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)
if [ "${2:-}" = "--json" ]; then
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
exit 0
fi
echo "stub-agent-system 1.0"
exit 0 ;;
serve)
shift
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" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
import json, 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):
def _send(self, body):
raw = body.encode() if isinstance(body, str) else body
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path == '/api/plugins':
self._send(json.dumps([{"manifest": {"id": "demo"}, "backend": {"mode": "inproc"}}]))
return
if path == '/api/registry':
self._send(json.dumps({"compiled": ["demo"]}))
return
if path == '/api/commands':
enabled = os.path.exists(os.path.join(trace, 'plugin-enabled'))
self._send(json.dumps([{"id": "demo.ping", "enabled": enabled}]))
return
self._send('{"ok":true}')
def do_HEAD(self):
self._send(b'')
def do_POST(self):
path = self.path.split('?', 1)[0]
n = int(self.headers.get('Content-Length') or 0)
self.rfile.read(n)
if path.endswith('/enabled'):
open(os.path.join(trace, 'plugin-enabled'), 'w').close()
open(os.path.join(trace, 'enable-post'), 'w').write(path + '\n')
self._send('{"ok":true}')
def log_message(self, *args):
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 PluginComposeTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.mkdtemp(prefix="svc-plugins-")
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.root / "Makefile").write_text(
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % self.stub)
self.plugin = Path(tempfile.mkdtemp(prefix="svc-plugin-src-"))
self.addCleanup(shutil.rmtree, self.plugin, True)
self.cache = self.root / "plugin-cache"
self.rdm_cache = self.root / "rdm-cache"
self.env = os.environ.copy()
self.env.update({
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "svc plugins", "GIT_COMMITTER_NAME": "svc plugins",
"GIT_AUTHOR_EMAIL": "svc-plugins@example.invalid",
"GIT_COMMITTER_EMAIL": "svc-plugins@example.invalid",
"SVC_STUB_TRACE": str(self.trace),
"SVC_SRC_DIR": str(self.root / "srccache"),
"SVC_COMPOSE_STUB": "1",
"SVC_PLUGIN_SRC_ROOT": str(self.cache),
"SVC_RDM_SRC_DIR": str(self.rdm_cache),
"SVC_DEMO_ORIGIN": self.plugin.resolve().as_uri(),
})
for key in list(self.env):
if key.startswith("SVC_") and key not in (
"SVC_STUB_TRACE", "SVC_SRC_DIR", "SVC_COMPOSE_STUB",
"SVC_PLUGIN_SRC_ROOT", "SVC_RDM_SRC_DIR", "SVC_DEMO_ORIGIN",
):
self.env.pop(key)
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
self.env.pop(key, None)
self.git(self.plugin, "init", "--initial-branch=main")
(self.plugin / "README").write_text("demo\n")
self.git(self.plugin, "add", "README")
self.git(self.plugin, "commit", "-m", "demo plugin")
self.plugin_sha = self.git(self.plugin, "rev-parse", "HEAD").stdout.strip()
self.write_registry([self.plugin_entry()])
self.git(self.root, "init", "--initial-branch=main", ".")
self.git(self.root, "add", ".")
self.git(self.root, "commit", "-m", "fixture")
def plugin_entry(self, plugin_id="demo", web=""):
entry = {
"id": plugin_id,
"name": plugin_id,
"repo": self.plugin.resolve().as_uri(),
"repoEnv": "SVC_DEMO_ORIGIN",
"defaultRef": "origin/main",
"goModule": "example.com/" + plugin_id,
"entryPackage": "example.com/%s/plugin" % plugin_id,
"commandPrefix": plugin_id + ".",
}
if web:
entry["web"] = web
return entry
def write_registry(self, plugins):
import json
(self.root / "plugins" / "registry.json").write_text(
json.dumps({"plugins": plugins}, ensure_ascii=False, indent=2) + "\n")
def git(self, cwd, *args):
proc = subprocess.run(
["git", *args], cwd=cwd, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
self.assertEqual(proc.returncode, 0, proc.stdout)
return proc
def tearDown(self):
runtime = self.root / "runtime"
if not runtime.is_dir():
return
for d in sorted(runtime.iterdir()):
if (d / "svc.pid").exists():
subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"],
cwd=self.root, env=self.env, timeout=60,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def svc(self, *args, check=True, env=None, input_text=None):
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
cwd=self.root, env=env if env is not None else self.env, text=True,
input=input_text, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=120)
if check:
self.assertEqual(proc.returncode, 0, proc.stdout)
return proc
def kv(self, name, key):
envfile = self.root / "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 pid_alive(self, name):
pidf = self.root / "runtime" / name / "svc.pid"
if not pidf.exists():
return False
try:
os.kill(int(pidf.read_text().strip()), 0)
return True
except (ValueError, ProcessLookupError, PermissionError):
return False
def test_help_plugins_and_rejects_with_off_deploy(self):
help_out = self.svc("help", "plugins")
self.assertIn("registry.json", help_out.stdout)
self.assertIn("--with", help_out.stdout)
self.assertIn("allow-source-switch", help_out.stdout)
rejected = self.svc("pdemo", "start", "--with", "demo", check=False)
self.assertEqual(rejected.returncode, 2, rejected.stdout)
self.assertIn("仅用于 deploy", rejected.stdout)
def test_dry_run_prints_plugin_sha_without_listening(self):
result = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
"--port", "18981", "--dry-run")
self.assertIn(self.plugin_sha, result.stdout)
self.assertIn("插件:", result.stdout)
self.assertFalse((self.root / "runtime" / "pdemo" / "svc.pid").exists())
def test_unknown_plugin_and_two_web_and_rdm_are_rejected(self):
unknown = self.svc("pdemo", "deploy", "--from", "local", "--with", "nope",
"--dry-run", check=False)
self.assertNotEqual(unknown.returncode, 0)
self.assertIn("没有插件", unknown.stdout)
self.write_registry([
self.plugin_entry("a", web="web"),
self.plugin_entry("b", web="web"),
])
two = self.svc("pdemo", "deploy", "--from", "local", "--with", "a,b",
"--dry-run", check=False)
self.assertNotEqual(two.returncode, 0)
self.assertIn("web", two.stdout)
rdm = self.svc("pdemo", "deploy", "--from", "rdm", "--with", "demo",
"--dry-run", check=False)
self.assertNotEqual(rdm.returncode, 0)
self.assertIn("不能与 --with", rdm.stdout)
self.assertFalse(self.rdm_cache.exists())
def test_deploy_reuses_selection_and_gates_clearing(self):
first = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
"--port", "18981")
self.assertIn("插件已激活", first.stdout)
self.assertTrue((self.trace / "enable-post").exists())
plugins = self.kv("pdemo", "plugins")
self.assertTrue(plugins and plugins.startswith("demo@" + self.plugin_sha), plugins)
source = self.kv("pdemo", "source")
self.assertIn("+demo@", source)
release = (self.root / "runtime" / "pdemo" / "current" / "release.json").read_text()
self.assertIn(self.plugin_sha, release)
self.assertTrue(self.pid_alive("pdemo"))
again = self.svc("pdemo", "deploy", "--from", "local", "--port", "18981")
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
self.assertIn("+demo@", self.kv("pdemo", "source"))
self.assertIn("插件已激活", again.stdout)
pid = int((self.root / "runtime" / "pdemo" / "svc.pid").read_text())
blocked = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
"--port", "18981", check=False)
self.assertNotEqual(blocked.returncode, 0, blocked.stdout)
self.assertIn("allow-source-switch", blocked.stdout)
self.assertTrue(self.pid_alive("pdemo"))
self.assertEqual(int((self.root / "runtime" / "pdemo" / "svc.pid").read_text()), pid)
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
cleared = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
"--allow-source-switch", "--port", "18981")
self.assertIsNone(self.kv("pdemo", "plugins"))
self.assertNotIn("+demo@", self.kv("pdemo", "source"))
self.assertIn("插件集合已确认", cleared.stdout)
self.svc("pdemo", "stop")
def test_prompt_selects_numbered_plugin_on_first_deploy(self):
env = dict(self.env, SVC_FORCE_PLUGIN_PROMPT="1")
result = self.svc("pprompt", "deploy", "--from", "local", "--port", "18982",
env=env, input_text="1\n")
self.assertIn("插件已激活", result.stdout)
plugins = self.kv("pprompt", "plugins")
self.assertTrue(plugins and plugins.startswith("demo@"), plugins)
self.svc("pprompt", "stop")
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -24,7 +24,10 @@ class TaskLifecycleTests(unittest.TestCase):
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "Lifecycle Test", "GIT_AUTHOR_EMAIL": "test@example.invalid",
"GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid"})
"GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT",
"TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH",
"TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE"):