Merge current main into renderer delivery work
Why: Resume the long-running two-tree and renderer task on the current Dev baseline without dropping project context, audit, human-decision, layered-environment, plugin or service changes. Evidence: Backend merge 92f9286 preserves both lines and passes full vet/test/build plus isolated PostgreSQL migration tests; Documents merge c313639 is conflict-free; tests.test_svc passes 23 cases. Impact: Resolves Backend/Documents gitlinks and the svc fixture, adopts current Core/Frontend/UI pins, and leaves Task 10 implementation separate. Refs: task-sync for feature/rdm-two-trees-latest-templates
This commit is contained in:
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
+26
-12
@@ -1,6 +1,6 @@
|
||||
# feature/work-companion-visibility 任务卡
|
||||
|
||||
任务主记录:[Dev PR #550](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/550);状态:进行中;owner:weichen
|
||||
任务主记录:[Dev PR #550](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/550);状态:已完成(回填);owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
|
||||
| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| UI1 | 中栏消息按回合保持阅读顺序,过程输出归入对应轮次的折叠记录,最终回复直接展示 | 符合 DESIGN.md 对话优先 | P0 | 过程文本、思考、工具与最终文本顺序可辨;终态与刷新后可回看已保存过程 | 持久化契约 | 代码已合入组件;Dev/8788 待验 |
|
||||
| UI2 | 工作卡只在可见的右侧工作栏完整展示;右栏关闭/文件模式时中栏提供完整替代 | 符合单对象单详细落位 | P0 | 同屏无重复完整工作卡;待确认动作始终可达 | UI 布局 | 代码已合入组件;Dev/8788 待验 |
|
||||
| UI3 | 中右栏形成清晰视觉层级并减少重复导航 | 符合 DESIGN.md 四原则 | P1 | 桌面及窄屏可辨对话/工作/文件,浏览器截图可审阅 | UI1–2 | 代码已合入组件;Dev/8788 待验 |
|
||||
| UI1 | 中栏消息按回合保持阅读顺序,过程输出归入对应轮次的折叠记录,最终回复直接展示 | 符合 DESIGN.md 对话优先 | P0 | 过程文本、思考、工具与最终文本顺序可辨;终态与刷新后可回看已保存过程 | 持久化契约 | 代码已合入;8788 用户验收通过(2026-10-15) |
|
||||
| UI2 | 工作卡只在可见的右侧工作栏完整展示;右栏关闭/文件模式时中栏提供完整替代 | 符合单对象单详细落位 | P0 | 同屏无重复完整工作卡;待确认动作始终可达 | UI 布局 | 代码已合入;8788 用户验收通过(2026-10-15) |
|
||||
| UI3 | 中右栏形成清晰视觉层级并减少重复导航 | 符合 DESIGN.md 四原则 | P1 | 桌面及窄屏可辨对话/工作/文件,浏览器截图可审阅 | UI1–2 | 代码已合入;8788 用户验收通过(2026-10-15) |
|
||||
|
||||
## 方案与计划
|
||||
|
||||
@@ -48,16 +48,24 @@
|
||||
| 方案设计 | DESIGN.md §6/§9.6/§12.3 | 不适用 | weichen 连续授权 | 2026-10-14 | 用户明确要求必要时重设计快慢通道,设计按本卡 I1 收敛 |
|
||||
| 实施计划 | 本卡 I1 | 不适用 | weichen 连续授权 | 2026-10-14 | 零付费实现与测试、跨仓组合及本机 8788 安全采用 |
|
||||
| 实施与自检 | UI/Backend/Frontend 主干合入提交与 Dev 本分支 | 不适用 | weichen 连续授权 | 2026-10-14 | 本地 SELF 证据;阶段人工出口由连续授权覆盖,GATE/REPLAY 另核验 |
|
||||
| 正式测试与验收 | 主干组件组合;8788 待部署 | 未评审 | — | — | 隔离浏览器与 Dev `make test` SELF 已记录;准确 HEAD CI 和真实 8788 未完成 |
|
||||
| 正式测试与验收 | 8788 `rdm@f76839f`;Dev `99119a7` | 通过 | 用户正常账号 | 2026-10-15 | 中栏顺序、中右栏去重、最终回复后折叠回看三项由用户确认通过;合成浏览器 21 项为 SELF |
|
||||
| 代码 Review | Frontend e10e447、Backend bb54191、UI fda7b0f | 通过 | /root/work_companion_review(独立只读智能体) | 2026-10-14 | 三项 Important 整改并重验,最后报告无 Critical/Important;Dev 组合 HEAD 后续复核 |
|
||||
| 合入 | 待定 | 未评审 | — | — | — |
|
||||
| 合入核验与收口 | 待定 | 未评审 | — | — | — |
|
||||
| 合入 | Dev `99119a7` / PR #550 | 通过 | 已合入主干 | 2026-10-14 | 合并提交已在 origin/main;本次未重跑该 HEAD 的 CI |
|
||||
| 合入核验与收口 | 闭合分支 feature/card-close-20261014-161949Z | 进行中 | — | — | 任务卡归档待闭合 PR 的当前 HEAD CI 通过后合入 |
|
||||
|
||||
## 探索试验与迭代
|
||||
|
||||
| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 批准依据 | 结果 | 变更影响 | 需重审补测 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| I1 隔离浏览器 | 中右栏同屏去重,终态后完整过程可回看 | 任务自有 Vite 预览、合成会话/事件/卡片;无用户数据或付费模型 | 15 场景、刷新和 320–1440px;只证明 UI/契约机制 | 本会话“开始实施直到完成”及后续 UI 要求 | SELF PASS,原始命令收据见下方;截图 `/tmp/work-companion-visibility-after-v2.png` | 视觉层级和回看机制可审阅 | 真 8788 待验 |
|
||||
| I1 隔离浏览器 | 中右栏同屏去重,终态后完整过程可回看 | 任务自有 Vite 预览、合成会话/事件/卡片;无用户数据或付费模型 | 15 场景、刷新和 320–1440px;只证明 UI/契约机制 | 本会话“开始实施直到完成”及后续 UI 要求 | SELF PASS,原始命令收据见下方;截图 `/tmp/work-companion-visibility-after-v2.png` | 视觉层级和回看机制可审阅 | 8788 用户验收通过,见下方切换记录 |
|
||||
|
||||
## 8788 受控切换
|
||||
|
||||
- 结论:已切换,未回退。服务源 `rdm@f76839f56342bb5b1e55255feed6cb3cb67a406f`,release `rdm-f76839f-20261014T160614Z-4181552`,PID 4181823。实例 `svc-dev-328312de05f9`,数据根未改,基座 schema 31,RDM schema 6,两库 `quick_check=ok`。
|
||||
- 停机快照:`runtime-backups/dev/cutover-8788-20261014T160508Z`。`MANIFEST` SHA-256 `690441fc264de870eae05e6ba989274f73a69eb20f9faee74709f4c21c30ff69`。清单校验、数据库完整性检查和表行数检查通过。表行数相同不证明每行内容相同。
|
||||
- 用户验收(2026-10-15,正常账号):中栏顺序、中右栏去重、最终回复后折叠回看,三项通过。合成浏览器 21 项对 8788 实际页面通过(SELF,固定 Frontend `710ef83`)。
|
||||
- 写入已放开。不得用切换前快照直接回退。
|
||||
- 对齐后的启动检查为 READY,收据 `/tmp/cutover-8788-precheck-after-align-20261015.json`(SELF,`receipt_sha256=5bf461bc474de8a77ed2ba922a92e26fd27ce8cdd2ea874b78ecf9ae3fabb30a`)。切换过程收据在 `/tmp/cutover-8788-{stop,backup,deploy}-20261014.json`。
|
||||
|
||||
## 同步记录
|
||||
|
||||
@@ -79,11 +87,11 @@
|
||||
- 调度依据:policy/merge-queue.md;新模式启用前按既有交付门禁
|
||||
- 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
## 结果与验证
|
||||
|
||||
- 结果:UI #55、Backend #199、Frontend #85 已合入各自主干;Dev 同步当前主干后固定 Backend `ead5ade`(包含 #199 与并行 #200)、Frontend `710ef83`、UI `7c35a78`;PR #550 的新 HEAD 待正式验收/CI。
|
||||
- 验证:SELF:Frontend 272 单测、构建、15 个隔离浏览器场景;Backend `go test ./...`;Dev `make build` 与独立网络命名空间中的 `make test` 均 exit 0,完整收据见下方。此前无隔离 `make test` 因并发工作区占用固定 1880x 端口失败,失败原件保留;受影响用例隔离重放 PASS。准确 Dev PR HEAD CI、真实 8788 浏览器和 RDM 下游组合均 NOT_RUN。
|
||||
- 影响与剩余项:Dev CI、RDM 精确 pin 和 8788 的身份/备份/回退/真实页面验收。
|
||||
- 结果:UI #55、Backend #199、Frontend #85 与 Dev PR #550 已合入。8788 已切换到 `rdm@f76839f`,三项用户验收见「8788 受控切换」。
|
||||
- 验证:隔离浏览器与本地测试的 SELF 收据仍见下方。8788 三项由用户于 2026-10-15 确认通过。本地 SELF 不代替 CI。
|
||||
- 影响与剩余项:本闭合提交的 CI 通过后合入。写入已放开,不能用切换前快照直接回退。
|
||||
|
||||
## 规划进展与下一步
|
||||
|
||||
@@ -635,3 +643,9 @@
|
||||
}
|
||||
```
|
||||
| 同步(记录前对象) | bc76b68cd075379122430acb24fa080b0a38ff5e | b1cb64c7f69d95382e42f385ab44065c290f8e16 | 2026-10-14T15:20:54Z |
|
||||
|
||||
## 闭合记录(card-autoclose 回填)
|
||||
|
||||
- 依据:merge 99119a733830bc120cd6747b8b61736ea6da6078(PR #550)已包含于 origin/main,分支成果已落盘;本段为合入后回填。
|
||||
- 合并主题:Merge pull request 'Integrate work companion visibility across components' (#550) from feature/work-companion-visibility into main
|
||||
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
# feature/layered-python-env 任务卡
|
||||
|
||||
状态:实施已落在独立 worktree,未推送、未合入;owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main
|
||||
- R0:64a50cf(origin/main,Merge PR #569)
|
||||
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-layered-python-env
|
||||
- 分支:feature/layered-python-env(父仓与 backend、frontend、documents 子仓同名)
|
||||
- 启动检查:/tmp/layered-python-env-precheck.json,退出码 2,结论 SHARED_WORKSPACE_BUSY。共享主检出有未提交 gitlink,且落后 origin/main 60 个提交。因此没有在共享检出写入,也没有运行 make task-start。
|
||||
- 授权:用户要求按已附方案实现全部分阶段,不改方案文件本身。不包含推送、合入或重启 8788。
|
||||
|
||||
## 目标
|
||||
|
||||
用不可变、按内容哈希命名的 Python 层,替换「每个工作区一个 venv」和「一个可变全局环境」。沙箱保持断网。安装只发生在宿主侧构建器。
|
||||
|
||||
## 本工作区已落地
|
||||
|
||||
- 提案:modules/documents/docs/proposals/2026-10-15-layered-python-env.md(documents 945db35),状态仍是待评审。
|
||||
- P0:解释器不再解析到被沙箱遮蔽的 linuxbrew 路径;PATH 固定;工具文案和提示词不再要求 pip install --user;svc.sh 在产物支持时调用 env ensure,并把环境根与当前基础层经环境变量交给 serve。
|
||||
- P1:execenv 环境根、托管解释器(独立 CPython,失败则一份系统 venv)、锁、层、wheelhouse、agent-system env ensure|lock|gc|doctor|rebase|python。
|
||||
- P2:schema v35 的 exec_env_requests / exec_env_bindings、python_env 工具、白名单自动批准、其余走审批卡、pip 垫片、按会话挂载。
|
||||
- P3:管理区「Python 环境」页,以及回收、重建基础层、上传 wheel 的管理接口。
|
||||
- 测试(SELF,本机):execenv、tools、store、app、server、runmgr、config、cmd/agent-system 通过。含 sandbox 参数、离线解析、真实 bwrap、两个会话各一层,以及 housestyle.build 产出的 docx 通过结构自检。权威源仍是 CI,本次未推送。
|
||||
|
||||
## 非目标
|
||||
|
||||
不重启正在运行的 8788。不修改方案文件。不把项目角色加入审批人。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
||||
# Project Agent Execution
|
||||
|
||||
Project-local execution routing configuration. This document is the single
|
||||
authority for which Agent CLI profiles may implement, repair, test, or review
|
||||
bounded units. Human-readable notes may appear outside the machine block; the
|
||||
parser accepts exactly one `agent-execution` fenced block.
|
||||
|
||||
U3's execution controller and U4's project wiring are integrated. This file is
|
||||
the execution-routing authority for implementation, repair, test, and review.
|
||||
Active routes are intentionally limited to Cursor, Antigravity (`agy`), and
|
||||
PI. `model: auto` lets each CLI use its configured default model; PI therefore
|
||||
uses the operator-selected MiniMax default. A provider that makes no successful
|
||||
stdin write or stdout/stderr read for `idle_timeout_seconds` is stopped before
|
||||
`timeout_seconds`.
|
||||
|
||||
```agent-execution
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: all-unavailable
|
||||
max_parallel: 3
|
||||
timeout_seconds: 7200
|
||||
idle_timeout_seconds: 600
|
||||
summary_bytes: 16384
|
||||
raw_output_bytes: 10485760
|
||||
|
||||
roles:
|
||||
implementation: [cursor, antigravity, pi]
|
||||
repair: [cursor, antigravity, pi]
|
||||
test: [pi, antigravity, cursor]
|
||||
review: [pi, antigravity, cursor]
|
||||
|
||||
profiles:
|
||||
cursor:
|
||||
adapter: cursor
|
||||
executable: cursor-agent
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
antigravity:
|
||||
adapter: antigravity
|
||||
executable: agy
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
pi:
|
||||
adapter: pi
|
||||
executable: pi
|
||||
model: auto
|
||||
enabled: true
|
||||
```
|
||||
@@ -3,9 +3,60 @@
|
||||
本仓开发遵循 Development-Guidelines(见 `deps/development-guidelines`,gitlink 固定版本):
|
||||
核心规则与术语见其 `core/`,按任务触发阅读 `standards/` 专题;通用交付依据为 `workflows/delivery.md`。
|
||||
本项目的需求、设计试验、实施调整及交付**必读 [policy/delivery.md](policy/delivery.md)**:
|
||||
需求、方案、实施与验收默认逐阶段人工评审,阶段内迭代、按影响回退;代码 Review 与 PR
|
||||
合入由智能体负责,解决冲突且当前 HEAD 的 CI 通过后执行合入,无需另请人工操作。
|
||||
具体评审、版本及授权条件见 policy/delivery.md;其他阶段仅在用户明确授权范围内连续推进。
|
||||
目标、范围和交付终点一次确认;范围内的分析、方案、实施、修复、验证、代码 Review、
|
||||
PR 合入及安全收口由智能体连续推进。只在目标/范围/验收条件变更、存在会影响用户结果的
|
||||
重大取舍、触及高后果操作,或业务验收必须由用户判定时请求人工决策。智能体解决冲突并确认
|
||||
当前 HEAD 的 CI 通过后执行合入,无需另请人工操作。具体决策点、版本及授权条件见
|
||||
policy/delivery.md。
|
||||
|
||||
## 立即生效的最高优先级(强制)
|
||||
|
||||
- 在既有授权与强制安全边界内,**先交付用户明确要求的产物**。不得用基础设施、工具治理、
|
||||
通用化、加固、重构或“以后可能用到”替代当前目标;尤其禁止把简单 Markdown、配置或说明
|
||||
修改擅自升级成平台、框架、自动化系统或大规模工程。
|
||||
- 采用最小充分实现:先让目标可用、系统可用并通过最小直接验证,然后立即停止并交付;体验、
|
||||
结构、性能与治理完善必须作为后续独立增量,不得拖延当前结果。
|
||||
- 只修复直接阻塞目标、可用性、明确验收条件或强制安全门禁的问题。高价值非阻塞项只简记,
|
||||
低影响噪音直接忽略;Review/迭代循环只围绕阻塞项,阻塞项闭合即结束。
|
||||
- 测试与风险成比例:实施中只跑最小直接检查;完整/长耗时套件仅在最终候选、规范明确要求或
|
||||
实际影响面确有必要时运行一次。禁止在每个小文档、配置或 adapter 修改后重复全量测试。
|
||||
- 编排成本不得超过任务本身。平凡文档/配置单元最多使用一个有界 CLI 进程;除非用户明确要求
|
||||
或已有证据证明是当前目标的直接阻塞,否则不得新增嵌套控制器、额外 worktree、复杂收据体系
|
||||
或新架构。执行路由是手段,不能成为推迟用户产物的理由。
|
||||
|
||||
## 任务目标与范围(强制)
|
||||
|
||||
- 执行优先级固定为:授权与安全边界 → 本次任务目标和系统可用 → 阻塞目标的必要问题 →
|
||||
非阻塞改进。完成标准是已批准验收条件成立且受影响系统保持可用,不是把所有附近问题一并清零。
|
||||
- 绝对禁止私自扩大范围。影响目标的问题仅在既有授权内作最小充分修复;必要修复超出范围时,说明
|
||||
阻塞、最小扩围和影响,取得授权后再做。“相关”、“顺手”或“更完美”不构成扩围授权。
|
||||
- 新发现按对当前目标的影响分流:阻塞验收、导致不可用/明确回归、违反安全边界或必需门禁失败的问题必须
|
||||
处理;其余不阻塞交付。只有具备明确影响和后续价值的非阻塞问题才记录,不为低影响噪音制造新任务。
|
||||
- 先实现端到端可用和可验证的最小闭环,再将体验、结构、性能及治理优化作为独立增量迭代;非阻塞 Review
|
||||
意见不得阻止当前目标交付。
|
||||
|
||||
## 实施执行路由(强制)
|
||||
|
||||
- 实施、修复、测试与审阅调度**必读根目录 [AGENT-EXECUTION.md](AGENT-EXECUTION.md)**(唯一项目执行路由配置)。
|
||||
- 产生仓库变更的实施按可独立实施的单元拆分;任务卡记 `实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。
|
||||
- 外部 Agent 在新进程/隔离工作区执行一个有界单元;host 负责计划、拆解、范围审阅、权威验证、规范提交、
|
||||
PR/CI/合入、证据与任务元数据及安全清理。Worker 不得提交、推送、开或合 PR、部署、改路由或派遣同伴。
|
||||
- 当前 host 仅在 `scripts/agent_exec.py run` 对该精确单元发出有效 `host-fallback-authorized` 收据后,
|
||||
方可撰写该单元的仓库变更;`failed-unknown`、`scope-expansion`、无效配置、未对账 Git 状态与安全失败
|
||||
均不授权回退。适配器、终态与回退细则见配置及 [policy/delivery.md](policy/delivery.md)、
|
||||
[policy/governance.md](policy/governance.md)。不削弱 goal-first、严格范围、先可用、独立 Review、
|
||||
证据、平台准入与高后果双钥匙规则。
|
||||
|
||||
### 阶段与会话隔离(强制)
|
||||
|
||||
- 需求分析、方案、设计、计划、实施、测试、Review 与验收默认各启动一个全新会话;不同阶段、
|
||||
不同实施单元不得复用同一 CLI 会话、conversation、thread 或 session。
|
||||
- Review 必须由当前主模型执行,或交给**全新且独立**的 CLI session。绝对禁止实施者使用原 session
|
||||
通过 `continue`、`resume`、conversation ID、session ID/路径或等价机制继续充当评审者。
|
||||
- 独立评审只接收固定的目标/需求、base/head、实际 diff、测试与证据;默认不转交实施聊天、思维过程、
|
||||
中间自评或原会话历史。确需补充信息时只提供可复核产物,不把实施上下文整体灌入评审会话。
|
||||
- 每阶段记录工具、模型、stage、attempt/session 身份及 `fresh_session`。工具不暴露 session ID 时标
|
||||
`not-observable`,但仍须用新进程并禁用恢复/续聊;无法证明会话独立时,不得把结果标为独立 Review。
|
||||
|
||||
## UI 设计门禁(强制)
|
||||
|
||||
@@ -39,14 +90,15 @@
|
||||
- **多个分支交付 / CI 调度**:先读 [合入调度](policy/merge-queue.md),核对采纳状态、协调者、
|
||||
记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口,
|
||||
同基线 `task-sync` 只核验,固定后检查用源码外 RECEIPT 保存(见 [证据流程](policy/governance.md#候选固定前后))。
|
||||
- 合入后由工作区 owner 确认所有写者退出,在**已登记任务 worktree 根目录**运行
|
||||
- 合入后核对所有写者退出;能从当前会话、进程、锁与工作区状态证明时,智能体在
|
||||
**已登记任务 worktree 根目录**运行
|
||||
`TASK_WRITERS_STOPPED=1 make task-finish`。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时
|
||||
保留现场并按原因处理,禁止强删兜底。放弃未合入工作用
|
||||
保留现场并按原因处理,禁止强删兜底;无法证明写者已退出时才请 owner 确认。放弃未合入工作用
|
||||
`make task-finish ABANDON="--abandon <原因>"` 并记录原因;放弃不豁免现场保护。
|
||||
- **任务卡闭合(防欠账)**:分支合入 main 后,其任务卡须回填"已完成"+主记录 PR 链接并
|
||||
归档至 `.task/archive/`。`task-finish` 回收后提示所有者运行 `make task-close`,不借回收修改
|
||||
归档至 `.task/archive/`。`task-finish` 回收后按提示运行 `make task-close`,不借回收修改
|
||||
共享主检出或推送另一任务。主检出 pull/merge 的 post-merge 钩子仅提示待办,不自动写入或推送。
|
||||
`make task-close` 会提交、推送并可能创建 PR,须先核对闭合范围和对应授权。闭合在主检出运行→建
|
||||
`make task-close` 会提交、推送并可能创建 PR,标准闭合属于已授权交付的收口;仍须核对只包含本任务卡,否则停止扩围。闭合在主检出运行→建
|
||||
`feature/card-close-*` 分支+PR,任务分支内加 `FOLD=--fold` 随当前 PR 合入。
|
||||
precheck 的 `[cards]` 行报告欠账,验收前应清零;闭合 PR 同样由智能体按
|
||||
policy/delivery.md 完成 Review、冲突处置及当前 HEAD 的 CI 核验后合入。
|
||||
@@ -110,5 +162,5 @@
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
- 任务卡闭合 PR(获授权后运行 `make task-close`)经 API 建 PR 时以
|
||||
- 任务卡闭合 PR(标准收口中运行 `make task-close`)经 API 建 PR 时以
|
||||
`GITEA_TOKEN` 环境变量传入同上的 token。
|
||||
|
||||
@@ -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)` /
|
||||
@@ -324,3 +331,13 @@ Dev 组合 PR 更新 `DESIGN.md` 并固定对应 UI 提交;两者互链,附
|
||||
沿用三栏、现有令牌与唯一文件预览。新用户默认浅色、辅助右栏收起,已有主题和布局偏好保留。中栏先给每轮结论,过程、会话设置和消息次要操作按需展开;后台工作通过同一任务卡显示。右栏「工作」打开时,工作卡详细内容与动作只在右栏;中栏保留对话顺序以及必要的定位入口。右栏收起或切到「文件」时,中栏提供完整工作卡。状态和动作只消费服务端已有内容。窄屏单面板提供上下文、对话、工作、文件与返回入口;不丢失同一预览的状态。
|
||||
|
||||
这是结构与信息层级调整;UI 仓新增当前工作卡的语义表面令牌,字号、圆角和动效仍使用现有标尺与原语。UI 组件与 Dev 精确 gitlink 组合验证后交付。设计与实施依据见 Documents 的 work-companion-ui-design r0.2 / plan r1,以及本次任务卡 I1。
|
||||
|
||||
### 12.4 后台成果与非阻塞选择
|
||||
|
||||
延续 §12.3 的视觉令牌和三栏职责。后台完成只更新原任务卡,不自动在中栏插入汇报或发起前台选择回合;中栏按真实发送顺序继续对话。中右栏独立滚动,只通过明确的定位动作关联。
|
||||
|
||||
任务卡先展示状态和结果预览,完整结果、材料依据与完整执行记录分层展开。累计进展合并为同一项的最新内容,已保存的公开过程仍可回看,不因最终回复删除。消息、任务更新和选择处理均使用真实时间,完整日期可查。
|
||||
|
||||
后续用途选择是可选事项,支持单选或多选、实际的自由输入和“先不选择”;暂缓后可重开。真正的高风险审批继续使用原确认机制。已提交的选择显示实际答案及时间,不残留输入提示。当前工作与工作历史互斥,历史默认折叠、最近在上;更新不打断阅读。未读按成果版本记录,仅明确用户动作确认已读。
|
||||
|
||||
右栏收起、显示文件或窄屏时,沿用中栏工作区回退,同一卡片和执行记录只保留一个完整展示位置。用户可主动将某份结果引用到下一条消息,后台完成本身不会更改输入框或当前话题。
|
||||
|
||||
@@ -13,7 +13,7 @@ PLUGINS := plugins
|
||||
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
|
||||
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version agent-exec-validate
|
||||
|
||||
version:
|
||||
@echo "agent-system $(VERSION)"
|
||||
@@ -77,6 +77,10 @@ help:
|
||||
@echo "make dev 开发模式说明(前后端分离热更新)"
|
||||
@echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)"
|
||||
@echo "make status submodule 状态"
|
||||
@echo "make agent-exec-validate 校验 AGENT-EXECUTION.md(agent_exec validate)"
|
||||
|
||||
agent-exec-validate:
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py validate --repo .
|
||||
|
||||
sync:
|
||||
git submodule update --init --recursive
|
||||
|
||||
+5
-1
@@ -14,6 +14,10 @@
|
||||
目录中的既有文档不因被收录而自动成为已批准规划。记录、任务关联及收尾建议按
|
||||
[交付规范的长期方向与任务衔接](policy/delivery.md#长期方向与任务衔接) 执行。
|
||||
|
||||
- [文档职责矩阵整改 r2](modules/documents/docs/proposals/2026-10-15-rdm-duty-matrix-rework.md):项目设置的文档职责改为三视图分配,不创建文档、不执行审签。2026-10-15 按实施方案执行;部署与真实账号验收未授权。
|
||||
|
||||
- [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) 维护实际结果与剩余限制;后续单元须另行授权。
|
||||
@@ -59,7 +63,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` 可独立检查开发门禁,使用临时
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
# Project Agent Execution Routing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a project-local, Markdown-configured execution router that delegates repository-writing work to ordered Agent CLI profiles and authorizes host-model fallback only after every candidate is safely unavailable.
|
||||
|
||||
**Architecture:** A small `agent-exec` CLI is the external seam. It parses one `agent-execution` block from `AGENT-EXECUTION.md`, validates profiles against built-in or controlled custom adapters, runs each implementation attempt in a fresh detached worktree, and emits a bounded JSON receipt plus a host-owned transport commit. The host remains responsible for scope review, authoritative verification, integration, commits, PRs, CI, merge, and cleanup.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, Git worktrees and plumbing commands, `unittest`, existing `make test-governance` discovery, Markdown with a constrained YAML subset.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-15-project-agent-execution-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve goal-first delivery, strict no-scope-expansion, usable-first sequencing, evidence provenance, platform admission, and high-consequence dual keys.
|
||||
- Use no new third-party runtime dependency. The parser accepts only the documented YAML subset.
|
||||
- Every implementation task below uses one fresh Cursor CLI run. Cursor workers do not commit, push, open PRs, merge, deploy, or clean canonical worktrees.
|
||||
- The host creates canonical commits only after inspecting actual changes and running the task's authoritative tests.
|
||||
- `run-everything` removes per-command prompts inside the isolated unit; it never expands user authorization or grants canonical Git authority.
|
||||
- `model: auto` omits the model flag instead of passing the string `auto`.
|
||||
- Shell command templates, secret values, unknown placeholders, and implicit environment inheritance are rejected.
|
||||
- `failed-unknown`, `scope-expansion`, `invalid-config`, and safety failures never authorize host fallback.
|
||||
- A normal worker return is bounded by `summary_bytes`; raw logs remain outside the repository.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- A CLI exits nonzero after touching files: preserve evidence, prove exact cleanup, then classify `failed-clean`; otherwise return `failed-unknown`.
|
||||
- A custom adapter places a placeholder inside a larger argv string: reject it instead of interpolating or invoking a shell.
|
||||
- A role lists several missing or incompatible profiles: exhaust them in order and issue exactly one `host-fallback-authorized` receipt.
|
||||
- A worker creates untracked or binary files: include them in the host-owned transport and changed-path inventory without modifying the canonical checkout.
|
||||
- A worker changes HEAD, `.git`, a disallowed path, or an external object: fail-stop and retain the attempt for reconciliation.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `AGENT-EXECUTION.md` | Human-readable project configuration and the single machine-readable `agent-execution` block |
|
||||
| `scripts/agent_exec_config.py` | Markdown extraction, constrained YAML parsing, schema validation, and immutable configuration types |
|
||||
| `scripts/agent_exec_adapters.py` | Built-in adapter registry, custom adapter validation, capability probes, and argv construction |
|
||||
| `scripts/agent_exec.py` | `validate`/`run` CLI, selection state machine, detached worktrees, process supervision, transport commits, receipts, cleanup, and host fallback authorization |
|
||||
| `tests/test_agent_exec_config.py` | Configuration parser and schema contract |
|
||||
| `tests/test_agent_exec_adapters.py` | Adapter argv, model, permission, output, and custom-adapter contract |
|
||||
| `tests/test_agent_exec.py` | Real temporary-Git execution, fallback, scope, transport, recovery, timeout, redaction, and process lifecycle |
|
||||
| `AGENTS.md` | Short context pointer to `AGENT-EXECUTION.md`; no duplicated CLI mappings |
|
||||
| `policy/delivery.md` | Project-level agent-per-unit execution and fallback authority |
|
||||
| `policy/governance.md` | Receipts, isolation, evidence ownership, and recovery rules |
|
||||
| `policy/adoption.md` | Adoption status and current capability limit |
|
||||
| `policy/tool-adaptation-matrix.md` | Manual/enforced capability classification and verification entrypoints |
|
||||
| `scripts/task.sh` | New task-card execution routing and receipt fields |
|
||||
| `tests/test_task.py` | Generated task-card contract |
|
||||
| `Makefile` | `agent-exec-validate` target and help text |
|
||||
| `.task/feature-goal-first-delivery.md` | Scope, Cursor attempt receipts, stage state, and exact evidence references |
|
||||
|
||||
### Task 1: U1 Configuration Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `AGENT-EXECUTION.md`
|
||||
- Create: `scripts/agent_exec_config.py`
|
||||
- Create: `tests/test_agent_exec_config.py`
|
||||
- Modify: `Makefile`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ExecutionConfig`, `ExecutionPolicy`, `Profile`, `CustomAdapter`, `ConfigError`, `load_config(repo: Path) -> ExecutionConfig`, `validate_config(repo: Path) -> list[str]`
|
||||
- Consumes: Python 3 standard library only
|
||||
|
||||
- [ ] **Step 1: Write the failing parser and schema tests**
|
||||
|
||||
Create `tests/test_agent_exec_config.py` with temporary repository fixtures and these cases:
|
||||
|
||||
```python
|
||||
def test_loads_single_agent_execution_block(): ...
|
||||
def test_rejects_missing_or_duplicate_blocks(): ...
|
||||
def test_rejects_unknown_schema_and_unknown_keys(): ...
|
||||
def test_role_order_does_not_follow_profile_order(): ...
|
||||
def test_model_auto_is_preserved_as_none(): ...
|
||||
def test_rejects_unknown_profile_reference(): ...
|
||||
def test_rejects_shell_command_and_secret_shaped_values(): ...
|
||||
def test_custom_placeholders_must_fill_whole_argv_items(): ...
|
||||
def test_numeric_limits_are_positive_and_bounded(): ...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the parser tests red**
|
||||
|
||||
Run: `python3 -m unittest tests.test_agent_exec_config -v`
|
||||
|
||||
Expected: FAIL because `scripts.agent_exec_config` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement immutable configuration types**
|
||||
|
||||
In `scripts/agent_exec_config.py`, define:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPolicy:
|
||||
fresh_process_per_unit: bool
|
||||
permission: str
|
||||
fallback_to_host: str
|
||||
max_parallel: int
|
||||
timeout_seconds: int
|
||||
summary_bytes: int
|
||||
raw_output_bytes: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
name: str
|
||||
adapter: str
|
||||
executable: str
|
||||
model: str | None
|
||||
enabled: bool
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomAdapter:
|
||||
name: str
|
||||
base_argv: tuple[str, ...]
|
||||
run_everything_argv: tuple[str, ...]
|
||||
workspace_argv: tuple[str, ...]
|
||||
model_argv: tuple[str, ...]
|
||||
prompt_transport: str
|
||||
output_format: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionConfig:
|
||||
schema: str
|
||||
execution: ExecutionPolicy
|
||||
roles: Mapping[str, tuple[str, ...]]
|
||||
profiles: Mapping[str, Profile]
|
||||
custom_adapters: Mapping[str, CustomAdapter]
|
||||
```
|
||||
|
||||
`model: auto` becomes `None`. Reject unknown keys, duplicate mapping keys, tabs, anchors, aliases, tags, multiline scalars, implicit dates, shell metacharacter templates, credential-looking keys/values, and placeholders other than the four spec placeholders.
|
||||
|
||||
- [ ] **Step 4: Implement the constrained YAML parser**
|
||||
|
||||
Support two-space-indented mappings, inline scalar lists, booleans, integers, null, and quoted/unquoted safe strings. The parser does not implement general YAML. Extract exactly one fenced block whose opening marker is ```` ```agent-execution ```` and whose closing marker is ```` ``` ````.
|
||||
|
||||
- [ ] **Step 5: Add the initial project configuration**
|
||||
|
||||
Create `AGENT-EXECUTION.md` using the spec's example with role order:
|
||||
|
||||
```text
|
||||
implementation: cursor -> antigravity -> claude -> codex -> pi -> kimi -> opencode
|
||||
repair: cursor -> antigravity -> codex -> claude
|
||||
test: pi -> antigravity -> codex
|
||||
review: claude -> codex -> pi
|
||||
```
|
||||
|
||||
Set `permission: run-everything`, `fallback_to_host: all-unavailable`, `model: auto` for every profile, and no custom adapters.
|
||||
|
||||
- [ ] **Step 6: Add validation entrypoint**
|
||||
|
||||
Add `make agent-exec-validate`, running:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_exec.py validate --repo .
|
||||
```
|
||||
|
||||
In U1 the target runs `python3 -m unittest tests.test_agent_exec_config -v`. Task 3 replaces that recipe with the final `agent_exec.py validate` command.
|
||||
|
||||
- [ ] **Step 7: Run U1 green**
|
||||
|
||||
Run: `python3 -m unittest tests.test_agent_exec_config -v`
|
||||
|
||||
Expected: PASS with all parser and schema cases.
|
||||
|
||||
- [ ] **Step 8: Host integration commit**
|
||||
|
||||
The Cursor worker leaves its tree uncommitted. The host inspects changed paths, runs the same test, and commits only the four U1 paths with subject `Add project execution agent configuration` and the repository-required four body sections.
|
||||
|
||||
### Task 2: U2 Adapter Seam
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/agent_exec_adapters.py`
|
||||
- Create: `tests/test_agent_exec_adapters.py`
|
||||
- Modify: `scripts/agent_exec_config.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Profile`, `CustomAdapter`, `ExecutionPolicy`
|
||||
- Produces: `AdapterSpec`, `AdapterProbe`, `Invocation`, `probe_adapter(profile, config) -> AdapterProbe`, `build_invocation(profile, config, workspace, unit_packet, result_path) -> Invocation`
|
||||
|
||||
- [ ] **Step 1: Write adapter contract tests red**
|
||||
|
||||
Cover Cursor, `agy`, Claude, Codex, PI, Kimi, OpenCode, and custom profiles:
|
||||
|
||||
```python
|
||||
def test_cursor_run_everything_and_auto_model_argv(): ...
|
||||
def test_antigravity_run_everything_argv(): ...
|
||||
def test_claude_run_everything_argv(): ...
|
||||
def test_codex_uses_noninteractive_workspace_sandbox(): ...
|
||||
def test_pi_and_kimi_auto_model_omit_model_flag(): ...
|
||||
def test_missing_opencode_is_unavailable(): ...
|
||||
def test_fixed_model_uses_only_adapter_model_flag(): ...
|
||||
def test_custom_adapter_builds_direct_argv_without_shell(): ...
|
||||
def test_unverified_run_everything_capability_is_unavailable(): ...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run adapter tests red**
|
||||
|
||||
Run: `python3 -m unittest tests.test_agent_exec_adapters -v`
|
||||
|
||||
Expected: FAIL because the adapter registry does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement adapter types and registry**
|
||||
|
||||
Define:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AdapterSpec:
|
||||
name: str
|
||||
noninteractive_argv: tuple[str, ...]
|
||||
run_everything_argv: tuple[str, ...]
|
||||
workspace_flag: str | None
|
||||
model_flag: str | None
|
||||
output_argv: tuple[str, ...]
|
||||
prompt_transport: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterProbe:
|
||||
available: bool
|
||||
reason: str | None
|
||||
executable: str
|
||||
observed_version: str | None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Invocation:
|
||||
argv: tuple[str, ...]
|
||||
cwd: Path
|
||||
stdin: bytes | None
|
||||
sanitized_display: tuple[str, ...]
|
||||
```
|
||||
|
||||
Use direct argv with no shell. Probe via executable lookup plus bounded `--version`/`--help` inspection. Do not contact a provider during `validate`.
|
||||
|
||||
- [ ] **Step 4: Implement built-in mappings**
|
||||
|
||||
Map the currently observed CLI interfaces from the spec. Cursor uses sandboxed `--force`; Codex uses `exec`, workspace-write sandbox, and never-ask approval rather than disabling outer isolation; OpenCode returns unavailable until its installed help proves the required interface. A missing or incompatible required flag returns `unavailable` rather than silently choosing a weaker posture.
|
||||
|
||||
- [ ] **Step 5: Implement custom adapter expansion**
|
||||
|
||||
Substitute only whole-argument placeholders. Build stdin or positional prompts according to `prompt_transport`. Inherit only an allowlist of environment variables required for locale, PATH, HOME, temporary storage, and provider credential discovery by the CLI; never serialize values into receipts.
|
||||
|
||||
- [ ] **Step 6: Run U2 green**
|
||||
|
||||
Run: `python3 -m unittest tests.test_agent_exec_config tests.test_agent_exec_adapters -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Host integration commit**
|
||||
|
||||
Commit only U2 files with subject `Add execution CLI adapter registry` after host review and tests.
|
||||
|
||||
### Task 3: U3 Execution Controller
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/agent_exec.py`
|
||||
- Create: `tests/test_agent_exec.py`
|
||||
- Modify: `Makefile`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ExecutionConfig`, adapter `Invocation`, a unit packet JSON document
|
||||
- Produces: CLI commands `validate` and `run`; receipt schema `agent-exec-receipt/v1`; transport ref `refs/agent-exec/<run-id>/<attempt-id>`
|
||||
|
||||
- [ ] **Step 1: Write end-to-end controller tests red**
|
||||
|
||||
Build temporary real Git repositories and fake executables. Cover:
|
||||
|
||||
```python
|
||||
def test_validate_does_not_contact_provider(): ...
|
||||
def test_run_uses_fresh_process_and_worktree_per_attempt(): ...
|
||||
def test_missing_profiles_fall_through_in_declared_order(): ...
|
||||
def test_all_unavailable_authorizes_host_fallback_once(): ...
|
||||
def test_failed_clean_can_continue_after_exact_restore(): ...
|
||||
def test_failed_unknown_blocks_fallback(): ...
|
||||
def test_scope_expansion_preserves_workspace_and_blocks(): ...
|
||||
def test_worker_commit_or_head_move_is_rejected(): ...
|
||||
def test_untracked_and_binary_files_enter_transport_inventory(): ...
|
||||
def test_timeout_kills_process_group_and_records_receipt(): ...
|
||||
def test_summary_and_raw_log_caps_are_enforced(): ...
|
||||
def test_receipt_redacts_environment_secrets(): ...
|
||||
def test_success_creates_base_parented_transport_without_touching_canonical_head(): ...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run controller tests red**
|
||||
|
||||
Run: `python3 -m unittest tests.test_agent_exec -v`
|
||||
|
||||
Expected: FAIL because `scripts/agent_exec.py` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement unit packet validation**
|
||||
|
||||
The JSON packet requires `unit_id`, `goal`, `allowed_paths`, `verification`, `constraints`, `network`, `external_cost`, and `timeout_seconds`. Reject absolute allowed paths, `..`, `.git`, secret files, an empty goal, or a timeout above project policy.
|
||||
|
||||
- [ ] **Step 4: Implement private run state**
|
||||
|
||||
Create `/tmp/agent-exec-<uid>/<run-id>/` with mode `0700`. Record config digest, base HEAD/tree, unit digest, attempts, worktree paths, process metadata, receipts, raw logs, transport refs, and cleanup state. Refuse symlinked or foreign-owned roots.
|
||||
|
||||
- [ ] **Step 5: Implement candidate selection and fresh worktrees**
|
||||
|
||||
For each role profile, create a new detached worktree from the fixed base, build the adapter invocation, launch a new process group, and never reuse a session. Missing/incompatible candidates become `unavailable` receipts without creating a provider request.
|
||||
|
||||
- [ ] **Step 6: Implement supervision and bounded output**
|
||||
|
||||
Capture stdout/stderr to private files, enforce idle/total timeout, terminate the process group, preserve exact exit state, and write bounded sanitized summaries. A process exit alone never proves no external side effect.
|
||||
|
||||
- [ ] **Step 7: Implement scope and transport checks**
|
||||
|
||||
Verify HEAD/base did not move, inventory tracked and untracked changes, reject paths outside `allowed_paths`, then create a host-owned synthetic transport commit with the recorded base as sole parent. Worker output never writes the canonical index or branch.
|
||||
|
||||
- [ ] **Step 8: Implement state classification and fallback**
|
||||
|
||||
Classify `success`, `unavailable`, `failed-clean`, `failed-unknown`, `scope-expansion`, `invalid-config`, and `cancelled`. Emit `host-fallback-authorized` only after every configured candidate is unavailable for the unit and every attempt has exact cleanup evidence.
|
||||
|
||||
- [ ] **Step 9: Finalize CLI and Make target**
|
||||
|
||||
Implement:
|
||||
|
||||
```text
|
||||
python3 scripts/agent_exec.py validate --repo <path>
|
||||
python3 scripts/agent_exec.py run --repo <path> --role <role> --unit <packet> --receipt <path>
|
||||
```
|
||||
|
||||
Return stable exit codes for success, host fallback, invalid configuration, blocked/unknown state, and ordinary unavailable exhaustion. Update `make agent-exec-validate` to call the final CLI.
|
||||
|
||||
- [ ] **Step 10: Run U3 green**
|
||||
|
||||
Run: `python3 -m unittest tests.test_agent_exec_config tests.test_agent_exec_adapters tests.test_agent_exec -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 11: Run governance regression**
|
||||
|
||||
Run: `make test-governance`
|
||||
|
||||
Expected: PASS with no tracked worktree changes.
|
||||
|
||||
- [ ] **Step 12: Host integration commit**
|
||||
|
||||
Commit U3 files with subject `Add recoverable execution agent controller` after inspecting transport and authoritative test output.
|
||||
|
||||
### Task 4: U4 Project Governance Integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `AGENTS.md`
|
||||
- Modify: `policy/delivery.md`
|
||||
- Modify: `policy/governance.md`
|
||||
- Modify: `policy/adoption.md`
|
||||
- Modify: `policy/tool-adaptation-matrix.md`
|
||||
- Modify: `scripts/task.sh`
|
||||
- Modify: `tests/test_task.py`
|
||||
- Modify: `.task/feature-goal-first-delivery.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `agent-exec validate/run`, receipt schema, `AGENT-EXECUTION.md`
|
||||
- Produces: agent-per-unit repository rule, task-card profile/attempt/fallback fields, and adoption/verification entrypoints
|
||||
|
||||
- [ ] **Step 1: Change task-card tests red**
|
||||
|
||||
Update `test_start_imports_precheck_and_creates_four_part_commit` to require:
|
||||
|
||||
```text
|
||||
实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
|
||||
配置摘要、角色、候选顺序、attempt、CLI/模型、终态、transport、fallback 与收据路径
|
||||
```
|
||||
|
||||
Require the generated card to reject the obsolete fixed-Cursor wording.
|
||||
|
||||
- [ ] **Step 2: Run the focused task test red**
|
||||
|
||||
Run: `python3 -m unittest tests.test_task.TaskLifecycleTests.test_start_imports_precheck_and_creates_four_part_commit -v`
|
||||
|
||||
Expected: FAIL because the template still says `cursor-per-unit`.
|
||||
|
||||
- [ ] **Step 3: Replace fixed-Cursor policy with the configuration pointer**
|
||||
|
||||
Keep `AGENTS.md` short: require reading `AGENT-EXECUTION.md` when routing implementation, repair, test, or review work. Move adapter and fallback detail to the config/design and policy. Replace, rather than layer on, the current `cursor-per-unit` authority.
|
||||
|
||||
- [ ] **Step 4: Update delivery and governance policy**
|
||||
|
||||
State that external agents implement per bounded unit, host owns planning and delivery, and current-host implementation is permitted only with a valid `host-fallback-authorized` receipt. Preserve goal-first, evidence, Review, CI, merge, cleanup, and dual-key rules.
|
||||
|
||||
- [ ] **Step 5: Update task generator and capability records**
|
||||
|
||||
Generate the agent-per-unit fields and keep all initial attempt states `NOT_RUN`. Mark config parsing/controller tests as local enforced behavior, real provider/model identity as SELF unless independently attested, and remote platform admission as GATE.
|
||||
|
||||
- [ ] **Step 6: Run U4 green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.test_task.TaskLifecycleTests.test_start_imports_precheck_and_creates_four_part_commit -v
|
||||
python3 scripts/agent_exec.py validate --repo .
|
||||
make test-governance
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Host integration commit**
|
||||
|
||||
Commit only U4 paths with subject `Route project work through configured agents`.
|
||||
|
||||
### Task 5: U5 Real CLI Probes and Combination Acceptance
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/fixtures/agent-exec/README.md`
|
||||
- Modify: `tests/test_agent_exec.py`
|
||||
- Modify: `.task/feature-goal-first-delivery.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: installed CLI profiles and `agent-exec run`
|
||||
- Produces: source-external probe receipts, final requirements mapping, and an adoption recommendation
|
||||
|
||||
- [ ] **Step 1: Add opt-in real-probe harness tests**
|
||||
|
||||
Add a harness gated by `RUN_REAL_AGENT_EXEC=1`. It creates a temporary Git repository and a one-file unit packet. Each installed CLI is asked to create an exact marker file in its isolated workspace. The host verifies content, transport parent, changed paths, cleanup, model receipt status, and canonical-tree preservation. Missing OpenCode is expected `unavailable`.
|
||||
|
||||
- [ ] **Step 2: Run fake and full local regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.test_agent_exec_config tests.test_agent_exec_adapters tests.test_agent_exec -v
|
||||
make test-governance
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
Expected: PASS; any pre-existing build warning remains non-blocking unless it affects the requested capability.
|
||||
|
||||
- [ ] **Step 3: Run installed CLI probes one profile at a time**
|
||||
|
||||
Use source-external receipts and a fixed temporary repository. Run Cursor first as the implementation acceptance path, then `agy`, Claude, PI, Codex, and Kimi. Do not run profiles concurrently against the same target. A provider quota or auth failure is recorded as `unavailable` for that probe and does not become a false PASS.
|
||||
|
||||
- [ ] **Step 4: Exercise host fallback**
|
||||
|
||||
Use a configuration whose candidates are deterministic missing executables. Verify `host-fallback-authorized`, then let the current host create the marker strictly inside the receipt's allowed scope. Run the authoritative verification and record the fallback attempt separately from external-agent success.
|
||||
|
||||
- [ ] **Step 5: Verify R1-R10 and migration**
|
||||
|
||||
Map every spec requirement to config/parser/adapter/controller/governance tests and exact receipts. Confirm no active fixed-Cursor authority remains, no task outside this scope was modified, and no CLI received commit/push/PR/merge/deploy authority.
|
||||
|
||||
- [ ] **Step 6: Commit acceptance records**
|
||||
|
||||
Commit only tracked fixtures and task-card management metadata with subject `Record execution agent acceptance evidence`. Raw CLI logs and source-external receipts remain outside Git.
|
||||
|
||||
- [ ] **Step 7: Enter normal delivery gates**
|
||||
|
||||
Run `make task-sync` before formal acceptance, Review request, and merge. Obtain an independent code-review receipt, push one PR, wait for exact-HEAD `test`, `test-pg`, and `svc-realcombo`, merge through Gitea with `force_merge=false`, verify the actual main tree, and safely close the task.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
| Requirement | Owning task and proof |
|
||||
|---|---|
|
||||
| R1 project-level single configuration | Task 1 parser/schema tests; Task 4 `AGENTS.md` pointer and task-card contract |
|
||||
| R2 declarative role routing | Task 1 role-order tests; Task 3 ordered selection tests |
|
||||
| R3 optional model | Task 1 `auto -> None`; Task 2 auto/fixed-model argv tests |
|
||||
| R4 run-everything priority | Task 2 permission mapping tests; Task 5 installed-CLI probes |
|
||||
| R5 multi-CLI adapters | Task 2 fake contracts for seven adapters; Task 5 real installed-CLI outcomes |
|
||||
| R6 safe host fallback | Task 3 exhaustion, restoration, unknown-state and fallback tests; Task 5 real fallback drill |
|
||||
| R7 low host-model consumption | Task 3 output-cap tests; Task 5 receipt and raw-log boundary review |
|
||||
| R8 strict scope | Task 3 allowed-path, HEAD movement, transport, and retained-workspace tests |
|
||||
| R9 controlled custom adapter | Task 1 placeholder/shell rejection; Task 2 direct-argv custom adapter tests |
|
||||
| R10 recoverable evidence | Task 3 attempt/transport/cleanup receipts; Task 4 task-card fields; Task 5 evidence mapping |
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- R1-R10 have code, tests, and exact evidence mappings.
|
||||
- `AGENT-EXECUTION.md` validates and is the only project execution-routing authority.
|
||||
- Cursor implements every repository-writing task in this plan unless a recorded all-unavailable receipt authorizes host fallback.
|
||||
- Every successful worker result is scope-inspected, authoritatively tested, and committed by the host.
|
||||
- Fake adapters cover all built-in routes; installed real CLIs have source-external probe outcomes.
|
||||
- Full local test/build pass on the fixed candidate, independent Review has no blocking findings, and exact-HEAD CI passes before merge.
|
||||
- No deployment, platform-protection change, secret persistence, or unrelated cleanup occurs.
|
||||
@@ -0,0 +1,331 @@
|
||||
# 项目级执行 Agent 路由设计
|
||||
|
||||
状态:待书面评审
|
||||
|
||||
日期:2026-10-15(Asia/Shanghai)
|
||||
|
||||
适用范围:Agent-System-Dev 的项目级开发与交付治理
|
||||
|
||||
## 背景
|
||||
|
||||
当前候选将 Cursor CLI 固定为仓库写入的唯一实施通道。这能分离规划与实施,但不能表达不同项目的 CLI 可用性、额度、模型或角色分工,也无法在 Cursor 不可用时安全选择其他执行者。
|
||||
|
||||
目标架构将主模型限定为规划者和交付管理者。实施、修复和可下放的局部验证优先由项目配置的 Agent CLI 执行。只有配置的候选全部对当前单元不可用,且所有已启动尝试都能证明没有未知副作用时,才回退当前主模型。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 每个项目用一份 Markdown 配置文档声明可用 Agent、角色顺序、模型和执行档位。
|
||||
2. 主模型负责需求、方案、单元拆分、调度、范围审查、权威验证、集成和交付。
|
||||
3. 仓库写入默认交给外部 Agent CLI,每个实施单元使用新进程和隔离工作区。
|
||||
4. 默认使用 `run-everything` 权限语义,减少 CLI 向主模型或用户反复请求批准。
|
||||
5. 内置适配 Cursor、Antigravity `agy`、Claude、PI、OpenCode、Codex 和 Kimi,并允许受控扩展其他 CLI。
|
||||
6. 通过有界任务包、有上限的结果收据和按需读日志,降低主模型上下文消耗。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不让 Agent CLI 取得任务授权、扩围、提交、推送、PR、合入、部署或安全清理的终局权威。
|
||||
- 不把 `run-everything` 解释为可以越过秘密、数据、共享环境、平台准入或高后果双钥匙。
|
||||
- 不在 Markdown 中保存凭据值,也不允许未经结构化验证的 shell 命令模板。
|
||||
- 不在首个增量建设跨主机调度、中央队列、额度交易或自动模型评测系统。
|
||||
- 不要求所有 CLI 在首个增量具备相同的流式输出、沙箱或模型回执能力。
|
||||
|
||||
## 需求
|
||||
|
||||
| ID | 需求 | 验收条件 |
|
||||
|---|---|---|
|
||||
| R1 | 项目级单一配置 | 根目录只有一份 `AGENT-EXECUTION.md`;`AGENTS.md` 在需要实施、修复、测试或审阅调度时指向它 |
|
||||
| R2 | 声明式角色路由 | 每个角色用有序 profile 列表声明候选;选择不依赖文档顺序以外的隐式偏好 |
|
||||
| R3 | 模型可选 | `model: auto` 不传模型参数;固定模型必须通过 adapter 的路由兼容校验 |
|
||||
| R4 | `run-everything` 优先 | 实施候选默认要求非交互自动执行;adapter 不支持时该 profile 对当前角色不可用 |
|
||||
| R5 | 多 CLI adapter | 七个内置 adapter 共用同一执行收据契约;未安装 CLI 明确返回 `unavailable` |
|
||||
| R6 | 安全回退 | 只有全部候选已耗尽且无未知副作用时才返回 `host-fallback-authorized` |
|
||||
| R7 | 主模型低消耗 | 正常路径只向主模型返回有上限的摘要、改动路径、验证结果、身份与收据索引;原始日志留在仓外 |
|
||||
| R8 | 严格范围 | worker 只能修改任务包的允许路径;实际路径超出范围时保留现场并返回 `scope-expansion` |
|
||||
| R9 | 受控自定义 | `custom` adapter 只接受结构化 argv 字段和受限占位符;禁止 shell 解析、凭据值和未知环境继承 |
|
||||
| R10 | 可恢复证据 | 每个候选尝试记录配置摘要、单元、CLI/模型身份、终态、改动和清理/恢复收据 |
|
||||
|
||||
## 配置文档
|
||||
|
||||
### 路径与载入
|
||||
|
||||
`AGENT-EXECUTION.md` 是项目执行路由的单一权威。文档可包含人读说明,但只能有一个标记为 `agent-execution` 的 YAML 代码块。解析器拒绝缺失、重复或未知 schema,不从其他 Markdown 代码块猜测配置。
|
||||
|
||||
`AGENTS.md` 只保留一个上下文指针:在需要选择执行 Agent 时必读 `AGENT-EXECUTION.md`。CLI 标志、模型和回退细则不在 `AGENTS.md` 重复。
|
||||
|
||||
### 配置示例
|
||||
|
||||
```agent-execution
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: all-unavailable
|
||||
max_parallel: 3
|
||||
timeout_seconds: 7200
|
||||
summary_bytes: 16384
|
||||
raw_output_bytes: 10485760
|
||||
|
||||
roles:
|
||||
implementation: [cursor, antigravity, claude, codex, pi, kimi, opencode]
|
||||
repair: [cursor, antigravity, codex, claude]
|
||||
test: [pi, antigravity, codex]
|
||||
review: [claude, codex, pi]
|
||||
|
||||
profiles:
|
||||
cursor:
|
||||
adapter: cursor
|
||||
executable: cursor-agent
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
antigravity:
|
||||
adapter: antigravity
|
||||
executable: agy
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
claude:
|
||||
adapter: claude
|
||||
executable: claude
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
codex:
|
||||
adapter: codex
|
||||
executable: codex
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
pi:
|
||||
adapter: pi
|
||||
executable: pi
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
kimi:
|
||||
adapter: kimi
|
||||
executable: kimi
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
opencode:
|
||||
adapter: opencode
|
||||
executable: opencode
|
||||
model: auto
|
||||
enabled: true
|
||||
```
|
||||
|
||||
顺序只来自 `roles.<role>`。`profiles` 的排列不表示优先级。一个 profile 可被多个角色引用。未被角色引用的 profile 只能被 `validate` 检查,不会被自动选中。
|
||||
|
||||
### 自定义 adapter
|
||||
|
||||
`custom` adapter 为未纳入内置注册表的 CLI 提供扩展点。它不接受一整段命令字符串。项目分别声明 executable、固定 argv、模型参数、工作区参数、提示输入方式、结果格式和 `run-everything` argv。执行器通过 argv 直接启动进程,不调用 shell。
|
||||
|
||||
允许的占位符限于 `${workspace}`、`${unit_packet}`、`${result_path}` 和 `${model}`。`${model}` 在 `model: auto` 时必须整段省略,不得生成字面量 `auto`。占位符只能占用完整 argv 元素,不做字符串拼接。
|
||||
|
||||
## 模块与接口
|
||||
|
||||
### 外部 seam
|
||||
|
||||
调用者只使用两个命令:
|
||||
|
||||
```text
|
||||
agent-exec validate --repo <path>
|
||||
agent-exec run --repo <path> --role <role> --unit <packet> --receipt <path>
|
||||
```
|
||||
|
||||
`validate` 检查 Markdown/schema、profile 引用、executable、adapter 能力和模型路由兼容性,不向模型供应商发送请求。`run` 管理选择、隔离、进程、收据、恢复和回退。调用者不拼接任何供应商命令。
|
||||
|
||||
### 内部 adapter seam
|
||||
|
||||
内置 adapter 实现同一内部契约:
|
||||
|
||||
- 检查 executable 和版本能力;
|
||||
- 将标准 profile 转换为无 shell 的 argv;
|
||||
- 在 `model: auto` 时省略模型选项,否则验证并传递固定模型;
|
||||
- 将 `run-everything` 映射为当前 CLI 支持的非交互执行档位;
|
||||
- 流式或终态解析为标准 receipt;
|
||||
- 声明是否可验证 served model、沙箱、改动范围和结构化输出。
|
||||
|
||||
adapter 是 CLI 差异的唯一实现位置。任务卡、`AGENTS.md`、业务脚本和调用者不再复制参数映射。
|
||||
|
||||
## 内置 adapter 基线
|
||||
|
||||
下表是适配目标,不是永久硬编码的命令。adapter 必须根据当前安装版本检查所需参数,无法证明时返回 `unavailable`。
|
||||
|
||||
| adapter | 非交互入口 | `run-everything` 目标 | 模型 | 结构化输出 |
|
||||
|---|---|---|---|---|
|
||||
| Cursor | `cursor-agent -p` | `--force`/`--yolo`,配合可用沙箱和 `--trust` | `--model`;auto 时省略 | `--output-format stream-json` |
|
||||
| Antigravity | `agy --print` | `--dangerously-skip-permissions`,执行模式 `auto` | `--model`;auto 时省略 | JSON/stream-json 及可选 schema |
|
||||
| Claude | `claude -p` | `--permission-mode bypassPermissions` 或当前版本的等价无提示模式 | `--model`;auto 时省略 | JSON/stream-json 及 schema |
|
||||
| Codex | `codex exec` | 外层隔离下的无批准自动执行档 | `--model`;auto 时省略 | JSONL 与 output schema |
|
||||
| PI | `pi -p` | 当前版本可验证的项目信任/自动工具模式 | `--model`/可选 `--provider`;auto 时省略 | JSON 模式 |
|
||||
| Kimi | `kimi --prompt` | `--auto` | `--model`;auto 时省略 | stream-json |
|
||||
| OpenCode | 从已安装版本的非交互入口探测;未安装返回 `unavailable` | 从已安装版本验证无提示档位;无可验证档位返回 `unavailable` | 固定模型只在当前版本支持时传递 | 从当前版本能力探测并归一化 |
|
||||
|
||||
本机 2026-10-15 的只读检查发现 Cursor、`agy`、Claude、PI、Codex 和 Kimi;OpenCode 未安装。该观察只用于设计与后续验收准备,不是 adapter 已实现的证据。
|
||||
|
||||
## 实施单元与任务包
|
||||
|
||||
主模型在调用 `run` 前将工作拆成可独立实施与验证的单元。任务包是 Markdown 或 JSON,至少包含:
|
||||
|
||||
- 单元 ID 和可观测目标;
|
||||
- 允许路径、必要上下文和显式非目标;
|
||||
- 已批准决定与不得改变的用户意图;
|
||||
- 验收条件和权威验证命令;
|
||||
- 是否允许网络、外部成本、新依赖或共享资源;
|
||||
- 输出大小、超时和停止条件。
|
||||
|
||||
任务包不包含整段会话历史、凭据、无关规划或可由 worker 从仓库定位的大文件内容。主模型传路径和精确版本,worker 在隔离工作区读取必要源码。
|
||||
|
||||
## 选择、执行与回退
|
||||
|
||||
### 选择
|
||||
|
||||
1. 解析并验证配置。配置无效直接返回 `invalid-config`,不触发主模型回退。
|
||||
2. 从 `roles.<role>` 读取有序 profile,过滤 `enabled: false` 和无对应 adapter 的项。
|
||||
3. 执行无供应商请求的本地能力检查:executable、版本、必需参数和结构化输出能力。
|
||||
4. 按声明顺序尝试候选。开始后不允许 adapter 内部换供应商、换 CLI 或换模型。
|
||||
|
||||
### 终态
|
||||
|
||||
| 终态 | 含义 | 下一动作 |
|
||||
|---|---|---|
|
||||
| `success` | worker 完成且输出可检查 | host 检查实际 diff,运行权威验证后集成 |
|
||||
| `unavailable` | CLI/版本/模型/非交互能力不可用,且尚未产生写入 | 尝试下一 profile |
|
||||
| `failed-clean` | 尝试失败,但隔离工作区和已授权外部对象均已精确恢复 | 保留失败收据,将该 profile 标为对本单元不可用,尝试下一个 |
|
||||
| `failed-unknown` | 外部请求、副作用或恢复结果不明 | 停止调度并对账,不换 Agent,不回退主模型 |
|
||||
| `scope-expansion` | worker 要求超出任务包 | 由主模型提交最小扩围决策 |
|
||||
| `invalid-config` | schema、profile 引用或 custom adapter 不合法 | 修复项目配置,不回退 |
|
||||
| `cancelled` | 用户、主模型或策略取消 | 保留收据并清理可证明安全的工作区 |
|
||||
|
||||
### 主模型回退
|
||||
|
||||
当每个候选均以 `unavailable` 或已精确恢复的 `failed-clean` 耗尽,执行器生成 `host-fallback-authorized` 收据。收据包含完整候选顺序、每次失败原因、恢复证明、任务包摘要和主模型可继续的精确范围。主模型只在收到该收据后直接实施。
|
||||
|
||||
`failed-unknown`、`scope-expansion`、`invalid-config` 和安全边界失败均不计入“全部不可用”,不能触发主模型回退。
|
||||
|
||||
## `run-everything` 与安全边界
|
||||
|
||||
`run-everything` 表示候选 CLI 在任务包范围内自动执行编辑和命令,不向用户或主模型逐项请求批准。它必须与以下执行端控制同时使用:
|
||||
|
||||
- 每单元独立工作区与独立进程组;
|
||||
- 输入任务包和允许路径固定;
|
||||
- worker 不获得主仓索引、推送、PR、合入、部署和安全清理权威;
|
||||
- 网络、外部成本、共享环境和新依赖按任务包单独授权;
|
||||
- 超时时终止整个进程组;
|
||||
- 实际改动路径与允许路径差异触发 fail-stop;
|
||||
- 命令、输出、摘要和收据统一脱敏。
|
||||
|
||||
如果某 CLI 的“全自动”参数同时关闭沙箱或扩大文件访问,adapter 只能在外层隔离已证明时启用该参数。外层隔离不可用时,该 profile 返回 `unavailable`,不静默降低权限档位或更改安全边界。
|
||||
|
||||
## 低消耗上下文策略
|
||||
|
||||
1. 主模型传递路径、固定 SHA 和有界单元包,不内联整个仓库或会话。
|
||||
2. worker 自行在隔离工作区定位相关源码,原始事件流写入仓外日志。
|
||||
3. 正常返回只包含状态、CLI/模型身份、改动路径、有上限的摘要、局部验证、范围请求和收据索引。
|
||||
4. 主模型先检查改动清单、diff 统计、worker 证据和高风险区域;仅在异常、冲突、审阅 finding 或验证失败时读取详细日志。
|
||||
5. 代码 Review 和测试分析可按项目配置下放给其他 CLI,但最终验证对象、出口码、证据来源和合入决定仍由 host 核对。
|
||||
|
||||
## 收据
|
||||
|
||||
每次 `run` 产生一份结构化收据,至少包含:
|
||||
|
||||
- schema 版本、run ID、单元 ID、role 和配置摘要;
|
||||
- 候选 profile、adapter、executable 观察版本、请求/实际模型及可核验状态;
|
||||
- 权限档位、隔离档位、网络/成本范围和输出上限;
|
||||
- 开始/终止时间、进程终态、退出码、终态分类和原因;
|
||||
- 实际改动路径、transport/diff 标识、worker 局部验证和原始日志摘要;
|
||||
- 恢复、清理、下一 profile 选择或 `host-fallback-authorized` 的依据。
|
||||
|
||||
收据摘要不证明 worker 身份或验证权威。任务卡和 PR 仍按 `SELF`/`REPLAY`/`GATE` 标注证据来源。
|
||||
|
||||
## 错误与恢复
|
||||
|
||||
- 启动前失败不得创建输出改动,可安全转下一 profile。
|
||||
- 已启动尝试必须先终态化,再判断是否可恢复。进程退出、超时或断连均不证明没有副作用。
|
||||
- 只有隔离工作区、transport、进程组和已授权外部对象都能对账时,才记为 `failed-clean`。
|
||||
- 恢复不删除失败收据。每次重试使用同一逻辑 run ID 和新 attempt ID。
|
||||
- 检查到范围外改动时保留独立工作区和 transport,host 不自动挑选其中部分改动。
|
||||
- 已完成并集成的单元不因主模型会话中断而重新执行。
|
||||
|
||||
## 验证方案
|
||||
|
||||
### 配置与解析
|
||||
|
||||
- 缺失、重复、未知 schema 和非法 YAML 拒绝;
|
||||
- 未知 profile、重复 role 引用、非法模型和不合法 custom argv 拒绝;
|
||||
- 凭据值、shell 字符串和占位符拼接拒绝;
|
||||
- `model: auto` 证明模型参数整段省略。
|
||||
|
||||
### 适配器契约
|
||||
|
||||
每个内置 adapter 用 fake CLI 覆盖:
|
||||
|
||||
- 实际 argv、工作区、任务包和结果路径;
|
||||
- `run-everything` 映射;
|
||||
- auto 和固定模型;
|
||||
- 结构化与纯文本结果归一化;
|
||||
- 非零退出、超时、无结果、超大输出和进程组终止;
|
||||
- 脱敏后不存在 token、Authorization 头或已知 secret 值。
|
||||
|
||||
### 路由与回退
|
||||
|
||||
- 按 role 顺序选择;
|
||||
- 缺失 executable 继续下一 profile;
|
||||
- 一个 profile 失败且精确恢复后继续;
|
||||
- 候选全部耗尽后生成 `host-fallback-authorized`;
|
||||
- `failed-unknown`、`scope-expansion` 和 `invalid-config` 不得触发回退;
|
||||
- 每个单元和每个尝试均使用新进程,不复用 CLI 会话。
|
||||
|
||||
### 主模型消耗
|
||||
|
||||
- 任务包不包含原始会话或全仓源码;
|
||||
- 正常收据摘要不超过 `summary_bytes`;
|
||||
- 原始输出不进入主模型上下文;
|
||||
- 只有失败、finding、扩围或验证不一致会触发详细日志读取。
|
||||
|
||||
### 真实 CLI 最小探针
|
||||
|
||||
当前安装的 Cursor、`agy`、Claude、PI、Codex 和 Kimi 分别执行一次无业务副作用的隔离探针,核对启动、非交互执行、输出归一化和收据。探针仍属 `SELF`,不代替对真实实施单元的业务验收。OpenCode 在未安装环境中的期望结果是 `unavailable`。
|
||||
|
||||
## 分阶段交付
|
||||
|
||||
1. **U1 配置合同**:`AGENT-EXECUTION.md` schema、Markdown 解析、验证器和项目示例。
|
||||
2. **U2 adapter seam**:通用 adapter 契约、fake CLI 夹具与七个内置 adapter。
|
||||
3. **U3 执行器**:单元包、隔离工作区、进程监督、范围检查、恢复、路由、收据和主模型回退。
|
||||
4. **U4 项目接线**:`AGENTS.md` 指针、任务卡生成器、evidence 和交付流程接入。
|
||||
5. **U5 组合验收**:当前安装 CLI 真实探针、主模型消耗边界、失败/恢复、回退和完整治理回归。
|
||||
|
||||
每个增量都必须保持现有 goal-first、严格范围、证据和平台准入边界。U1 不启用新路由;U2 只验证 adapter;U3 建立可用执行闭环;U4 才将新路由接入日常任务;U5 决定是否取代当前固定 Cursor 候选。
|
||||
|
||||
## 迁移与兼容
|
||||
|
||||
- 当前分支上的 `cursor-per-unit` 是历史候选,不是新架构的并行权威。实施时修订原条款为项目配置驱动的 `agent-per-unit`,不再叠加一套路由规则。
|
||||
- 新路由只在 `AGENT-EXECUTION.md` 存在、`validate` 通过且项目明确采纳后生效。未采纳项目保持原行为。
|
||||
- 第一个采纳项目是 Agent-System-Dev。其他组件仓需要各自的配置、预检查、Review 和合入,不因 Dev 配置自动生效。
|
||||
- 旧任务卡无需批量重写。恢复旧任务时记录它使用的历史执行规则,新实施单元在采纳后使用新收据。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
### 采用:声明式配置 + 内置 adapter + 受控 custom adapter
|
||||
|
||||
该方案将 CLI 差异收敛在一个深模块中。项目配置只表达选择和约束,调用者只学习 `validate` 和 `run` 两个接口。新 CLI 通过新 adapter 或受控 `custom` 加入,不修改每个任务调用点。
|
||||
|
||||
### 拒绝:原始 shell 命令模板
|
||||
|
||||
这种方案灵活,但会将引号、占位符、凭据、shell 注入、版本差异和输出解析分散到每个项目。它无法保持一致脱敏、恢复和收据,不采用。
|
||||
|
||||
### 暂不采用:独立中央调度服务
|
||||
|
||||
中央服务可以统一多主机配额和执行者管理,但会引入运行服务、身份、租约和高可用边界。当前问题是单项目的 CLI 路由和主模型消耗,项目内深模块足以解决,不预支多主机复杂度。
|
||||
|
||||
## 完成条件
|
||||
|
||||
本设计可进入实施计划的条件是:
|
||||
|
||||
- 用户审阅并批准本文档;
|
||||
- 配置路径、schema、回退语义、`run-everything` 边界和五个增量无未决取舍;
|
||||
- 实施计划将 R1–R10 映射到工作项、测试和可判定出口;
|
||||
- 实施、正式验证、Review、合入和采纳仍分别保留对象与证据。
|
||||
+29
@@ -144,6 +144,9 @@ scripts/svc.sh <name> deploy [--kind agent|static] [--from 源] [--tag T]... [
|
||||
|
||||
**agent**:来源见 [§5](#5-命令参考部署来源);启动参数组装见 [§9 配置系统](#9-配置系统);
|
||||
健康后按 `--model-*` / `--light-*` / `--tavily-keys` 播种(幂等,设置存在 agent 数据目录)。
|
||||
密钥表 `secret_keys` 的生效行优先于设置明文;换 key 后以 `/api/secrets` 为准。
|
||||
当前后端在 `PUT /api/settings/system` 收到非掩码密钥时,会把它写成系统行的生效密钥。
|
||||
嵌入式库中生效密钥与环境变量指纹不一致时,播种后告警,不打印明文。
|
||||
|
||||
**static**:
|
||||
|
||||
@@ -381,6 +384,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(总览)
|
||||
@@ -789,7 +813,12 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
|
||||
| `SVC_SRC_DIR` | 独立部署源仓路径(默认 `~/.cache/agent-system-svc/src`) |
|
||||
| `SVC_PG_BIN` | PostgreSQL 工具链 bindir |
|
||||
| `SVC_MODEL_BASE/KEY/NAME` | 主模型(播种 `settings.models.chat`) |
|
||||
| `SVC_MODEL_FALLBACKS` | 主模型回退链(逗号分隔,播种 `settings.models.chat.fallbacks`) |
|
||||
| `SVC_LIGHT_BASE/KEY/NAME` | 轻量模型 |
|
||||
| `SVC_VISION_MODEL` | 视觉兜底模型(播种 `settings.models.vision.model`) |
|
||||
| `SVC_VISION_CAPABLE` | 可看图模型清单(逗号分隔,播种 `settings.models.vision.capable`) |
|
||||
| `SVC_MINIMAX_BASE/KEY` | 原 MiniMax 端点(播种 `settings.models.reserve.minimax`,不替换当前对话模型) |
|
||||
| `SVC_MINIMAX_CHAT/FAST` | 原 MiniMax 对话模型与快速模型名 |
|
||||
| `SVC_TAVILY_KEYS` | Tavily 密钥池(逗号分隔) |
|
||||
|
||||
### 9.4 持久化语义
|
||||
|
||||
+1
-1
Submodule modules/backend updated: 14e780c6fa...92f9286e6c
+1
-1
Submodule modules/core updated: 5773e74086...1a5ef6b75c
+1
-1
Submodule modules/documents updated: 6b3031acbd...c313639208
+1
-1
Submodule modules/frontend updated: 710ef836b5...781cde6667
+1
-1
Submodule modules/ui updated: 7c35a78abc...c9187473fb
@@ -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`。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-5
@@ -6,30 +6,31 @@
|
||||
- 过程文档位置: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 记录为准。
|
||||
|
||||
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
|
||||
|---|---|---|
|
||||
| core/baseline.md | 日常 | manual |
|
||||
| workflows/delivery.md(含启动自检 §1) | 交付 | 本地命令检查;首次收据与 BUSY/SYNC/REMOTE_UNVERIFIED 语义见 policy/governance.md |
|
||||
| standards/collaboration.md | 并行/同步节点 | **enforced(本地命令)**:分支、现场、组件检查;task-sync 实际合并时记录,同基线只核验。最终平台与阶段授权另验 |
|
||||
| standards/collaboration.md | 并行/同步节点 | **enforced(本地命令)**:分支、现场、组件检查;task-sync 实际合并时记录,同基线只核验。最终平台准入与任务授权边界另验 |
|
||||
| 提交格式(主题行/四段 body) | 全部提交 | 主题行 enforced(local),body manual;任务工具生成四段 body,历史 PR 仅作旧版本证据 |
|
||||
| standards/verification.md | 验收 | 本地 make test-governance + 组合测试;当前证据与历史 scratch 记录分开 |
|
||||
| standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
|
||||
| 工作区回收 | 交付终点 | advisory(task-finish);负例:合入前 finish 拒绝输出 |
|
||||
| policy/delivery.md(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | manual(人工/Agent 执行):前序人工决策与有范围的连续推进授权;智能体完成 Review、冲突处置和当前 HEAD 的 CI 核验后合入;任务卡提供记录,post-merge 默认只提示,行为回归见 tests/test_task.py |
|
||||
| policy/delivery.md(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | goal-first:目标/范围/终点一次确认,范围内连续推进验证、Review、合入和安全收口;实施写入按 agent-per-unit(配置 AGENT-EXECUTION.md;外部 Agent 一单元一新进程;host 仅在有效 host-fallback-authorized 收据后可写实施变更);仅真实决策点需人工;任务卡提供记录,post-merge 默认只提示,行为回归见 tests/test_task.py |
|
||||
| AGENT-EXECUTION.md + agent-exec | 项目执行路由 | 本地 SELF:parser/adapter/controller 假 CLI 契约由 tests/test_agent_exec*.py 与 make agent-exec-validate 强制;已安装 provider/model 真实身份待 U5 探针,此前不接受为已就位;远端平台准入仍为 GATE |
|
||||
| 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 不是原子锁,平台保护仍承担最终准入 |
|
||||
|
||||
## 项目差异
|
||||
|
||||
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|
||||
|---|---|---|---|
|
||||
| baseline 硬边界 1(授权范围) | 高后果双钥匙:改写历史/解除分支保护/删归档/发布——Agent 只准备,用户执行(AGENTS.md 条文) | 单人+Agent 场景下消除单点;风险=用户不在场时高后果操作排队 | weichen;首次触发时复查 |
|
||||
| delivery.md §2、design-verification.md 的轻量流程默认做法 | 按 [policy/delivery.md](delivery.md) 保留需求、方案、实施与验收的人工节点;代码 Review 与 PR 合入交由智能体,冲突解决且当前 HEAD 的 CI 通过后合入;小任务简化篇幅,阶段内可迭代 | 2026-10-12 用户明确要求智能体 Review 后合入,合入前解决冲突且 CI 通过;其他人工决策与高后果双钥匙边界保留 | weichen;Review、CI 或平台准入条件变化时复查 |
|
||||
| delivery.md §2、design-verification.md 的轻量流程默认做法 | 按 [policy/delivery.md](delivery.md) 保留阶段产物和验证,移除逐阶段人工签字;目标、范围和终点一次确认后,智能体连续完成范围内交付,只在结果变更、重大取舍、高后果操作或用户专属验收时请求决策 | 2026-10-15 用户要求目标优先、绝不私自扩围、先可用再迭代,并移除可移除的人工环节;独立 Review、证据、平台准入和高后果双钥匙保留 | weichen;目标/授权边界、Review、CI 或平台准入条件变化时复查 |
|
||||
|
||||
- 历史记录(2026-09-30)报告 11 个组合仓 main 禁直推、拒绝陈旧候选;当前平台能力须独立重验,本地脚本测试不替代远端证据。
|
||||
- 规范源码以 gitlink 及其 VERSION 为准,流程启用以本采纳状态及项目条件为准;固定源码版本不代替实际能力证明。治理可靠性修复的命令与证据约定见 [开发门禁](governance.md),本地与客户端/平台能力分别登记;本轮不改变分支保护或发布授权。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"backend/app backend/cardsvc",
|
||||
"backend/app backend/cmdbus",
|
||||
"backend/app backend/config",
|
||||
"backend/app backend/execenv",
|
||||
"backend/app backend/genui",
|
||||
"backend/app backend/hostapi",
|
||||
"backend/app backend/ingest",
|
||||
@@ -28,7 +29,9 @@
|
||||
"backend/app backend/tools",
|
||||
"backend/app backend/workflow",
|
||||
"backend/cardsvc backend/store",
|
||||
"backend/ctxasm backend/vision",
|
||||
"backend/hostapi backend/store",
|
||||
"backend/ingest backend/vision",
|
||||
"backend/pgbus backend/bus",
|
||||
"backend/pgbus backend/store",
|
||||
"backend/plugins backend/settings",
|
||||
@@ -61,9 +64,11 @@
|
||||
"backend/runtime backend/quality",
|
||||
"backend/runtime backend/squadadapter",
|
||||
"backend/runtime backend/wfcompile",
|
||||
"backend/runtime backend/vision",
|
||||
"backend/runtime backend/workflow",
|
||||
"backend/server backend/cardsvc",
|
||||
"backend/server backend/cmdbus",
|
||||
"backend/server backend/execenv",
|
||||
"backend/server backend/ingest",
|
||||
"backend/server backend/intentgate",
|
||||
"backend/server backend/plugins",
|
||||
@@ -82,6 +87,7 @@
|
||||
"backend/squadadapter backend/quality",
|
||||
"backend/store backend/blobstore",
|
||||
"backend/surfacesvc backend/store",
|
||||
"backend/tools backend/execenv",
|
||||
"backend/tools backend/quality",
|
||||
"backend/wfcompile backend/projectcontext",
|
||||
"backend/wfcompile backend/tools",
|
||||
|
||||
+146
-40
@@ -2,53 +2,159 @@
|
||||
|
||||
适用:本项目的需求、修复、规范修改和其他交付任务。依据固定版本的
|
||||
[通用交付流程](../deps/development-guidelines/workflows/delivery.md),本项目收紧为
|
||||
**需求、方案、实施与验收默认逐阶段人工评审;代码 Review 与 PR 合入由智能体完成;
|
||||
阶段内迭代,按影响回退,按增量交付**。
|
||||
本文件是阶段与评审的权威表述;启动、同步、命令证据和回收操作见
|
||||
**目标、范围和交付终点一次确认;范围内连续推进;问题按是否阻塞目标分流;
|
||||
先可用、再迭代完善**。代码 Review 和 PR 合入由智能体完成。
|
||||
本文件是阶段、决策点与连续执行的权威表述;启动、同步、命令证据和回收操作见
|
||||
[开发门禁](governance.md)。规则的执行能力见 [能力矩阵](tool-adaptation-matrix.md)。
|
||||
|
||||
## 授权与阶段评审
|
||||
## 最高优先级:目标兑现与最小充分交付
|
||||
|
||||
任务卡默认记为 `manual`。阶段 1–7 完成当前阶段产物并取得人工评审结论后,才进入下一阶段;
|
||||
阶段 8–9 由智能体执行 Review 和合入,条件见下文「智能体 Review 与 PR 合入」。
|
||||
评审记录包含阶段、对象版本、评审人、时间、结论、意见及可追溯依据;结论使用
|
||||
未评审、通过、需修改、拒绝、不适用。人工结论与命令执行证据分开记录;Agent 自审、
|
||||
测试通过、工具写入或用户沉默均不能代替阶段 1–7 的人工批准,也不能由作者代签独立审核。
|
||||
本节自写入起立即生效,并在不削弱既有授权、证据、Review、CI、合入、清理与高后果双钥匙
|
||||
边界的前提下,优先于本文件其他关于阶段、编排和完善程度的解释。
|
||||
|
||||
只有用户明确要求自动或连续推进时,才将对应范围记为 `authorized-continuous`,记录
|
||||
授权来源、覆盖阶段、允许动作、交付终点、资源约束和停止条件。未覆盖的人工节点仍逐阶段
|
||||
人工评审;智能体 Review/合入按阶段 8–9 的职责与条件执行。
|
||||
授权连续推进不免除需求分析、试验判定、测试、代码 Review、证据和平台准入;
|
||||
既有独立审核要求及高后果操作双钥匙仍有效。智能体合入职责不补齐尚不存在的前序产物。
|
||||
### 先做用户要求,不把任务升级成工程项目
|
||||
|
||||
阶段内已授权的调查、编辑、自检、缺陷修复和等价实现可以持续进行,无需逐命令批准。
|
||||
需求拆解、评估、需求表可以共用一份任务卡,但分别保留阶段结论。小任务缩短篇幅;
|
||||
确实不适用的内容说明原因,经人工确认或在明确授权的裁剪范围内处理,不能静默跳步。
|
||||
任务启动检查、独立工作区和收据用于准备现场,不表示任何阶段评审已通过。
|
||||
- 用户明确要求的产物、范围、验收条件和交付终点是当前工作的唯一主线。基础设施、工具修复、
|
||||
通用化、加固、重构、迁移和新架构只有在用户明确要求,或有可复核证据证明其直接阻塞当前
|
||||
目标时才能进入当前范围。
|
||||
- Markdown、配置、提示词、链接和说明等简单修改默认走最小闭环:定位 → 修改 → 最小直接验证
|
||||
→ 报告。不得为此新建平台、框架、自动化系统、嵌套控制器、额外 worktree、复杂收据体系、
|
||||
新 PR 模板或新的阶段审批。
|
||||
- Agent 路由、任务卡和证据工具都是交付手段,不是交付目标。候选 CLI 迟迟不产出时按配置及时
|
||||
切换或按既有回退规则处理;不得转而长期建设或加固路由系统,导致用户要求的简单产物一直未交付。
|
||||
|
||||
### 最小可用即停,问题按是否阻塞分流
|
||||
|
||||
- 首次达到“用户要求的产物可用、受影响系统可用、最小直接验证通过”后,立即停止新增工作并
|
||||
进入交付。后续体验、结构、性能、命名、治理与文档完善是新的增量,须由用户明确要求或另行授权。
|
||||
- 只修复直接导致目标无法实现、系统不可用、明确验收失败或强制安全门禁失败的问题。高价值但
|
||||
不阻塞的问题只记录事实、影响与建议归属;低影响噪音直接忽略,不创建任务、不阻塞交付。
|
||||
- Review 和 CLI 迭代只处理阻塞项。阻塞项闭合、请求产物可用且最小验证通过后循环立即结束;
|
||||
不得以“更完整”“更安全但并非门禁”“更通用”或“顺手清理”为由继续循环。
|
||||
|
||||
### 验证与编排必须成比例
|
||||
|
||||
- 实施阶段运行能直接证明本次修改的最小检查。完整或长耗时测试只在最终候选上运行一次,且仅限
|
||||
本仓规范明确要求、真实影响面覆盖广,或最小测试不足以判断回归的情况。
|
||||
- 禁止在每个小文档、配置、提示词或 adapter 修改后重复完整套件;已经针对同一候选取得且对象
|
||||
未变化的有效结果不得因记录、格式或非阻塞意见重复执行。
|
||||
- 编排成本不得超过任务本身。平凡单元最多启动一个有界 CLI 进程;主模型负责计划、范围与审查,
|
||||
但角色分工不得推迟简单产物。检测到实现粒度与任务不匹配时,立即降级到最小充分做法。
|
||||
- 移除机械人工停顿不等于增加机械 Agent 停顿。真实业务取舍、授权变化、高后果操作和必须由用户
|
||||
判定的验收仍请求人工;除此之外,不为计划、工具、自审或非阻塞完善反复等待。
|
||||
|
||||
## 授权、决策点与连续执行
|
||||
|
||||
任务卡默认记为 `goal-first`。用户的直接请求在目标、允许动作、写入对象和交付终点足够明确时,
|
||||
即是对该范围的执行授权;方案、审计或只读请求仍不包含业务实施,本地实施不自动包含部署、迁移或共享环境写入。
|
||||
授权一旦成立,范围内的调查、需求拆解、方案选择、计划、编辑、必要缺陷修复、测试、Review、PR 合入和安全收口连续推进,
|
||||
不逐阶段、逐文件、逐命令重复请求批准。阶段用于追溯和质量控制,不是默认的人工停顿点。
|
||||
|
||||
只有以下情况请求人工决策:
|
||||
|
||||
- 目标、范围、优先级、验收条件或交付终点将发生变化;
|
||||
- 多个可行选项会造成不同的用户结果、公开契约、权限/数据语义或持续运维成本,且现有决策无法确定取舍;
|
||||
- 触及未授权共享环境、外部资源/成本、不可逆或本仓定义的高后果双钥匙操作;
|
||||
- 用户感受、业务判断、用户持有的凭据/设备或平台 owner 操作使验收无法由可复核证据自动完成。
|
||||
|
||||
存在决策点时,只暂停受影响部分,提交具体选项、影响和推荐;其他已授权且不受影响的工作继续。
|
||||
用户沉默不补齐真实决策点,但 Agent 自审、测试进展和工具记录也不得被包装成新的人工门禁。
|
||||
独立 Review、证据、平台准入和高后果双钥匙仍有效;它们保证交付质量和安全,不扩大业务授权。
|
||||
|
||||
需求拆解、评估、方案和计划可以共用一份任务卡。小任务只记录目标、范围、验收、关键决定和验证;
|
||||
不适用的阶段标记原因后直接跳过。任务启动检查、独立工作区和收据用于准备现场,不表示业务结果已验证。
|
||||
|
||||
## Agent 实施单元与主模型职责
|
||||
|
||||
产生仓库变更的实施工作拆成可独立实施的单元。任务卡默认记
|
||||
`实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。执行路由以根目录
|
||||
[AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 为唯一权威;按角色声明的候选顺序在新进程与
|
||||
隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程。
|
||||
|
||||
主模型(host)负责计划、拆解、有界单元包、强制范围、检查与审阅实际 diff、运行权威验证、
|
||||
规范提交、管理 PR/CI/合入、写入证据与任务管理元数据,以及安全清理。权威验证、集成、合入与
|
||||
任务卡证据所有权属于 host;worker 的局部自检不能替代任务卡证据、独立 Review、当前 HEAD 的 CI
|
||||
与平台准入。
|
||||
|
||||
每个外部 worker 只接收一个有界单元。Worker 不得扩围、提交、推送、打开或合并 PR、派遣同伴、
|
||||
改变路由或模型、部署,或清理规范 worktree。
|
||||
|
||||
`run-everything` 仅在隔离单元内取消逐步命令批准提示;不扩大用户授权范围,也不授予 worker
|
||||
规范 Git、推送、PR、合入、部署或清理权威。
|
||||
|
||||
当前 host **仅当** `scripts/agent_exec.py run` 对该精确单元发出有效
|
||||
`host-fallback-authorized` 收据后,方可直接撰写该单元的仓库变更。全部候选须已安全耗尽
|
||||
(`unavailable` 或已精确恢复的 `failed-clean`)。`failed-unknown`、`scope-expansion`、无效配置、
|
||||
未对账 Git 状态与安全失败均不授权回退。规划、只读调查、证据捕获、控制器/任务卡元数据与集成
|
||||
操作属于 host 管理工作,不递归创建另一实施单元。
|
||||
|
||||
本条不重新引入逐阶段人工门禁;goal-first 的决策点、严格范围、先可用再迭代、独立 Review、
|
||||
证据、平台准入与高后果双钥匙规则继续有效。
|
||||
|
||||
### 阶段会话隔离与独立 Review
|
||||
|
||||
为避免实施上下文、既有结论和自我辩护污染后续判断,阶段执行遵循“一个阶段/单元,一个新会话”:
|
||||
|
||||
- 需求分析、方案比较、设计、计划、实施单元、测试、Review 和验收默认分别启动全新会话。主模型可持续
|
||||
保存调度与任务状态,但交给 CLI 的每个阶段必须是新进程、新 conversation/thread、新 session;
|
||||
不跨阶段复用实现会话。
|
||||
- Review 只有两种有效执行者:当前 host 主模型,或单独启动的全新 CLI session。实施 CLI 可以做局部
|
||||
自检,但其原 session、自我总结和续聊结果不构成独立 Review。
|
||||
- 禁止评审调用使用 `--continue`、`--resume`、既有 conversation ID、session ID/文件、共享交互窗格,
|
||||
或任何会恢复实施上下文的等价方式。即使使用相同 CLI 或相同模型,也必须有新的 session 身份。
|
||||
- 评审包只包含:固定目标与验收条件、适用规范、base/head、实际 diff、直接相关测试及来源明确的证据。
|
||||
默认不包含实施对话、隐藏推理、中间自评、失败尝试叙述和原 session 历史;需要背景时提供最小可复核
|
||||
事实或产物链接,不复制整段上下文。
|
||||
- 任务卡/收据为各阶段记录 `stage`、工具、模型、attempt、session ID 与 `fresh_session=true`。CLI 不提供
|
||||
session ID 时记录 `not-observable`,同时记录新进程命令及未使用恢复参数的事实。无法证明独立性时,
|
||||
结果只能标为作者自检 `SELF`,不得标为独立 Review/REPLAY。
|
||||
- 会话隔离不要求为简单任务增加新平台或复杂编排。平凡文档/配置任务仍按最小闭环执行;需要独立 Review
|
||||
时只额外启动一个最小、一次性的评审 session,不复用实施 session,也不扩展任务范围。
|
||||
|
||||
## 目标优先、范围控制与问题分流
|
||||
|
||||
执行顺序为:
|
||||
|
||||
` 授权与安全边界 → 任务目标和系统可用 → 直接阻塞项 → 后续改进 `。
|
||||
|
||||
当前任务的完成标准是已批准的验收条件成立、目标用户链路可用,且受影响的既有关键能力没有明确回归。
|
||||
完成不要求把所有邻近代码、历史欠账、工具噪音和低影响建议同时清零。先形成端到端可用、可验证的最小闭环,
|
||||
再把体验、结构、性能和治理优化作为独立增量。
|
||||
|
||||
新发现按以下标准处理:
|
||||
|
||||
| 类别 | 判定 | 当前任务动作 |
|
||||
|---|---|---|
|
||||
| 阻塞项 | 直接导致目标无法实现、验收条件失败、系统/关键回归不可用、安全边界违反或必需门禁失败 | 在已授权范围内作最小充分修复;若超出范围,提交最小扩围决策 |
|
||||
| 有价值非阻塞项 | 当前目标仍可交付,但有具体影响、复发风险或明确后续价值 | 只记录事实、影响和建议归属,不在当前任务修复,不阻塞交付 |
|
||||
| 低影响噪音 | 不影响目标、可用性、安全、回归或后续决策 | 忽略或在当前记录中一句带过;不新建任务、不阻塞、不扩围 |
|
||||
|
||||
“影响目标”只决定问题的优先级,不自动扩大授权。“相关”、“顺手”、“重构更干净”、“以后可能用到”都不是当前实施依据。
|
||||
Review 只将阻塞项列为必修;非阻塞意见标明后续建议,不得以“更完善”为由拒绝已达标且可用的当前增量。
|
||||
|
||||
## 阶段与出口
|
||||
|
||||
| 阶段 | 必需产物 | 进入下一阶段的条件 |
|
||||
|---|---|---|
|
||||
| 1 需求拆解 | 目标、用户场景、子需求、边界、非目标及待澄清问题 | 人工确认拆解与原始意图一致 |
|
||||
| 2 需求评估 | 价值、可行性、成本、风险、依赖、现有能力及在途任务重叠;与项目定位、模块职责和公开契约的符合度 | 人工确认取舍;重大未知转入下述受控试验 |
|
||||
| 3 需求表确认 | 稳定 ID、描述、适配依据、优先级、可判定验收条件、依赖和处理结论 | 人工确认本增量范围;影响实施的关键待核实项已处理 |
|
||||
| 4 方案设计 | 推荐方案与主要备选、职责和接口、数据与权限影响、失败恢复、待验证假设及试验结论 | 人工批准当前方案版本及剩余风险处理方式 |
|
||||
| 5 实施计划 | 关联需求的工作项、依赖顺序、责任人、文件或模块范围、测试方法与环境、完成条件及停止条件 | 人工批准当前实施和测试计划;远期工作可保留粗粒度 |
|
||||
| 6 实施与自检 | 可审查变更、局部测试、计划偏差与迭代记录 | 人工确认当前增量实现产物,必要设计调整已重新获批 |
|
||||
| 7 正式测试与验收 | 需求到证据映射;正常、异常、边界、回归结果及缺项 | 人工审阅当前候选的验证结果;正式验收前同步并固定被测对象 |
|
||||
| 1 需求拆解 | 目标、用户场景、子需求、边界、非目标及待澄清问题 | 目标、授权范围和交付终点可执行;只有会改变结果的歧义才触发决策点 |
|
||||
| 2 需求评估 | 价值、可行性、成本、风险、依赖、现有能力及在途任务重叠;与项目定位、模块职责和公开契约的符合度 | 无需用户取舍的评估由智能体结论;重大未知转入受控试验或具体决策点 |
|
||||
| 3 需求表确认 | 稳定 ID、描述、适配依据、优先级、可判定验收条件、依赖和处理结论 | 当前增量与授权一致;影响实施的关键事实已核实或显式标注限制 |
|
||||
| 4 方案设计 | 推荐方案与必要备选、职责和接口、数据与权限影响、失败恢复、待验证假设及试验结论 | 在已批准目标内选定最小充分方案;只有重大取舍触发人工决策 |
|
||||
| 5 实施计划 | 关联需求的工作项、依赖顺序、责任人、文件或模块范围、测试方法与环境、完成条件及停止条件 | 计划足以执行且不扩大授权;远期工作保留粗粒度但不纳入当前任务 |
|
||||
| 6 实施与自检 | 可审查变更、局部测试、计划偏差与迭代记录 | 当前增量达到验收条件,范围内阻塞项已闭合,非阻塞项已按价值分流 |
|
||||
| 7 正式测试与验收 | 需求到证据映射;正常、异常、边界、回归结果及缺项 | 同步并固定被测对象,必需自动验证有有效证据;仅用户可判定的验收单独请求人工结论 |
|
||||
| 8 代码 Review | 智能体审阅者、对象版本、需求符合性、正确性、维护性、测试充分性及问题闭合记录 | 智能体完成必要重放、整改、补测和复审;当前版本 Review 通过,平台要求的独立批准已满足 |
|
||||
| 9 合入 | 最新 base/head、当前 HEAD 的 CI、Review、冲突处置和合入结果 | 智能体再次同步,确认冲突已解决、Review 有效且 CI 全部通过,经 PR 执行合入并核验真实结果;无需另请人工合入 |
|
||||
| 10 合入核验与收口 | 实际合入内容及组件版本、验证依据、成果保存位置、写者状态、清理清单、任务归档安排及适用规划的进展与下一步建议 | 清理前取得人工批准或对应连续推进授权;安全回收后核对结果、闭合任务卡 |
|
||||
| 10 合入核验与收口 | 实际合入内容及组件版本、验证依据、成果保存位置、写者状态、清理清单、任务归档安排及适用规划的进展与下一步建议 | 证明成果已保存、所有写者已退出并且回收条件满足后,智能体安全回收并闭合任务卡;无法证明时保留现场并请求决策 |
|
||||
|
||||
阶段 1–7 和阶段 10 的人工节点仅在明确的连续推进授权范围内按授权执行;
|
||||
阶段 8–9 按智能体职责执行;各阶段产物及验证条件保留。
|
||||
各阶段默认按已授权范围连续执行;只有命中本文定义的决策点才暂停受影响部分。
|
||||
阶段产物、独立 Review、验证条件和平台准入保留;移除的是机械性人工签字,不是质量证据。
|
||||
正式测试和最终 Review 的顺序不限制设计阶段试验、实施期测试或提前审阅。
|
||||
文本合并无冲突不证明组合兼容;合入后仍核验真实内容与被测候选是否一致。
|
||||
|
||||
多个就绪任务的最终验证与合入按 [合入调度](merge-queue.md) 的采纳状态执行。
|
||||
新模式生效后,阶段 7–9 在同一目标交付窗口内完成;对象未变时各节点重复核验,
|
||||
不因记录检查再创建提交或重跑有效 CI。窗口领取不代替人工结论、独立 Review 或平台准入;
|
||||
不因记录检查再创建提交或重跑有效 CI。窗口领取不代替已触发的必要决策、独立 Review 或平台准入;
|
||||
未生效时继续既有同步规则。固定后的证据按 [源码外收据流程](governance.md#候选固定前后) 保存。
|
||||
|
||||
## 长期方向与任务衔接
|
||||
@@ -80,7 +186,7 @@
|
||||
进展更新和建议属于当前任务的收尾产物。跨仓更新遵循对应工作区、版本和评审要求;
|
||||
更新尚未获批或合入时,任务卡保留待更新内容、负责人和下一动作,不能声称原规划已更新。
|
||||
**下一步建议不等于启动授权**:没有覆盖该任务的明确授权,建议停留在待确认状态;
|
||||
当前任务的完成、批准或连续推进授权不自动扩展到新任务。
|
||||
当前任务的完成或执行授权不自动扩展到新任务。
|
||||
|
||||
## 需求表与追溯
|
||||
|
||||
@@ -99,13 +205,13 @@
|
||||
## 设计探索循环
|
||||
|
||||
需求分析和设计阶段允许先试验再完善方案,无须先批准尚未能确定的完整设计。
|
||||
执行试验前提交最小试验计划:待回答的问题或假设、对象和隔离环境、方法、预期判据、
|
||||
允许副作用、时间或成本边界、停止条件及产物保存或处置方式,并取得人工批准;
|
||||
已有连续推进授权明确覆盖该试验范围时,引用该授权。
|
||||
执行试验前记录最小试验计划:待回答的问题或假设、对象和隔离环境、方法、预期判据、
|
||||
允许副作用、时间或成本边界、停止条件及产物保存或处置方式。试验在已授权目标内、可逆、已隔离且不新增外部成本时直接执行;
|
||||
命中决策点时再请求对应授权。
|
||||
|
||||
在已批准范围内可以反复尝试:
|
||||
|
||||
`初步方案 → 批准试验范围 → 原型或测试 → 分析证据 → 修订方案与计划 → 结果评审`。
|
||||
`初步方案 → 核对授权与试验边界 → 原型或测试 → 分析证据 → 修订方案与计划 → 核对结果`。
|
||||
|
||||
记录成功、失败及不确定结果,以及下一步继续探索、采用、换方案或暂停的理由。
|
||||
超出范围、触及未授权共享环境、达到预算或持续无有效进展时,暂停受影响试验并提交决策。
|
||||
@@ -131,8 +237,8 @@
|
||||
|
||||
| 变化 | 执行动作与评审边界 |
|
||||
|---|---|
|
||||
| 已批准范围内的内部组织、等价实现、局部缺陷修正 | 当前阶段继续调整、自测并记录;按阶段表由人工或智能体完成出口评审 |
|
||||
| 技术路线、模块职责、公开契约、数据或权限设计、外部影响,或计划的关键依赖、交付顺序和资源约束变化 | 暂停受影响工作,修订方案或计划,经人工重审后继续;已有明确授权覆盖该类调整时按其边界执行 |
|
||||
| 已批准范围内的内部组织、等价实现、局部缺陷修正 | 当前阶段继续调整、自测并记录;满足阶段表的客观出口条件后继续 |
|
||||
| 技术路线、模块职责、公开契约、数据或权限设计、外部影响,或计划的关键依赖、交付顺序和资源约束变化 | 先判断是否改变已批准用户结果或持续成本;否则在范围内调整并补测,是则暂停受影响部分并提交决策 |
|
||||
| 目标、范围、优先级或验收条件变化 | 返回需求评估和需求表,取得对应批准后更新下游方案、计划和验证 |
|
||||
| 影响不明 | 先调查;仍无法判定时提交人工决定,不自行归入细节调整 |
|
||||
|
||||
@@ -149,11 +255,11 @@
|
||||
### 智能体 Review 与 PR 合入
|
||||
|
||||
已获实施及交付授权的任务,代码 Review 和所有 PR 合入必须由智能体执行,包括任务卡闭合 PR、
|
||||
机器人和紧急通道。用户负责阶段评审及按双钥匙要求亲手调整平台保护;合入统一经智能体的
|
||||
机器人和紧急通道。用户只负责已触发的必要决策及按双钥匙要求亲手调整平台保护;合入统一经智能体的
|
||||
同步、Review、当前 HEAD CI 和平台准入流程完成,不由人工直接点击合入。
|
||||
本条即该范围内的合入执行授权,条件满足后由智能体直接合入,无需用户再次批准或亲手点击。
|
||||
用户明确限定为只读、方案、本地修改、草稿 PR 或禁止合入时,以该任务边界为准。
|
||||
前序阶段的必要人工结论仍须齐备;合入职责不扩大需求、业务实施、清理或高后果操作授权。
|
||||
前序阶段的必要产物和已触发决策结论仍须齐备;合入职责不扩大需求、业务实施、部署/迁移或高后果操作授权。
|
||||
|
||||
智能体按以下顺序执行:
|
||||
|
||||
@@ -181,9 +287,9 @@
|
||||
|
||||
### 清理与恢复
|
||||
|
||||
清理另行核对授权及条件。仅回收本任务登记工作区,按开发门禁核对当前权威目标、
|
||||
已授权任务的标准安全收口无需另行人工批准。仅回收本任务登记工作区,按开发门禁核对当前权威目标、
|
||||
父子仓成果、未跟踪和 ignored 文件、独有提交与引用、活动 Git 操作以及所有写者退出。
|
||||
无法证明成果已保存时保留现场;本地删除与远端分支删除分别报告。
|
||||
无法证明成果已保存或写者已退出时保留现场并只请求该具体决策;本地删除与远端分支删除分别报告。
|
||||
|
||||
清理前保存任务主记录与证据。执行回收时将命令输出用 evidence 的 `RECEIPT` 模式保存到
|
||||
待删目录之外;回收后的结果写入后续闭合记录,不能向已删除的任务卡补写收据。
|
||||
|
||||
+17
-3
@@ -3,8 +3,22 @@
|
||||
本文件细化根 AGENTS.md 的启动、验证和回收规则。通用语义以固定的
|
||||
`deps/development-guidelines` 为准;门禁实现及回归用例随本仓版本管理。
|
||||
需求表、设计试验、迭代调整、阶段产物、人工决策及智能体 Review/合入按
|
||||
[迭代交付](delivery.md) 执行。任务卡分别记录人工批准、智能体审阅者及对象版本;
|
||||
本地 evidence 仅记录 SELF 命令,不能证明人工批准或提升为 CI/独立审阅证据。
|
||||
[迭代交付](delivery.md) 执行。任务卡分别记录授权边界、必要人工决策点、
|
||||
`agent-per-unit(配置:AGENT-EXECUTION.md)` 实施执行模式、逐单元 Agent 实施收据、
|
||||
智能体审阅者及对象版本;权威验证与合入仍由 host/主模型执行,worker 局部自检不替代。
|
||||
本地 evidence 仅记录 SELF 命令,不能证明授权/人工决策或提升为 CI/独立审阅证据。
|
||||
|
||||
### Agent 实施收据与回退
|
||||
|
||||
每次外部实施尝试须在任务卡「Agent 实施收据」记录:配置摘要(`AGENT-EXECUTION.md` digest)、
|
||||
角色、声明候选顺序、attempt、CLI/模型、终态、transport、fallback,以及源码外原始收据路径。
|
||||
初始生成状态为 `NOT_RUN`。原始事件流与超限输出保留在源码外;未知终态(`failed-unknown`)
|
||||
与 `scope-expansion` 的隔离工作区须保留待对账,不得静默丢弃。清理须有精确恢复或保留现场的
|
||||
可复核证据。
|
||||
|
||||
当前 host 仅在 `scripts/agent_exec.py run` 对该精确单元发出有效 `host-fallback-authorized`
|
||||
收据后,方可撰写该单元的仓库变更;该收据不得由叙述或局部失败推断。`failed-unknown`、
|
||||
`scope-expansion`、无效配置、未对账 Git 状态与安全失败均不授权回退。
|
||||
|
||||
## 启动与恢复
|
||||
|
||||
@@ -25,7 +39,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
@@ -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 流程推断
|
||||
这些能力已经存在。
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
|
||||
| 要求 | 执行能力及范围 | 机制 | 复验入口 |
|
||||
|---|---|---|---|
|
||||
| A1 启动/恢复 | 本地命令阻断;首次调用及阶段授权仍属 manual | 默认严格 READY;显式 local 验证既有任务/R0及 LOCAL_READY;start 固定严格用途,resume 验证后导入 | `tests/test_task.py`、`tests/test_task_recovery.py`、`tests/test_evidence.py` |
|
||||
| A1 启动/恢复 | 本地命令阻断;首次收据必需,不创建阶段人工停顿 | 默认严格 READY;显式 local 验证既有任务/R0及 LOCAL_READY;start 固定严格用途,resume 验证后导入 | `tests/test_task.py`、`tests/test_task_recovery.py`、`tests/test_evidence.py` |
|
||||
| A2 同步 | 本地命令阻断;最终准入由平台承担 | 合并父仓、保护组件分支、物化固定指针、同基线幂等、完整最终 SHA | `tests/test_task.py`、`make test-worktree` |
|
||||
| A3 集成分支 | 本地提交闸;平台状态须独立核验 | pre-commit 拒绝 main/master 与坏分支名 | 本地 hook 负例;远端缺 CI/陈旧候选/直推等负例另验 |
|
||||
| A4 提交格式 | 主题行 enforced(local),body manual | commit-msg 校验宽度/句点;任务工具生成四段 body | `tests/test_task.py`;人工核对 PR/卡片同源 |
|
||||
| A5 回收 | 本地命令检查;写者退出由 owner 确认 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` |
|
||||
| A4 提交格式 | 主题行 enforced(local),body manual | commit-msg 校验宽度/句点;任务工具生成四段 body | `tests/test_task.py`;智能体核对 PR/卡片同源 |
|
||||
| A5 回收 | 本地命令检查;写者退出须可证明,无法证明时才请求 owner 决策 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` |
|
||||
| A6 秘密 | 本地脱敏及 CI 获取边界;权限与宿主隔离另验 | 命令/输出/摘要脱敏,凭据不持久写 Git 配置 | `tests/test_evidence.py`、`tests/test_ci_credentials.py` |
|
||||
| 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);真实三任务及复盘待完成 |
|
||||
| A8 阶段记录与迭代 | goal-first + agent-per-unit(AGENT-EXECUTION.md);模板默认连续执行,仅真实决策点需人工,没有全流程授权强制;实施写入经配置候选在新进程/隔离工作区按单元执行;host 回退仅凭有效 host-fallback-authorized 收据 | policy/delivery.md、AGENT-EXECUTION.md;任务卡分别记录阶段状态、决策点、Agent 实施收据(配置摘要/角色/候选/attempt/CLI-模型/终态/transport/fallback/源码外路径)、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对 goal-first/agent-per-unit 模板及钩子不自动写入;`python3 scripts/agent_exec.py validate` / parser·adapter·controller 假测为本地 SELF 强制行为;已安装 provider/model 身份待 U5,远端平台准入为 GATE;智能体核对授权范围、实施单元边界、Review、冲突及匹配 HEAD 的 CI/平台准入 |
|
||||
| A9 长期方向与后续任务 | manual;模板不校验规划有效性或自动调度任务 | policy/delivery.md;PROJECT.md 提供规划入口,任务卡关联规划/里程碑并记录下一步建议 | `tests/test_task.py` 核对生成字段与未授权默认值;智能体核对当前规划、进展、重复任务和建议依据,新任务仍不自动获得授权 |
|
||||
| A10 交付窗口 | manual;无自动互斥或接管 | [合入调度](merge-queue.md)与 #518:同一目标一个最终窗口,等待分支不追 main;轮到后同步、固定、验证并由智能体合入 | 每个实际 PR 核对窗口记录、精确 HEAD 三项 GATE、Review 和合入后 main;不要求额外试行或全写者审计 |
|
||||
| A11 固定后证据 | manual 选择源码外位置;工具捕获绝对未用路径/原始输出;无 OS 冻结 | policy/governance.md;RECEIPT 捕获,后续授权闭合再原样导入 | `tests/test_evidence.py` 固定对象、权限、覆盖拒绝、原样导入与幂等 |
|
||||
|
||||
## 失败处理
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,490 @@
|
||||
"""Built-in and controlled-custom CLI adapter registry for agent-exec.
|
||||
|
||||
Builds direct argv only (never shell). Probes local executable/version/help;
|
||||
does not contact providers or launch real agent runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.agent_exec_config import (
|
||||
ALLOWED_PLACEHOLDERS,
|
||||
SECRET_VALUE_RE,
|
||||
ExecutionConfig,
|
||||
Profile,
|
||||
)
|
||||
|
||||
_PROBE_TIMEOUT_SECONDS = 5
|
||||
# Conservative bound for embedding unit packets into prompts (policy-compatible).
|
||||
MAX_UNIT_PACKET_BYTES = 262_144
|
||||
UNIT_PACKET_DISPLAY_MARKER = "<unit-packet>"
|
||||
|
||||
_PLACEHOLDER_MAP = {
|
||||
"${workspace}": "workspace",
|
||||
"${unit_packet}": "unit_packet",
|
||||
"${result_path}": "result_path",
|
||||
"${model}": "model",
|
||||
}
|
||||
|
||||
_CUSTOM_OUTPUT_ARGV: dict[str, tuple[str, ...]] = {
|
||||
"json": ("--output-format", "json"),
|
||||
"stream-json": ("--output-format", "stream-json"),
|
||||
"jsonl": ("--output-format", "jsonl"),
|
||||
"text": (),
|
||||
}
|
||||
|
||||
# Substrings that must appear in --help before a built-in is available.
|
||||
_REQUIRED_HELP_MARKERS: dict[str, tuple[str, ...]] = {
|
||||
"cursor": (
|
||||
"--print",
|
||||
"--force",
|
||||
"--sandbox",
|
||||
"--trust",
|
||||
"--workspace",
|
||||
"--model",
|
||||
"--output-format",
|
||||
),
|
||||
"antigravity": (
|
||||
"--print",
|
||||
"--dangerously-skip-permissions",
|
||||
"--mode",
|
||||
"--model",
|
||||
"--output-format",
|
||||
),
|
||||
"claude": (
|
||||
"--print",
|
||||
"--permission-mode",
|
||||
"--model",
|
||||
"--output-format",
|
||||
),
|
||||
"codex": (
|
||||
"exec",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--config",
|
||||
"--model",
|
||||
"--cd",
|
||||
"--json",
|
||||
"--output-last-message",
|
||||
),
|
||||
"pi": ("--print", "--approve", "--model", "--mode"),
|
||||
"kimi": ("--prompt", "--auto", "--model", "--output-format"),
|
||||
"opencode": (
|
||||
"--print",
|
||||
"--yes",
|
||||
"--workspace",
|
||||
"--output-format",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterSpec:
|
||||
name: str
|
||||
noninteractive_argv: tuple[str, ...]
|
||||
run_everything_argv: tuple[str, ...]
|
||||
workspace_flag: str | None
|
||||
model_flag: str | None
|
||||
output_argv: tuple[str, ...]
|
||||
prompt_transport: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterProbe:
|
||||
available: bool
|
||||
reason: str | None
|
||||
executable: str
|
||||
observed_version: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Invocation:
|
||||
argv: tuple[str, ...]
|
||||
cwd: Path
|
||||
stdin: bytes | None
|
||||
sanitized_display: tuple[str, ...]
|
||||
|
||||
|
||||
BUILTIN_SPECS: dict[str, AdapterSpec] = {
|
||||
"cursor": AdapterSpec(
|
||||
name="cursor",
|
||||
noninteractive_argv=("-p",),
|
||||
run_everything_argv=("--force", "--sandbox", "enabled", "--trust"),
|
||||
workspace_flag="--workspace",
|
||||
model_flag="--model",
|
||||
output_argv=("--output-format", "stream-json"),
|
||||
prompt_transport="positional",
|
||||
),
|
||||
"antigravity": AdapterSpec(
|
||||
name="antigravity",
|
||||
noninteractive_argv=(),
|
||||
run_everything_argv=(
|
||||
"--dangerously-skip-permissions",
|
||||
"--mode",
|
||||
"auto",
|
||||
),
|
||||
workspace_flag=None,
|
||||
model_flag="--model",
|
||||
output_argv=("--output-format", "stream-json"),
|
||||
prompt_transport="print",
|
||||
),
|
||||
"claude": AdapterSpec(
|
||||
name="claude",
|
||||
noninteractive_argv=("-p",),
|
||||
run_everything_argv=("--permission-mode", "bypassPermissions"),
|
||||
workspace_flag=None,
|
||||
model_flag="--model",
|
||||
output_argv=("--output-format", "stream-json"),
|
||||
prompt_transport="positional",
|
||||
),
|
||||
"codex": AdapterSpec(
|
||||
name="codex",
|
||||
noninteractive_argv=("exec",),
|
||||
run_everything_argv=(
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"-c",
|
||||
"approval_policy=never",
|
||||
),
|
||||
workspace_flag="--cd",
|
||||
model_flag="--model",
|
||||
output_argv=("--json", "--output-last-message"),
|
||||
prompt_transport="positional",
|
||||
),
|
||||
"pi": AdapterSpec(
|
||||
name="pi",
|
||||
noninteractive_argv=("-p",),
|
||||
run_everything_argv=("--approve",),
|
||||
workspace_flag=None,
|
||||
model_flag="--model",
|
||||
output_argv=("--mode", "json"),
|
||||
prompt_transport="positional",
|
||||
),
|
||||
"kimi": AdapterSpec(
|
||||
name="kimi",
|
||||
noninteractive_argv=(),
|
||||
run_everything_argv=("--auto",),
|
||||
workspace_flag=None,
|
||||
model_flag="--model",
|
||||
output_argv=("--output-format", "stream-json"),
|
||||
prompt_transport="flag",
|
||||
),
|
||||
"opencode": AdapterSpec(
|
||||
name="opencode",
|
||||
noninteractive_argv=("--print",),
|
||||
run_everything_argv=("--yes",),
|
||||
workspace_flag="--workspace",
|
||||
model_flag="--model",
|
||||
output_argv=("--output-format", "json"),
|
||||
prompt_transport="positional",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def probe_adapter(profile: Profile, config: ExecutionConfig) -> AdapterProbe:
|
||||
"""Local executable/version/help probe; never contacts a provider."""
|
||||
del config # reserved for custom-adapter-aware probes in later units
|
||||
executable = profile.executable
|
||||
resolved = shutil.which(executable)
|
||||
if resolved is None:
|
||||
return AdapterProbe(
|
||||
available=False,
|
||||
reason="executable not found on PATH",
|
||||
executable=executable,
|
||||
observed_version=None,
|
||||
)
|
||||
|
||||
version = _bounded_probe_output(resolved, ["--version"])
|
||||
if version is None:
|
||||
version = _bounded_probe_output(resolved, ["-v"])
|
||||
if version is None:
|
||||
version = _bounded_probe_output(resolved, ["-V"])
|
||||
|
||||
help_text = _bounded_probe_output(resolved, ["--help"]) or ""
|
||||
help_text += "\n" + (_bounded_probe_output(resolved, ["-h"]) or "")
|
||||
# Codex documents exec options under the subcommand.
|
||||
if profile.adapter == "codex":
|
||||
help_text += "\n" + (
|
||||
_bounded_probe_output(resolved, ["exec", "--help"]) or ""
|
||||
)
|
||||
|
||||
markers = _REQUIRED_HELP_MARKERS.get(profile.adapter)
|
||||
if markers is not None:
|
||||
missing = [m for m in markers if not _help_has_marker(help_text, m)]
|
||||
if missing:
|
||||
return AdapterProbe(
|
||||
available=False,
|
||||
reason=(
|
||||
"unavailable: missing required capability flags in help: "
|
||||
+ ", ".join(missing)
|
||||
),
|
||||
executable=resolved,
|
||||
observed_version=version,
|
||||
)
|
||||
|
||||
if profile.adapter == "custom":
|
||||
# Custom adapters are validated at config load; help is advisory only.
|
||||
pass
|
||||
|
||||
return AdapterProbe(
|
||||
available=True,
|
||||
reason=None,
|
||||
executable=resolved,
|
||||
observed_version=version,
|
||||
)
|
||||
|
||||
|
||||
def build_invocation(
|
||||
profile: Profile,
|
||||
config: ExecutionConfig,
|
||||
workspace: Path,
|
||||
unit_packet: Path,
|
||||
result_path: Path,
|
||||
) -> Invocation:
|
||||
"""Compose a direct argv invocation; never uses a shell."""
|
||||
workspace = Path(workspace)
|
||||
unit_packet = Path(unit_packet)
|
||||
result_path = Path(result_path)
|
||||
packet_text = _read_unit_packet_text(unit_packet)
|
||||
prompt = _build_bounded_unit_prompt(packet_text)
|
||||
|
||||
if profile.adapter == "custom":
|
||||
return _build_custom_invocation(
|
||||
profile,
|
||||
config,
|
||||
workspace,
|
||||
unit_packet,
|
||||
result_path,
|
||||
packet_text=packet_text,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
spec = BUILTIN_SPECS.get(profile.adapter)
|
||||
if spec is None:
|
||||
raise ValueError(f"unknown adapter: {profile.adapter!r}")
|
||||
|
||||
argv: list[str] = [profile.executable]
|
||||
argv.extend(spec.noninteractive_argv)
|
||||
argv.extend(spec.run_everything_argv)
|
||||
if spec.workspace_flag is not None:
|
||||
argv.extend([spec.workspace_flag, str(workspace)])
|
||||
if profile.model is not None and spec.model_flag is not None:
|
||||
argv.extend([spec.model_flag, profile.model])
|
||||
argv.extend(spec.output_argv)
|
||||
if spec.name == "codex":
|
||||
# --output-last-message requires the result path as the next argv item.
|
||||
argv.append(str(result_path))
|
||||
|
||||
stdin: bytes | None = None
|
||||
prompt_values: set[str] = set()
|
||||
if spec.prompt_transport == "positional":
|
||||
argv.append(prompt)
|
||||
prompt_values.add(prompt)
|
||||
elif spec.prompt_transport == "stdin":
|
||||
stdin = packet_text.encode("utf-8")
|
||||
elif spec.prompt_transport == "flag":
|
||||
argv.extend(["--prompt", prompt])
|
||||
prompt_values.add(prompt)
|
||||
elif spec.prompt_transport == "print":
|
||||
# agy 1.3.5 consumes the argv item immediately after --print.
|
||||
argv.extend(["--print", prompt])
|
||||
prompt_values.add(prompt)
|
||||
else:
|
||||
raise ValueError(f"unsupported prompt_transport: {spec.prompt_transport!r}")
|
||||
|
||||
argv_t = tuple(argv)
|
||||
return Invocation(
|
||||
argv=argv_t,
|
||||
cwd=workspace,
|
||||
stdin=stdin,
|
||||
sanitized_display=_sanitize_display(argv_t, prompt_values=prompt_values),
|
||||
)
|
||||
|
||||
|
||||
def _build_custom_invocation(
|
||||
profile: Profile,
|
||||
config: ExecutionConfig,
|
||||
workspace: Path,
|
||||
unit_packet: Path,
|
||||
result_path: Path,
|
||||
*,
|
||||
packet_text: str,
|
||||
prompt: str,
|
||||
) -> Invocation:
|
||||
custom = config.custom_adapters.get(profile.name)
|
||||
if custom is None:
|
||||
raise ValueError(
|
||||
f"profile {profile.name!r} uses custom adapter without definition"
|
||||
)
|
||||
|
||||
values = {
|
||||
"workspace": str(workspace),
|
||||
# Placeholder expands to the packet path, not the body.
|
||||
"unit_packet": str(unit_packet),
|
||||
"result_path": str(result_path),
|
||||
"model": profile.model or "",
|
||||
}
|
||||
|
||||
argv: list[str] = [profile.executable]
|
||||
argv.extend(_expand_argv(custom.base_argv, values, omit_model=False))
|
||||
argv.extend(_expand_argv(custom.run_everything_argv, values, omit_model=False))
|
||||
argv.extend(_expand_argv(custom.workspace_argv, values, omit_model=False))
|
||||
if profile.model is not None:
|
||||
argv.extend(_expand_argv(custom.model_argv, values, omit_model=False))
|
||||
# model is None: omit the complete model flag/value pair (entire model_argv).
|
||||
argv.extend(_CUSTOM_OUTPUT_ARGV.get(custom.output_format, ()))
|
||||
|
||||
stdin: bytes | None = None
|
||||
prompt_values: set[str] = set()
|
||||
if custom.prompt_transport == "stdin":
|
||||
stdin = packet_text.encode("utf-8")
|
||||
elif custom.prompt_transport == "positional":
|
||||
argv.append(prompt)
|
||||
prompt_values.add(prompt)
|
||||
elif custom.prompt_transport == "flag":
|
||||
argv.extend(["--prompt", prompt])
|
||||
prompt_values.add(prompt)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"unsupported prompt_transport: {custom.prompt_transport!r}"
|
||||
)
|
||||
|
||||
argv_t = tuple(argv)
|
||||
return Invocation(
|
||||
argv=argv_t,
|
||||
cwd=workspace,
|
||||
stdin=stdin,
|
||||
sanitized_display=_sanitize_display(argv_t, prompt_values=prompt_values),
|
||||
)
|
||||
|
||||
|
||||
def _read_unit_packet_text(path: Path) -> str:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"unable to read unit packet: {path}") from exc
|
||||
if len(raw) > MAX_UNIT_PACKET_BYTES:
|
||||
raise ValueError(
|
||||
f"unit packet exceeds size limit of {MAX_UNIT_PACKET_BYTES} bytes"
|
||||
)
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("unit packet is not valid UTF-8") from exc
|
||||
|
||||
|
||||
def _build_bounded_unit_prompt(packet_text: str) -> str:
|
||||
return (
|
||||
"Implement exactly the supplied bounded implementation unit. "
|
||||
"Work only inside the current workspace. "
|
||||
"Leave all changes uncommitted. "
|
||||
"Do not expand scope beyond the unit packet. "
|
||||
"Return a structured final result matching the unit's required schema.\n\n"
|
||||
"--- BOUNDED IMPLEMENTATION UNIT PACKET ---\n\n"
|
||||
f"{packet_text}"
|
||||
)
|
||||
|
||||
|
||||
def _expand_argv(
|
||||
items: tuple[str, ...],
|
||||
values: dict[str, str],
|
||||
*,
|
||||
omit_model: bool,
|
||||
) -> list[str]:
|
||||
out: list[str] = []
|
||||
for item in items:
|
||||
if item in ALLOWED_PLACEHOLDERS:
|
||||
key = _PLACEHOLDER_MAP[item]
|
||||
if key == "model" and (omit_model or not values.get("model")):
|
||||
continue
|
||||
out.append(values[key])
|
||||
continue
|
||||
if "${" in item:
|
||||
# Config validation already rejects non-whole-item placeholders.
|
||||
raise ValueError(f"placeholder must fill whole argv item: {item!r}")
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _help_has_marker(help_text: str, marker: str) -> bool:
|
||||
if marker in help_text:
|
||||
return True
|
||||
# Accept short forms when long forms are required markers.
|
||||
short_aliases = {
|
||||
"--print": ("-p",),
|
||||
"--force": ("-f",),
|
||||
"--model": ("-m",),
|
||||
"--cd": ("-C",),
|
||||
"--config": ("-c",),
|
||||
"--approve": ("-a",),
|
||||
"--prompt": ("-p",),
|
||||
}
|
||||
for alias in short_aliases.get(marker, ()):
|
||||
if re.search(rf"(^|\s){re.escape(alias)}(\s|,|$)", help_text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _bounded_probe_output(executable: str, args: list[str]) -> str | None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[executable, *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_PROBE_TIMEOUT_SECONDS,
|
||||
shell=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
text = (completed.stdout or "") + (completed.stderr or "")
|
||||
text = text.strip()
|
||||
return text or None
|
||||
|
||||
|
||||
_SENSITIVE_FLAG_RE = re.compile(
|
||||
r"^(?:--api-key|--token|--password|--secret|-H|--header|--authorization)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_display(
|
||||
argv: tuple[str, ...],
|
||||
*,
|
||||
prompt_values: set[str],
|
||||
) -> tuple[str, ...]:
|
||||
out: list[str] = []
|
||||
redact_next = False
|
||||
for item in argv:
|
||||
if item in prompt_values:
|
||||
out.append(UNIT_PACKET_DISPLAY_MARKER)
|
||||
continue
|
||||
if redact_next:
|
||||
out.append("***")
|
||||
redact_next = False
|
||||
continue
|
||||
if _SENSITIVE_FLAG_RE.match(item):
|
||||
out.append(item)
|
||||
redact_next = True
|
||||
continue
|
||||
if SECRET_VALUE_RE.match(item.strip()):
|
||||
out.append("***")
|
||||
continue
|
||||
out.append(item)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_UNIT_PACKET_BYTES",
|
||||
"UNIT_PACKET_DISPLAY_MARKER",
|
||||
"AdapterProbe",
|
||||
"AdapterSpec",
|
||||
"BUILTIN_SPECS",
|
||||
"Invocation",
|
||||
"build_invocation",
|
||||
"probe_adapter",
|
||||
]
|
||||
@@ -0,0 +1,554 @@
|
||||
"""Markdown extraction and constrained YAML parsing for AGENT-EXECUTION.md."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_V1 = "agent-execution/v1"
|
||||
CONFIG_NAME = "AGENT-EXECUTION.md"
|
||||
BLOCK_OPEN = "```agent-execution"
|
||||
BLOCK_CLOSE = "```"
|
||||
|
||||
BUILTIN_ADAPTERS = frozenset(
|
||||
{"cursor", "antigravity", "claude", "codex", "pi", "kimi", "opencode", "custom"}
|
||||
)
|
||||
REQUIRED_ROLES = ("implementation", "repair", "test", "review")
|
||||
ALLOWED_PLACEHOLDERS = frozenset(
|
||||
{"${workspace}", "${unit_packet}", "${result_path}", "${model}"}
|
||||
)
|
||||
PLACEHOLDER_RE = re.compile(r"\$\{[A-Za-z0-9_]+\}")
|
||||
|
||||
PERMISSION_VALUES = frozenset({"run-everything"})
|
||||
FALLBACK_VALUES = frozenset({"all-unavailable"})
|
||||
PROMPT_TRANSPORTS = frozenset({"stdin", "positional", "flag"})
|
||||
OUTPUT_FORMATS = frozenset({"json", "stream-json", "jsonl", "text"})
|
||||
|
||||
MAX_PARALLEL = 32
|
||||
MAX_TIMEOUT_SECONDS = 86400
|
||||
MAX_SUMMARY_BYTES = 1_048_576
|
||||
MAX_RAW_OUTPUT_BYTES = 104_857_600
|
||||
# Omitted idle_timeout_seconds uses this cap, then clamps to timeout_seconds.
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS = 600
|
||||
|
||||
SHELL_META_RE = re.compile(r"[;&|`$<>(){}]|&&|\|\||\$\(")
|
||||
SECRET_KEY_RE = re.compile(
|
||||
r"(password|secret|token|api[_-]?key|authorization|credential|passwd)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SECRET_VALUE_RE = re.compile(
|
||||
r"(?i)^(bearer\s+\S+|sk-[A-Za-z0-9_\-]{8,}|[A-Fa-f0-9]{32,})$"
|
||||
)
|
||||
|
||||
ROOT_KEYS = frozenset({"schema", "execution", "roles", "profiles", "custom_adapters"})
|
||||
EXECUTION_KEYS = frozenset(
|
||||
{
|
||||
"fresh_process_per_unit",
|
||||
"permission",
|
||||
"fallback_to_host",
|
||||
"max_parallel",
|
||||
"timeout_seconds",
|
||||
"idle_timeout_seconds",
|
||||
"summary_bytes",
|
||||
"raw_output_bytes",
|
||||
}
|
||||
)
|
||||
EXECUTION_REQUIRED = EXECUTION_KEYS - frozenset({"idle_timeout_seconds"})
|
||||
PROFILE_KEYS = frozenset({"adapter", "executable", "model", "enabled"})
|
||||
CUSTOM_ADAPTER_KEYS = frozenset(
|
||||
{
|
||||
"base_argv",
|
||||
"run_everything_argv",
|
||||
"workspace_argv",
|
||||
"model_argv",
|
||||
"prompt_transport",
|
||||
"output_format",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when AGENT-EXECUTION.md cannot be parsed or fails schema checks."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPolicy:
|
||||
fresh_process_per_unit: bool
|
||||
permission: str
|
||||
fallback_to_host: str
|
||||
max_parallel: int
|
||||
timeout_seconds: int
|
||||
summary_bytes: int
|
||||
raw_output_bytes: int
|
||||
idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
name: str
|
||||
adapter: str
|
||||
executable: str
|
||||
model: str | None
|
||||
enabled: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomAdapter:
|
||||
name: str
|
||||
base_argv: tuple[str, ...]
|
||||
run_everything_argv: tuple[str, ...]
|
||||
workspace_argv: tuple[str, ...]
|
||||
model_argv: tuple[str, ...]
|
||||
prompt_transport: str
|
||||
output_format: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionConfig:
|
||||
schema: str
|
||||
execution: ExecutionPolicy
|
||||
roles: Mapping[str, tuple[str, ...]]
|
||||
profiles: Mapping[str, Profile]
|
||||
custom_adapters: Mapping[str, CustomAdapter]
|
||||
|
||||
|
||||
def load_config(repo: Path) -> ExecutionConfig:
|
||||
"""Load and validate the single agent-execution block from repo/AGENT-EXECUTION.md."""
|
||||
path = Path(repo) / CONFIG_NAME
|
||||
if not path.is_file():
|
||||
raise ConfigError(f"missing {CONFIG_NAME}")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
raw = _parse_subset_yaml(_extract_block(text))
|
||||
return _build_config(raw)
|
||||
|
||||
|
||||
def validate_config(repo: Path) -> list[str]:
|
||||
"""Return an empty list when configuration is valid; raise ConfigError otherwise."""
|
||||
load_config(repo)
|
||||
return []
|
||||
|
||||
|
||||
def _extract_block(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
starts: list[int] = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == BLOCK_OPEN:
|
||||
starts.append(i)
|
||||
if not starts:
|
||||
raise ConfigError("missing agent-execution fenced block")
|
||||
if len(starts) > 1:
|
||||
raise ConfigError("duplicate agent-execution fenced blocks")
|
||||
start = starts[0]
|
||||
end = None
|
||||
for j in range(start + 1, len(lines)):
|
||||
if lines[j].strip() == BLOCK_CLOSE:
|
||||
end = j
|
||||
break
|
||||
if end is None:
|
||||
raise ConfigError("unclosed agent-execution fenced block")
|
||||
return "\n".join(lines[start + 1 : end])
|
||||
|
||||
|
||||
def _parse_subset_yaml(text: str) -> dict[str, Any]:
|
||||
if "\t" in text:
|
||||
raise ConfigError("tabs are not allowed; use two-space indentation")
|
||||
lines = text.splitlines()
|
||||
root: dict[str, Any] = {}
|
||||
stack: list[tuple[int, dict[str, Any]]] = [(-2, root)]
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
if not raw.strip() or raw.lstrip().startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
indent = len(raw) - len(raw.lstrip(" "))
|
||||
stripped = raw.lstrip(" ")
|
||||
if stripped.startswith(("- ", "?", "|", ">", "&", "*", "!")):
|
||||
raise ConfigError(f"unsupported YAML construct: {raw.strip()!r}")
|
||||
if re.search(r"(^|\s)<<", stripped):
|
||||
raise ConfigError(f"unsupported YAML merge/alias: {raw.strip()!r}")
|
||||
content = raw[indent:]
|
||||
if ":" not in content:
|
||||
raise ConfigError(f"expected key: value, got {raw.strip()!r}")
|
||||
key, _, rest = content.partition(":")
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if not key or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
||||
raise ConfigError(f"invalid mapping key: {key!r}")
|
||||
_reject_secret_key(key)
|
||||
|
||||
# Pop to parent whose indent is strictly less than current.
|
||||
while len(stack) > 1 and stack[-1][0] >= indent:
|
||||
stack.pop()
|
||||
parent_indent, parent = stack[-1]
|
||||
expected = parent_indent + 2
|
||||
if indent != expected:
|
||||
raise ConfigError(
|
||||
f"indentation error: expected {expected} spaces, got {indent}"
|
||||
)
|
||||
if key in parent:
|
||||
raise ConfigError(f"duplicate mapping key: {key!r}")
|
||||
|
||||
if rest == "":
|
||||
child: dict[str, Any] = {}
|
||||
parent[key] = child
|
||||
stack.append((indent, child))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
value = _parse_scalar(rest, key=key)
|
||||
parent[key] = value
|
||||
i += 1
|
||||
return root
|
||||
|
||||
|
||||
def _parse_scalar(text: str, *, key: str) -> Any:
|
||||
if text.startswith(("|", ">")):
|
||||
raise ConfigError("multiline scalars are not supported")
|
||||
if text.startswith("["):
|
||||
return _parse_flow_sequence(text)
|
||||
if text in ("true", "false"):
|
||||
return text == "true"
|
||||
if text in ("null", "~"):
|
||||
return None
|
||||
if re.fullmatch(r"-?\d+", text):
|
||||
return int(text)
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2}.*", text):
|
||||
raise ConfigError(f"implicit dates are not supported: {text!r}")
|
||||
if text[:1] in ("'", '"'):
|
||||
value = _parse_quoted_string(text)
|
||||
else:
|
||||
if any(ch in text for ch in ":#[]{},"):
|
||||
raise ConfigError(f"unsafe unquoted scalar: {text!r}")
|
||||
value = text
|
||||
_reject_shell_or_secret_value(key, value)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_quoted_string(text: str) -> str:
|
||||
quote = text[0]
|
||||
if quote not in ("'", '"'):
|
||||
raise ConfigError(f"invalid quoted string: {text!r}")
|
||||
if quote == "'":
|
||||
# Single-quoted YAML-ish: only '' escape; keep non-ASCII intact.
|
||||
if not text.endswith("'") or len(text) < 2:
|
||||
raise ConfigError(f"unclosed single-quoted string: {text!r}")
|
||||
inner = text[1:-1]
|
||||
return inner.replace("''", "'")
|
||||
# Double-quoted: exact Python/JSON-compatible escapes via ast.literal_eval.
|
||||
try:
|
||||
value = ast.literal_eval(text)
|
||||
except (SyntaxError, ValueError) as exc:
|
||||
raise ConfigError(f"invalid double-quoted string: {text!r}") from exc
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError(f"quoted scalar must be a string: {text!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_flow_sequence(text: str) -> list[Any]:
|
||||
if not text.startswith("[") or not text.endswith("]"):
|
||||
raise ConfigError(f"invalid flow sequence: {text!r}")
|
||||
inner = text[1:-1].strip()
|
||||
if not inner:
|
||||
return []
|
||||
items: list[Any] = []
|
||||
buf: list[str] = []
|
||||
in_quote: str | None = None
|
||||
escape = False
|
||||
i = 0
|
||||
while i < len(inner):
|
||||
ch = inner[i]
|
||||
if in_quote:
|
||||
buf.append(ch)
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\" and in_quote == '"':
|
||||
escape = True
|
||||
elif ch == in_quote:
|
||||
in_quote = None
|
||||
i += 1
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
in_quote = ch
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == ",":
|
||||
items.append(_parse_scalar("".join(buf).strip(), key="list_item"))
|
||||
buf = []
|
||||
i += 1
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
if in_quote:
|
||||
raise ConfigError(f"unclosed quote in flow sequence: {text!r}")
|
||||
if buf and "".join(buf).strip():
|
||||
items.append(_parse_scalar("".join(buf).strip(), key="list_item"))
|
||||
return items
|
||||
|
||||
|
||||
def _reject_secret_key(key: str) -> None:
|
||||
if SECRET_KEY_RE.search(key):
|
||||
raise ConfigError(f"credential-looking key rejected: {key!r}")
|
||||
|
||||
|
||||
def _reject_shell_or_secret_value(key: str, value: Any) -> None:
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
# Whole-item placeholders are validated later for argv contexts.
|
||||
if value in ALLOWED_PLACEHOLDERS:
|
||||
return
|
||||
if SHELL_META_RE.search(value):
|
||||
raise ConfigError(f"shell metacharacter template rejected for {key!r}")
|
||||
if SECRET_VALUE_RE.match(value.strip()):
|
||||
raise ConfigError(f"secret-shaped value rejected for {key!r}")
|
||||
|
||||
|
||||
def _build_config(raw: dict[str, Any]) -> ExecutionConfig:
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("root must be a mapping")
|
||||
unknown = set(raw) - ROOT_KEYS
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown top-level keys: {sorted(unknown)}")
|
||||
for required in ("schema", "execution", "roles", "profiles"):
|
||||
if required not in raw:
|
||||
raise ConfigError(f"missing required key: {required}")
|
||||
|
||||
schema = raw["schema"]
|
||||
if schema != SCHEMA_V1:
|
||||
raise ConfigError(f"unknown schema: {schema!r}")
|
||||
|
||||
execution = _build_execution(raw["execution"])
|
||||
profiles = _build_profiles(raw["profiles"])
|
||||
custom_adapters = _build_custom_adapters(raw.get("custom_adapters", {}))
|
||||
roles = _build_roles(raw["roles"], profiles)
|
||||
|
||||
for profile in profiles.values():
|
||||
if profile.adapter == "custom":
|
||||
if profile.name not in custom_adapters:
|
||||
raise ConfigError(
|
||||
f"profile {profile.name!r} uses adapter custom without "
|
||||
f"custom_adapters.{profile.name}"
|
||||
)
|
||||
elif profile.adapter not in BUILTIN_ADAPTERS - {"custom"}:
|
||||
if profile.adapter not in custom_adapters:
|
||||
raise ConfigError(f"unknown adapter: {profile.adapter!r}")
|
||||
|
||||
return ExecutionConfig(
|
||||
schema=schema,
|
||||
execution=execution,
|
||||
roles=roles,
|
||||
profiles=profiles,
|
||||
custom_adapters=custom_adapters,
|
||||
)
|
||||
|
||||
|
||||
def _build_execution(raw: Any) -> ExecutionPolicy:
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("execution must be a mapping")
|
||||
unknown = set(raw) - EXECUTION_KEYS
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown execution keys: {sorted(unknown)}")
|
||||
missing = EXECUTION_REQUIRED - set(raw)
|
||||
if missing:
|
||||
raise ConfigError(f"missing execution keys: {sorted(missing)}")
|
||||
|
||||
fresh = raw["fresh_process_per_unit"]
|
||||
if fresh is not True:
|
||||
raise ConfigError("fresh_process_per_unit must be true")
|
||||
|
||||
permission = raw["permission"]
|
||||
if permission not in PERMISSION_VALUES:
|
||||
raise ConfigError(f"unsupported permission: {permission!r}")
|
||||
fallback = raw["fallback_to_host"]
|
||||
if fallback not in FALLBACK_VALUES:
|
||||
raise ConfigError(f"unsupported fallback_to_host: {fallback!r}")
|
||||
|
||||
max_parallel = _positive_bounded(
|
||||
raw["max_parallel"], "max_parallel", 1, MAX_PARALLEL
|
||||
)
|
||||
timeout_seconds = _positive_bounded(
|
||||
raw["timeout_seconds"], "timeout_seconds", 1, MAX_TIMEOUT_SECONDS
|
||||
)
|
||||
if "idle_timeout_seconds" in raw:
|
||||
idle_timeout_seconds = _positive_bounded(
|
||||
raw["idle_timeout_seconds"],
|
||||
"idle_timeout_seconds",
|
||||
1,
|
||||
timeout_seconds,
|
||||
)
|
||||
else:
|
||||
idle_timeout_seconds = min(DEFAULT_IDLE_TIMEOUT_SECONDS, timeout_seconds)
|
||||
summary_bytes = _positive_bounded(
|
||||
raw["summary_bytes"], "summary_bytes", 1, MAX_SUMMARY_BYTES
|
||||
)
|
||||
raw_output_bytes = _positive_bounded(
|
||||
raw["raw_output_bytes"], "raw_output_bytes", 1, MAX_RAW_OUTPUT_BYTES
|
||||
)
|
||||
if raw_output_bytes < summary_bytes:
|
||||
raise ConfigError("raw_output_bytes must be >= summary_bytes")
|
||||
|
||||
return ExecutionPolicy(
|
||||
fresh_process_per_unit=True,
|
||||
permission=permission,
|
||||
fallback_to_host=fallback,
|
||||
max_parallel=max_parallel,
|
||||
timeout_seconds=timeout_seconds,
|
||||
summary_bytes=summary_bytes,
|
||||
raw_output_bytes=raw_output_bytes,
|
||||
idle_timeout_seconds=idle_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _positive_bounded(value: Any, name: str, lo: int, hi: int) -> int:
|
||||
if type(value) is not int or isinstance(value, bool):
|
||||
raise ConfigError(f"{name} must be an integer")
|
||||
if value < lo or value > hi:
|
||||
raise ConfigError(f"{name} out of bounds: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def _build_profiles(raw: Any) -> dict[str, Profile]:
|
||||
if not isinstance(raw, dict) or not raw:
|
||||
raise ConfigError("profiles must be a non-empty mapping")
|
||||
out: dict[str, Profile] = {}
|
||||
for name, body in raw.items():
|
||||
if not isinstance(body, dict):
|
||||
raise ConfigError(f"profile {name!r} must be a mapping")
|
||||
unknown = set(body) - PROFILE_KEYS
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown keys in profile {name!r}: {sorted(unknown)}")
|
||||
missing = PROFILE_KEYS - set(body)
|
||||
if missing:
|
||||
raise ConfigError(f"missing keys in profile {name!r}: {sorted(missing)}")
|
||||
adapter = body["adapter"]
|
||||
executable = body["executable"]
|
||||
enabled = body["enabled"]
|
||||
model_raw = body["model"]
|
||||
if not isinstance(adapter, str) or not adapter:
|
||||
raise ConfigError(f"profile {name!r} adapter must be a string")
|
||||
if not isinstance(executable, str) or not executable:
|
||||
raise ConfigError(f"profile {name!r} executable must be a string")
|
||||
if type(enabled) is not bool:
|
||||
raise ConfigError(f"profile {name!r} enabled must be a boolean")
|
||||
if model_raw is None or model_raw == "auto":
|
||||
model: str | None = None
|
||||
elif isinstance(model_raw, str) and model_raw:
|
||||
model = model_raw
|
||||
else:
|
||||
raise ConfigError(f"profile {name!r} model must be a string or auto")
|
||||
out[name] = Profile(
|
||||
name=name,
|
||||
adapter=adapter,
|
||||
executable=executable,
|
||||
model=model,
|
||||
enabled=enabled,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _build_roles(
|
||||
raw: Any, profiles: Mapping[str, Profile]
|
||||
) -> dict[str, tuple[str, ...]]:
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("roles must be a mapping")
|
||||
unknown = set(raw) - set(REQUIRED_ROLES)
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown roles: {sorted(unknown)}")
|
||||
missing = set(REQUIRED_ROLES) - set(raw)
|
||||
if missing:
|
||||
raise ConfigError(f"missing roles: {sorted(missing)}")
|
||||
out: dict[str, tuple[str, ...]] = {}
|
||||
for role in REQUIRED_ROLES:
|
||||
value = raw[role]
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ConfigError(f"role {role!r} must be a non-empty list")
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ConfigError(f"role {role!r} entries must be strings")
|
||||
if item in seen:
|
||||
raise ConfigError(f"duplicate profile reference in role {role!r}: {item}")
|
||||
if item not in profiles:
|
||||
raise ConfigError(f"unknown profile reference: {item!r}")
|
||||
seen.add(item)
|
||||
names.append(item)
|
||||
out[role] = tuple(names)
|
||||
return out
|
||||
|
||||
|
||||
def _build_custom_adapters(raw: Any) -> dict[str, CustomAdapter]:
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("custom_adapters must be a mapping")
|
||||
out: dict[str, CustomAdapter] = {}
|
||||
for name, body in raw.items():
|
||||
if not isinstance(body, dict):
|
||||
raise ConfigError(f"custom adapter {name!r} must be a mapping")
|
||||
unknown = set(body) - CUSTOM_ADAPTER_KEYS
|
||||
if unknown:
|
||||
raise ConfigError(
|
||||
f"unknown keys in custom adapter {name!r}: {sorted(unknown)}"
|
||||
)
|
||||
missing = CUSTOM_ADAPTER_KEYS - set(body)
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"missing keys in custom adapter {name!r}: {sorted(missing)}"
|
||||
)
|
||||
base_argv = _argv_tuple(body["base_argv"], f"custom_adapters.{name}.base_argv")
|
||||
run_everything_argv = _argv_tuple(
|
||||
body["run_everything_argv"],
|
||||
f"custom_adapters.{name}.run_everything_argv",
|
||||
)
|
||||
workspace_argv = _argv_tuple(
|
||||
body["workspace_argv"], f"custom_adapters.{name}.workspace_argv"
|
||||
)
|
||||
model_argv = _argv_tuple(
|
||||
body["model_argv"], f"custom_adapters.{name}.model_argv"
|
||||
)
|
||||
prompt_transport = body["prompt_transport"]
|
||||
output_format = body["output_format"]
|
||||
if prompt_transport not in PROMPT_TRANSPORTS:
|
||||
raise ConfigError(
|
||||
f"unsupported prompt_transport for {name!r}: {prompt_transport!r}"
|
||||
)
|
||||
if output_format not in OUTPUT_FORMATS:
|
||||
raise ConfigError(
|
||||
f"unsupported output_format for {name!r}: {output_format!r}"
|
||||
)
|
||||
out[name] = CustomAdapter(
|
||||
name=name,
|
||||
base_argv=base_argv,
|
||||
run_everything_argv=run_everything_argv,
|
||||
workspace_argv=workspace_argv,
|
||||
model_argv=model_argv,
|
||||
prompt_transport=prompt_transport,
|
||||
output_format=output_format,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _argv_tuple(value: Any, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
raise ConfigError(f"{label} must be a list")
|
||||
items: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
raise ConfigError(f"{label} entries must be strings")
|
||||
matches = PLACEHOLDER_RE.findall(item)
|
||||
if matches:
|
||||
if len(matches) != 1 or item != matches[0]:
|
||||
raise ConfigError(
|
||||
f"placeholder must fill whole argv item in {label}: {item!r}"
|
||||
)
|
||||
if matches[0] not in ALLOWED_PLACEHOLDERS:
|
||||
raise ConfigError(f"unknown placeholder {matches[0]!r} in {label}")
|
||||
else:
|
||||
_reject_shell_or_secret_value(label, item)
|
||||
items.append(item)
|
||||
return tuple(items)
|
||||
+846
-54
File diff suppressed because it is too large
Load Diff
+25
-16
@@ -200,9 +200,10 @@ cmd_start() {
|
||||
- 工作区:${wt}(worktree);分支:${name}
|
||||
${parent_note}
|
||||
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
|
||||
- 交付模式:manual(前序人工决策,智能体 Review/合入);其他阶段明确授权后可记为 authorized-continuous
|
||||
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
|
||||
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
|
||||
- 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权)
|
||||
- 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收
|
||||
- 当前阶段:需求拆解;当前增量:I1(待定义)
|
||||
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
|
||||
|
||||
@@ -210,6 +211,14 @@ ${parent_note}
|
||||
|
||||
<预期结果、受影响入口/模块/契约;明确不做什么。>
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。仅当 scripts/agent_exec.py run 对该精确单元发出有效 host-fallback-authorized 收据后,当前 host 方可实施该单元。failed-unknown、scope-expansion、无效配置、未对账 Git 状态与安全失败均不授权回退。原始日志保留在源码外收据路径。
|
||||
|
||||
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
|
||||
|
||||
## 关联方向与规划
|
||||
|
||||
- 适用状态:待核对(无适用规划时说明原因)
|
||||
@@ -228,26 +237,26 @@ ${parent_note}
|
||||
- 实施计划引用:待填写(含增量与需求 ID)
|
||||
- 测试计划引用:待填写(含需求 ID 与验收条件)
|
||||
|
||||
## 阶段评审
|
||||
## 阶段记录
|
||||
|
||||
| 阶段 | 对象版本 | 结论 | 评审人 | 时间 | 依据 |
|
||||
| 阶段 | 对象版本 | 状态 | 决策/执行者 | 时间 | 依据 |
|
||||
|---|---|---|---|---|---|
|
||||
| 需求拆解 | 待定 | 未评审 | — | — | — |
|
||||
| 需求评估 | 待定 | 未评审 | — | — | — |
|
||||
| 需求表确认 | 待定 | 未评审 | — | — | — |
|
||||
| 方案设计 | 待定 | 未评审 | — | — | — |
|
||||
| 实施计划 | 待定 | 未评审 | — | — | — |
|
||||
| 实施与自检 | 待定 | 未评审 | — | — | — |
|
||||
| 正式测试与验收 | 待定 | 未评审 | — | — | — |
|
||||
| 代码 Review | 待定 | 未评审 | — | — | — |
|
||||
| 合入 | 待定 | 未评审 | — | — | — |
|
||||
| 合入核验与收口 | 待定 | 未评审 | — | — | — |
|
||||
| 需求拆解 | 待定 | 待执行 | — | — | — |
|
||||
| 需求评估 | 待定 | 待执行 | — | — | — |
|
||||
| 需求表确认 | 待定 | 待执行 | — | — | — |
|
||||
| 方案设计 | 待定 | 待执行 | — | — | — |
|
||||
| 实施计划 | 待定 | 待执行 | — | — | — |
|
||||
| 实施与自检 | 待定 | 待执行 | — | — | — |
|
||||
| 正式测试与验收 | 待定 | 待执行 | — | — | — |
|
||||
| 代码 Review | 待定 | 待执行 | — | — | — |
|
||||
| 合入 | 待定 | 待执行 | — | — | — |
|
||||
| 合入核验与收口 | 待定 | 待执行 | — | — | — |
|
||||
|
||||
## 探索试验与迭代
|
||||
|
||||
| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 批准依据 | 结果 | 变更影响 | 需重审补测 |
|
||||
| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 授权/决策依据 | 结果 | 变更影响 | 需重审补测 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 待定义 | — | — | — | 未批准 | NOT_RUN | 待评估 | 待评估 |
|
||||
| 待定义 | — | — | — | 待核对 | NOT_RUN | 待评估 | 待评估 |
|
||||
|
||||
## 同步记录
|
||||
|
||||
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
# U5 真实 Agent CLI 探针
|
||||
|
||||
本夹具描述 host 稍后执行的 opt-in 探针。七个 profile 的真实结果都还是 `NOT_RUN`。这里不记录探针 `PASS`。
|
||||
|
||||
## 门禁
|
||||
|
||||
默认测试不设置下面两个变量,因此不会启动真实 CLI,也不会联系 provider。
|
||||
|
||||
- `RUN_REAL_AGENT_EXEC=1`:显式打开真实探针。其他值(包括 `true`)保持关闭。
|
||||
- `AGENT_EXEC_REAL_PROFILE`:每次恰好一个 profile。允许的名字是 `cursor`、`antigravity`、`claude`、`codex`、`pi`、`kimi`、`opencode`。空值、空白、逗号或空格列表都会拒绝,并且不会启动 CLI。
|
||||
|
||||
一次只跑一个 profile,不要对同一个目标并行开多个探针。
|
||||
|
||||
```bash
|
||||
RUN_REAL_AGENT_EXEC=1 AGENT_EXEC_REAL_PROFILE=cursor \
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest \
|
||||
tests.test_agent_exec.RealAgentCliProbeTests.test_opt_in_real_profile_probe -v
|
||||
```
|
||||
|
||||
把 `cursor` 换成要测的那一个 profile。建议顺序:`cursor`,然后 `antigravity`(可执行文件 `agy`),`claude`,`pi`,`codex`,`kimi`。`opencode` 单独跑。
|
||||
|
||||
## 夹具
|
||||
|
||||
探针在临时 Git 仓库里进行,不使用本仓的规范工作区。
|
||||
|
||||
- 分支是非默认分支 `feature/u5-real-probe`。
|
||||
- 配置里只启用被选中的那一个 profile,`model` 为 `auto`(省略模型参数,不把字符串 `auto` 传给 CLI)。
|
||||
- 单元只允许一个文件:`src/u5-marker.txt`。
|
||||
- 文件字节必须是 ASCII `U5-REAL-PROBE-MARKER` 再加一个换行 `0x0A`。
|
||||
|
||||
## 核对
|
||||
|
||||
Host 只在机械检查全部成立时把能力结论记为 `PASS`:
|
||||
|
||||
- transport 树和工作区里的标记内容与上面的字节完全一致。
|
||||
- transport 提交只有一个父提交,且该父提交就是夹具基线 HEAD。
|
||||
- `changed_paths` 只有 `src/u5-marker.txt`。
|
||||
- 成功时保留隔离工作区,`transport_created` 为真,`exact_restore` 为假。
|
||||
- 模型收据只记录实际看得到的状态。原始输出里没有 `model_receipt_status` 时记 `not-observable`,不得编造成 `verified`。观察到 `mismatch` 时结论不是 `PASS`。
|
||||
- 规范检出的 HEAD、分支、状态和索引保持不变,标记文件不得出现在规范检出里。
|
||||
|
||||
## 不可用与回退
|
||||
|
||||
缺失或不兼容的 OpenCode 必须记 `unavailable`,不得记 `PASS`。不兼容指本地 help 缺少 adapter 要求的能力标志;这种情况下不得把 CLI 当成已经跑通。
|
||||
|
||||
唯一候选耗尽时,控制器的运行级终态可以是 `host-fallback-authorized`。那是回退授权,不是探针通过。夹具不会代替 CLI 去写标记文件。
|
||||
|
||||
Provider 配额、认证失败,或进程启动后没有留下标记且规范检出仍然完好,同样记 `unavailable`,不得记 `PASS`。范围外改动、规范检出被改写、标记内容不符,记 `failed`。
|
||||
|
||||
## 源码外收据
|
||||
|
||||
真实 opt-in 运行会保留临时仓库,并把收据复制到 `/tmp/agent-exec-<uid>/u5-probes/`。原始 CLI 日志留在控制器的私有 run 目录。这些文件不进入 Git。
|
||||
|
||||
默认单元测试使用隔离 PATH 上的假 CLI,只验证上述核对逻辑。
|
||||
|
||||
## 仍为 NOT_RUN
|
||||
|
||||
| profile | 探针 | 模型收据 | transport | 变更路径 | 清理/保留 | 规范检出 | 能力结论 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| cursor | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
|
||||
| antigravity | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
|
||||
| claude | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
|
||||
| codex | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
|
||||
| pi | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
|
||||
| kimi | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
|
||||
| opencode | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN |
|
||||
|
||||
Host 回退演练、完整治理回归、独立 Review、精确 HEAD CI 与合入也不在本夹具的已执行范围内。
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
||||
"""Adapter argv, probe, and custom-expansion contract for agent-exec."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.agent_exec_adapters import (
|
||||
MAX_UNIT_PACKET_BYTES,
|
||||
AdapterProbe,
|
||||
Invocation,
|
||||
build_invocation,
|
||||
probe_adapter,
|
||||
)
|
||||
from scripts.agent_exec_config import (
|
||||
CustomAdapter,
|
||||
ExecutionConfig,
|
||||
ExecutionPolicy,
|
||||
Profile,
|
||||
)
|
||||
|
||||
|
||||
UNIT_PACKET_MARKER = "<unit-packet>"
|
||||
|
||||
|
||||
def _policy() -> ExecutionPolicy:
|
||||
return ExecutionPolicy(
|
||||
fresh_process_per_unit=True,
|
||||
permission="run-everything",
|
||||
fallback_to_host="all-unavailable",
|
||||
max_parallel=1,
|
||||
timeout_seconds=60,
|
||||
summary_bytes=1024,
|
||||
raw_output_bytes=4096,
|
||||
)
|
||||
|
||||
|
||||
def _config(
|
||||
profiles: dict[str, Profile],
|
||||
custom_adapters: dict[str, CustomAdapter] | None = None,
|
||||
) -> ExecutionConfig:
|
||||
names = tuple(profiles)
|
||||
return ExecutionConfig(
|
||||
schema="agent-execution/v1",
|
||||
execution=_policy(),
|
||||
roles={
|
||||
"implementation": names,
|
||||
"repair": names[:1],
|
||||
"test": names[:1],
|
||||
"review": names[:1],
|
||||
},
|
||||
profiles=profiles,
|
||||
custom_adapters=custom_adapters or {},
|
||||
)
|
||||
|
||||
|
||||
def _write_fake_cli(
|
||||
directory: Path,
|
||||
name: str,
|
||||
*,
|
||||
help_text: str,
|
||||
version: str = "0.0.1-test",
|
||||
) -> Path:
|
||||
path = directory / name
|
||||
# Shebang must start at column 0; keep help body out of textwrap.dedent.
|
||||
path.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'if [ "$1" = "--version" ] || [ "$1" = "-v" ] || [ "$1" = "-V" ]; then\n'
|
||||
f' echo "{version}"\n'
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"cat <<'EOF'\n"
|
||||
f"{help_text.rstrip()}\n"
|
||||
"EOF\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
||||
return path
|
||||
|
||||
|
||||
def _assert_bounded_prompt(test: unittest.TestCase, prompt: str, packet_body: str) -> None:
|
||||
test.assertIn(packet_body, prompt)
|
||||
test.assertNotEqual(prompt, packet_body)
|
||||
lowered = prompt.lower()
|
||||
test.assertRegex(lowered, r"implement exactly|bounded")
|
||||
test.assertRegex(lowered, r"workspace")
|
||||
test.assertRegex(lowered, r"uncommitted")
|
||||
test.assertRegex(lowered, r"scope")
|
||||
test.assertRegex(lowered, r"structured|final result")
|
||||
|
||||
|
||||
CURSOR_HELP = """
|
||||
Usage: agent [options] [prompt...]
|
||||
-p, --print
|
||||
--model <model>
|
||||
-f, --force
|
||||
--yolo
|
||||
--sandbox <mode> choices: enabled, disabled
|
||||
--trust
|
||||
--workspace <path>
|
||||
--output-format <format> text | json | stream-json
|
||||
"""
|
||||
|
||||
AGY_HELP = """
|
||||
Usage of agy:
|
||||
--print
|
||||
--dangerously-skip-permissions
|
||||
--mode accept-edits, auto, plan
|
||||
--model
|
||||
--output-format text, json, stream-json
|
||||
"""
|
||||
|
||||
CLAUDE_HELP = """
|
||||
Usage: claude [options] [prompt]
|
||||
-p, --print
|
||||
--permission-mode <mode> bypassPermissions, manual, dontAsk
|
||||
--model <model>
|
||||
--output-format <format> text, json, stream-json
|
||||
"""
|
||||
|
||||
CODEX_HELP = """
|
||||
Usage: codex exec [OPTIONS] [PROMPT]
|
||||
exec
|
||||
-s, --sandbox <SANDBOX_MODE> read-only, workspace-write, danger-full-access
|
||||
-c, --config <key=value>
|
||||
-m, --model <MODEL>
|
||||
-C, --cd <DIR>
|
||||
--json
|
||||
-o, --output-last-message <FILE>
|
||||
"""
|
||||
|
||||
PI_HELP = """
|
||||
Usage: pi [options]
|
||||
--print, -p
|
||||
--approve, -a
|
||||
--model <pattern>
|
||||
--mode <mode> text, json, or rpc
|
||||
"""
|
||||
|
||||
KIMI_HELP = """
|
||||
Usage: kimi [options]
|
||||
-p, --prompt <prompt>
|
||||
--auto
|
||||
-m, --model <model>
|
||||
--output-format <format> text, stream-json
|
||||
"""
|
||||
|
||||
|
||||
class AgentExecAdapterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-adapters-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.bindir = self.root / "bin"
|
||||
self.bindir.mkdir()
|
||||
self.workspace = self.root / "workspace"
|
||||
self.workspace.mkdir()
|
||||
self.unit_packet = self.root / "unit.md"
|
||||
self.packet_body = "# unit\nsecret-token-should-not-leak-in-display\n"
|
||||
self.unit_packet.write_text(self.packet_body, encoding="utf-8")
|
||||
self.result_path = self.root / "result.json"
|
||||
self._old_path = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = f"{self.bindir}{os.pathsep}{self._old_path}"
|
||||
self.addCleanup(self._restore_path)
|
||||
|
||||
def _restore_path(self) -> None:
|
||||
os.environ["PATH"] = self._old_path
|
||||
|
||||
def test_cursor_run_everything_and_auto_model_argv(self) -> None:
|
||||
_write_fake_cli(self.bindir, "cursor-agent", help_text=CURSOR_HELP)
|
||||
profile = Profile(
|
||||
name="cursor",
|
||||
adapter="cursor",
|
||||
executable="cursor-agent",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"cursor": profile})
|
||||
probe = probe_adapter(profile, cfg)
|
||||
self.assertTrue(probe.available, probe.reason)
|
||||
inv = build_invocation(
|
||||
profile, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertIsInstance(inv, Invocation)
|
||||
self.assertEqual(inv.cwd, self.workspace)
|
||||
self.assertEqual(
|
||||
inv.argv[:-1],
|
||||
(
|
||||
"cursor-agent",
|
||||
"-p",
|
||||
"--force",
|
||||
"--sandbox",
|
||||
"enabled",
|
||||
"--trust",
|
||||
"--workspace",
|
||||
str(self.workspace),
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
),
|
||||
)
|
||||
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
|
||||
self.assertNotIn(str(self.unit_packet), inv.argv)
|
||||
self.assertNotIn("--model", inv.argv)
|
||||
self.assertIsNone(inv.stdin)
|
||||
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
|
||||
self.assertNotIn(self.packet_body, " ".join(inv.sanitized_display))
|
||||
self.assertNotIn(inv.argv[-1], inv.sanitized_display)
|
||||
|
||||
def test_antigravity_run_everything_argv(self) -> None:
|
||||
_write_fake_cli(self.bindir, "agy", help_text=AGY_HELP)
|
||||
profile = Profile(
|
||||
name="antigravity",
|
||||
adapter="antigravity",
|
||||
executable="agy",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"antigravity": profile})
|
||||
self.assertTrue(probe_adapter(profile, cfg).available)
|
||||
inv = build_invocation(
|
||||
profile, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertEqual(
|
||||
inv.argv[:-2],
|
||||
(
|
||||
"agy",
|
||||
"--dangerously-skip-permissions",
|
||||
"--mode",
|
||||
"auto",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
),
|
||||
)
|
||||
self.assertEqual(inv.argv[-2], "--print")
|
||||
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
|
||||
self.assertNotIn("--model", inv.argv)
|
||||
self.assertIsNone(inv.stdin)
|
||||
self.assertEqual(inv.cwd, self.workspace)
|
||||
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
|
||||
self.assertEqual(inv.sanitized_display[-2], "--print")
|
||||
self.assertNotIn(self.packet_body, " ".join(inv.sanitized_display))
|
||||
|
||||
def test_claude_run_everything_argv(self) -> None:
|
||||
_write_fake_cli(self.bindir, "claude", help_text=CLAUDE_HELP)
|
||||
profile = Profile(
|
||||
name="claude",
|
||||
adapter="claude",
|
||||
executable="claude",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"claude": profile})
|
||||
self.assertTrue(probe_adapter(profile, cfg).available)
|
||||
inv = build_invocation(
|
||||
profile, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertEqual(
|
||||
inv.argv[:-1],
|
||||
(
|
||||
"claude",
|
||||
"-p",
|
||||
"--permission-mode",
|
||||
"bypassPermissions",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
),
|
||||
)
|
||||
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
|
||||
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
|
||||
|
||||
def test_codex_uses_noninteractive_workspace_sandbox(self) -> None:
|
||||
_write_fake_cli(self.bindir, "codex", help_text=CODEX_HELP)
|
||||
profile = Profile(
|
||||
name="codex",
|
||||
adapter="codex",
|
||||
executable="codex",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"codex": profile})
|
||||
self.assertTrue(probe_adapter(profile, cfg).available)
|
||||
inv = build_invocation(
|
||||
profile, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertEqual(
|
||||
inv.argv[:-1],
|
||||
(
|
||||
"codex",
|
||||
"exec",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"-c",
|
||||
"approval_policy=never",
|
||||
"--cd",
|
||||
str(self.workspace),
|
||||
"--json",
|
||||
"--output-last-message",
|
||||
str(self.result_path),
|
||||
),
|
||||
)
|
||||
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
|
||||
self.assertNotIn("--dangerously-bypass-approvals-and-sandbox", inv.argv)
|
||||
self.assertEqual(inv.sanitized_display[-1], UNIT_PACKET_MARKER)
|
||||
|
||||
def test_pi_and_kimi_auto_model_omit_model_flag(self) -> None:
|
||||
_write_fake_cli(self.bindir, "pi", help_text=PI_HELP)
|
||||
_write_fake_cli(self.bindir, "kimi", help_text=KIMI_HELP)
|
||||
pi = Profile(
|
||||
name="pi", adapter="pi", executable="pi", model=None, enabled=True
|
||||
)
|
||||
kimi = Profile(
|
||||
name="kimi", adapter="kimi", executable="kimi", model=None, enabled=True
|
||||
)
|
||||
cfg = _config({"pi": pi, "kimi": kimi})
|
||||
pi_inv = build_invocation(
|
||||
pi, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
kimi_inv = build_invocation(
|
||||
kimi, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertEqual(
|
||||
pi_inv.argv[:-1],
|
||||
(
|
||||
"pi",
|
||||
"-p",
|
||||
"--approve",
|
||||
"--mode",
|
||||
"json",
|
||||
),
|
||||
)
|
||||
_assert_bounded_prompt(self, pi_inv.argv[-1], self.packet_body)
|
||||
self.assertNotIn("--model", pi_inv.argv)
|
||||
self.assertEqual(
|
||||
kimi_inv.argv[:-1],
|
||||
(
|
||||
"kimi",
|
||||
"--auto",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--prompt",
|
||||
),
|
||||
)
|
||||
_assert_bounded_prompt(self, kimi_inv.argv[-1], self.packet_body)
|
||||
self.assertNotIn("--model", kimi_inv.argv)
|
||||
self.assertNotIn("-m", kimi_inv.argv)
|
||||
self.assertEqual(kimi_inv.sanitized_display[-1], UNIT_PACKET_MARKER)
|
||||
self.assertNotIn(self.packet_body, " ".join(kimi_inv.sanitized_display))
|
||||
self.assertNotIn(kimi_inv.argv[-1], kimi_inv.sanitized_display)
|
||||
|
||||
def test_missing_opencode_is_unavailable(self) -> None:
|
||||
profile = Profile(
|
||||
name="opencode",
|
||||
adapter="opencode",
|
||||
executable="opencode",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"opencode": profile})
|
||||
# Ensure no opencode on PATH from bindir; real PATH may also lack it.
|
||||
probe = probe_adapter(profile, cfg)
|
||||
self.assertIsInstance(probe, AdapterProbe)
|
||||
self.assertFalse(probe.available)
|
||||
self.assertIsNotNone(probe.reason)
|
||||
|
||||
def test_fixed_model_uses_only_adapter_model_flag(self) -> None:
|
||||
_write_fake_cli(self.bindir, "cursor-agent", help_text=CURSOR_HELP)
|
||||
profile = Profile(
|
||||
name="cursor",
|
||||
adapter="cursor",
|
||||
executable="cursor-agent",
|
||||
model="gpt-5",
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"cursor": profile})
|
||||
inv = build_invocation(
|
||||
profile, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertIn("--model", inv.argv)
|
||||
model_idx = inv.argv.index("--model")
|
||||
self.assertEqual(inv.argv[model_idx + 1], "gpt-5")
|
||||
self.assertEqual(inv.argv.count("--model"), 1)
|
||||
self.assertNotIn("auto", inv.argv)
|
||||
_assert_bounded_prompt(self, inv.argv[-1], self.packet_body)
|
||||
|
||||
def test_custom_adapter_builds_direct_argv_without_shell(self) -> None:
|
||||
_write_fake_cli(
|
||||
self.bindir,
|
||||
"mycli",
|
||||
help_text="Usage: mycli --yes --cwd --model --output-format",
|
||||
)
|
||||
profile = Profile(
|
||||
name="mycustom",
|
||||
adapter="custom",
|
||||
executable="mycli",
|
||||
model="fixed-model",
|
||||
enabled=True,
|
||||
)
|
||||
custom = CustomAdapter(
|
||||
name="mycustom",
|
||||
base_argv=("run", "${unit_packet}"),
|
||||
run_everything_argv=("--yes",),
|
||||
workspace_argv=("--cwd", "${workspace}"),
|
||||
model_argv=("--model", "${model}"),
|
||||
prompt_transport="stdin",
|
||||
output_format="json",
|
||||
)
|
||||
cfg = _config({"mycustom": profile}, {"mycustom": custom})
|
||||
inv = build_invocation(
|
||||
profile, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertEqual(
|
||||
inv.argv,
|
||||
(
|
||||
"mycli",
|
||||
"run",
|
||||
str(self.unit_packet),
|
||||
"--yes",
|
||||
"--cwd",
|
||||
str(self.workspace),
|
||||
"--model",
|
||||
"fixed-model",
|
||||
"--output-format",
|
||||
"json",
|
||||
),
|
||||
)
|
||||
self.assertEqual(inv.stdin, self.unit_packet.read_bytes())
|
||||
self.assertNotIn("&&", " ".join(inv.argv))
|
||||
self.assertNotIn("|", " ".join(inv.argv))
|
||||
# Path placeholder is fine; body must not appear in display.
|
||||
self.assertNotIn(self.packet_body.strip(), " ".join(inv.sanitized_display))
|
||||
# auto model must omit the whole model_argv pair
|
||||
auto_profile = Profile(
|
||||
name="mycustom",
|
||||
adapter="custom",
|
||||
executable="mycli",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
auto_inv = build_invocation(
|
||||
auto_profile, cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
self.assertNotIn("--model", auto_inv.argv)
|
||||
self.assertNotIn("auto", auto_inv.argv)
|
||||
|
||||
# Positional custom transport may embed the bounded prompt, but display
|
||||
# must use the stable marker.
|
||||
positional = CustomAdapter(
|
||||
name="mycustom",
|
||||
base_argv=("run",),
|
||||
run_everything_argv=("--yes",),
|
||||
workspace_argv=("--cwd", "${workspace}"),
|
||||
model_argv=("--model", "${model}"),
|
||||
prompt_transport="positional",
|
||||
output_format="json",
|
||||
)
|
||||
pos_cfg = _config({"mycustom": profile}, {"mycustom": positional})
|
||||
pos_inv = build_invocation(
|
||||
profile, pos_cfg, self.workspace, self.unit_packet, self.result_path
|
||||
)
|
||||
_assert_bounded_prompt(self, pos_inv.argv[-1], self.packet_body)
|
||||
self.assertEqual(pos_inv.sanitized_display[-1], UNIT_PACKET_MARKER)
|
||||
self.assertNotIn(pos_inv.argv[-1], pos_inv.sanitized_display)
|
||||
|
||||
def test_unverified_run_everything_capability_is_unavailable(self) -> None:
|
||||
_write_fake_cli(
|
||||
self.bindir,
|
||||
"cursor-agent",
|
||||
help_text="Usage: agent\n -p, --print\n --model <model>\n",
|
||||
)
|
||||
profile = Profile(
|
||||
name="cursor",
|
||||
adapter="cursor",
|
||||
executable="cursor-agent",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"cursor": profile})
|
||||
probe = probe_adapter(profile, cfg)
|
||||
self.assertFalse(probe.available)
|
||||
self.assertIsNotNone(probe.reason)
|
||||
self.assertRegex(probe.reason or "", r"unavailable|missing|capability|flag", re.I)
|
||||
|
||||
def test_rejects_oversized_and_invalid_utf8_unit_packet(self) -> None:
|
||||
_write_fake_cli(self.bindir, "cursor-agent", help_text=CURSOR_HELP)
|
||||
profile = Profile(
|
||||
name="cursor",
|
||||
adapter="cursor",
|
||||
executable="cursor-agent",
|
||||
model=None,
|
||||
enabled=True,
|
||||
)
|
||||
cfg = _config({"cursor": profile})
|
||||
|
||||
oversized = self.root / "oversized.md"
|
||||
oversized.write_bytes(b"x" * (MAX_UNIT_PACKET_BYTES + 1))
|
||||
with self.assertRaises(ValueError) as oversized_ctx:
|
||||
build_invocation(
|
||||
profile, cfg, self.workspace, oversized, self.result_path
|
||||
)
|
||||
self.assertRegex(str(oversized_ctx.exception).lower(), r"size|large|bound|limit")
|
||||
|
||||
invalid = self.root / "invalid.md"
|
||||
invalid.write_bytes(b"\xff\xfe not utf-8")
|
||||
with self.assertRaises(ValueError) as utf8_ctx:
|
||||
build_invocation(
|
||||
profile, cfg, self.workspace, invalid, self.result_path
|
||||
)
|
||||
self.assertRegex(str(utf8_ctx.exception).lower(), r"utf-8|utf8|encoding|decode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Configuration parser and schema contract for AGENT-EXECUTION.md."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.agent_exec_config import (
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS,
|
||||
ConfigError,
|
||||
ExecutionConfig,
|
||||
load_config,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = "agent-execution/v1"
|
||||
|
||||
MINIMAL_BLOCK = textwrap.dedent(
|
||||
"""\
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: all-unavailable
|
||||
max_parallel: 3
|
||||
timeout_seconds: 7200
|
||||
summary_bytes: 16384
|
||||
raw_output_bytes: 10485760
|
||||
|
||||
roles:
|
||||
implementation: [cursor, claude]
|
||||
repair: [cursor]
|
||||
test: [claude]
|
||||
review: [claude]
|
||||
|
||||
profiles:
|
||||
cursor:
|
||||
adapter: cursor
|
||||
executable: cursor-agent
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
claude:
|
||||
adapter: claude
|
||||
executable: claude
|
||||
model: auto
|
||||
enabled: true
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def write_repo(root: Path, body: str, *, extra_blocks: list[str] | None = None) -> Path:
|
||||
parts = ["# Fixture\n", "```agent-execution\n", body.rstrip(), "\n```\n"]
|
||||
if extra_blocks:
|
||||
for block in extra_blocks:
|
||||
parts.extend(["\n```agent-execution\n", block.rstrip(), "\n```\n"])
|
||||
(root / "AGENT-EXECUTION.md").write_text("".join(parts), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
class AgentExecConfigTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-config-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.repo = Path(self.tmp.name)
|
||||
|
||||
def test_loads_single_agent_execution_block(self) -> None:
|
||||
write_repo(self.repo, MINIMAL_BLOCK)
|
||||
self.assertTrue(issubclass(ConfigError, ValueError))
|
||||
cfg = load_config(self.repo)
|
||||
self.assertIsInstance(cfg, ExecutionConfig)
|
||||
self.assertEqual(cfg.schema, SCHEMA)
|
||||
self.assertTrue(cfg.execution.fresh_process_per_unit)
|
||||
self.assertEqual(cfg.execution.permission, "run-everything")
|
||||
self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable")
|
||||
self.assertEqual(cfg.roles["implementation"], ("cursor", "claude"))
|
||||
self.assertEqual(cfg.profiles["cursor"].executable, "cursor-agent")
|
||||
self.assertEqual(cfg.custom_adapters, {})
|
||||
self.assertEqual(validate_config(self.repo), [])
|
||||
|
||||
def test_rejects_missing_or_duplicate_blocks(self) -> None:
|
||||
(self.repo / "AGENT-EXECUTION.md").write_text("# no block\n", encoding="utf-8")
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
write_repo(self.repo, MINIMAL_BLOCK, extra_blocks=[MINIMAL_BLOCK])
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
def test_rejects_unknown_schema_and_unknown_keys(self) -> None:
|
||||
bad_schema = MINIMAL_BLOCK.replace(
|
||||
"schema: agent-execution/v1", "schema: agent-execution/v0"
|
||||
)
|
||||
write_repo(self.repo, bad_schema)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
bad_key = MINIMAL_BLOCK + "\nextra_top_level: true\n"
|
||||
write_repo(self.repo, bad_key)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
bad_exec = MINIMAL_BLOCK.replace(
|
||||
"raw_output_bytes: 10485760\n",
|
||||
"raw_output_bytes: 10485760\n mystery: 1\n",
|
||||
)
|
||||
write_repo(self.repo, bad_exec)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
def test_role_order_does_not_follow_profile_order(self) -> None:
|
||||
# profiles listed claude before cursor; role order must stay cursor -> claude
|
||||
block = textwrap.dedent(
|
||||
"""\
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: all-unavailable
|
||||
max_parallel: 1
|
||||
timeout_seconds: 60
|
||||
summary_bytes: 1024
|
||||
raw_output_bytes: 4096
|
||||
|
||||
roles:
|
||||
implementation: [cursor, claude]
|
||||
repair: [cursor]
|
||||
test: [claude]
|
||||
review: [claude]
|
||||
|
||||
profiles:
|
||||
claude:
|
||||
adapter: claude
|
||||
executable: claude
|
||||
model: auto
|
||||
enabled: true
|
||||
cursor:
|
||||
adapter: cursor
|
||||
executable: cursor-agent
|
||||
model: auto
|
||||
enabled: true
|
||||
"""
|
||||
)
|
||||
write_repo(self.repo, block)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertEqual(cfg.roles["implementation"], ("cursor", "claude"))
|
||||
self.assertEqual(list(cfg.profiles.keys())[0], "claude")
|
||||
|
||||
def test_model_auto_is_preserved_as_none(self) -> None:
|
||||
write_repo(self.repo, MINIMAL_BLOCK)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertIsNone(cfg.profiles["cursor"].model)
|
||||
self.assertIsNone(cfg.profiles["claude"].model)
|
||||
|
||||
def test_rejects_unknown_profile_reference(self) -> None:
|
||||
block = MINIMAL_BLOCK.replace(
|
||||
"implementation: [cursor, claude]",
|
||||
"implementation: [cursor, claude, missing]",
|
||||
)
|
||||
write_repo(self.repo, block)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
def test_rejects_shell_command_and_secret_shaped_values(self) -> None:
|
||||
shell = MINIMAL_BLOCK.replace(
|
||||
"executable: cursor-agent",
|
||||
"executable: \"cursor-agent && rm -rf /\"",
|
||||
)
|
||||
write_repo(self.repo, shell)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
secret_key = MINIMAL_BLOCK.replace(
|
||||
"enabled: true\n\n claude:",
|
||||
"enabled: true\n api_token: \"sk-secret-value\"\n\n claude:",
|
||||
)
|
||||
write_repo(self.repo, secret_key)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
secret_val = MINIMAL_BLOCK.replace(
|
||||
"executable: claude",
|
||||
"executable: \"Bearer abcdef0123456789abcdef0123456789\"",
|
||||
)
|
||||
write_repo(self.repo, secret_val)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
def test_custom_placeholders_must_fill_whole_argv_items(self) -> None:
|
||||
good = textwrap.dedent(
|
||||
"""\
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: all-unavailable
|
||||
max_parallel: 1
|
||||
timeout_seconds: 60
|
||||
summary_bytes: 1024
|
||||
raw_output_bytes: 4096
|
||||
|
||||
roles:
|
||||
implementation: [mycustom]
|
||||
repair: [mycustom]
|
||||
test: [mycustom]
|
||||
review: [mycustom]
|
||||
|
||||
profiles:
|
||||
mycustom:
|
||||
adapter: custom
|
||||
executable: mycli
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
custom_adapters:
|
||||
mycustom:
|
||||
base_argv: ["run", "${unit_packet}"]
|
||||
run_everything_argv: ["--yes"]
|
||||
workspace_argv: ["--cwd", "${workspace}"]
|
||||
model_argv: ["--model", "${model}"]
|
||||
prompt_transport: stdin
|
||||
output_format: json
|
||||
"""
|
||||
)
|
||||
write_repo(self.repo, good)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertEqual(
|
||||
cfg.custom_adapters["mycustom"].workspace_argv,
|
||||
("--cwd", "${workspace}"),
|
||||
)
|
||||
|
||||
bad = good.replace(
|
||||
'workspace_argv: ["--cwd", "${workspace}"]',
|
||||
'workspace_argv: ["--cwd=${workspace}"]',
|
||||
)
|
||||
write_repo(self.repo, bad)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
unknown_ph = good.replace(
|
||||
'base_argv: ["run", "${unit_packet}"]',
|
||||
'base_argv: ["run", "${other}"]',
|
||||
)
|
||||
write_repo(self.repo, unknown_ph)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
def test_numeric_limits_are_positive_and_bounded(self) -> None:
|
||||
zero = MINIMAL_BLOCK.replace("max_parallel: 3", "max_parallel: 0")
|
||||
write_repo(self.repo, zero)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
negative = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: -1")
|
||||
write_repo(self.repo, negative)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
huge = MINIMAL_BLOCK.replace("summary_bytes: 16384", "summary_bytes: 999999999")
|
||||
write_repo(self.repo, huge)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
inverted = MINIMAL_BLOCK.replace(
|
||||
"summary_bytes: 16384\n raw_output_bytes: 10485760",
|
||||
"summary_bytes: 10485760\n raw_output_bytes: 16384",
|
||||
)
|
||||
write_repo(self.repo, inverted)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
def test_fresh_process_per_unit_must_be_true(self) -> None:
|
||||
block = MINIMAL_BLOCK.replace(
|
||||
"fresh_process_per_unit: true",
|
||||
"fresh_process_per_unit: false",
|
||||
)
|
||||
write_repo(self.repo, block)
|
||||
with self.assertRaises(ConfigError) as ctx:
|
||||
load_config(self.repo)
|
||||
self.assertIn("fresh_process_per_unit", str(ctx.exception))
|
||||
|
||||
def test_rejects_indentation_jump(self) -> None:
|
||||
# root mapping child jumps from 0 spaces to 4 spaces (must be exactly +2)
|
||||
jumped = textwrap.dedent(
|
||||
"""\
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: all-unavailable
|
||||
max_parallel: 1
|
||||
timeout_seconds: 60
|
||||
summary_bytes: 1024
|
||||
raw_output_bytes: 4096
|
||||
|
||||
roles:
|
||||
implementation: [cursor]
|
||||
repair: [cursor]
|
||||
test: [cursor]
|
||||
review: [cursor]
|
||||
|
||||
profiles:
|
||||
cursor:
|
||||
adapter: cursor
|
||||
executable: cursor-agent
|
||||
model: auto
|
||||
enabled: true
|
||||
"""
|
||||
)
|
||||
write_repo(self.repo, jumped)
|
||||
with self.assertRaises(ConfigError) as ctx:
|
||||
load_config(self.repo)
|
||||
self.assertRegex(str(ctx.exception).lower(), r"indent|indentation")
|
||||
|
||||
def test_quoted_unicode_string_round_trips(self) -> None:
|
||||
block = MINIMAL_BLOCK.replace(
|
||||
"executable: claude",
|
||||
'executable: "模型-β-测试"',
|
||||
)
|
||||
write_repo(self.repo, block)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertEqual(cfg.profiles["claude"].executable, "模型-β-测试")
|
||||
|
||||
escaped = MINIMAL_BLOCK.replace(
|
||||
"executable: claude",
|
||||
r'executable: "say \"hi\" 模型"',
|
||||
)
|
||||
write_repo(self.repo, escaped)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertEqual(cfg.profiles["claude"].executable, 'say "hi" 模型')
|
||||
|
||||
def test_idle_timeout_defaults_within_total_and_rejects_bounds(self) -> None:
|
||||
self.assertEqual(DEFAULT_IDLE_TIMEOUT_SECONDS, 600)
|
||||
write_repo(self.repo, MINIMAL_BLOCK)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertEqual(cfg.execution.idle_timeout_seconds, 600)
|
||||
self.assertLessEqual(cfg.execution.idle_timeout_seconds, cfg.execution.timeout_seconds)
|
||||
|
||||
short = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: 60")
|
||||
write_repo(self.repo, short)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertEqual(cfg.execution.timeout_seconds, 60)
|
||||
self.assertEqual(cfg.execution.idle_timeout_seconds, 60)
|
||||
|
||||
explicit = MINIMAL_BLOCK.replace(
|
||||
"timeout_seconds: 7200\n",
|
||||
"timeout_seconds: 7200\n idle_timeout_seconds: 600\n",
|
||||
)
|
||||
write_repo(self.repo, explicit)
|
||||
self.assertEqual(load_config(self.repo).execution.idle_timeout_seconds, 600)
|
||||
|
||||
equal = MINIMAL_BLOCK.replace(
|
||||
"timeout_seconds: 7200\n",
|
||||
"timeout_seconds: 100\n idle_timeout_seconds: 100\n",
|
||||
)
|
||||
write_repo(self.repo, equal)
|
||||
cfg = load_config(self.repo)
|
||||
self.assertEqual(cfg.execution.idle_timeout_seconds, 100)
|
||||
self.assertEqual(cfg.execution.timeout_seconds, 100)
|
||||
|
||||
above = MINIMAL_BLOCK.replace(
|
||||
"timeout_seconds: 7200\n",
|
||||
"timeout_seconds: 100\n idle_timeout_seconds: 101\n",
|
||||
)
|
||||
write_repo(self.repo, above)
|
||||
with self.assertRaises(ConfigError) as ctx:
|
||||
load_config(self.repo)
|
||||
self.assertIn("idle_timeout_seconds", str(ctx.exception))
|
||||
|
||||
zero = MINIMAL_BLOCK.replace(
|
||||
"timeout_seconds: 7200\n",
|
||||
"timeout_seconds: 7200\n idle_timeout_seconds: 0\n",
|
||||
)
|
||||
write_repo(self.repo, zero)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
negative = MINIMAL_BLOCK.replace(
|
||||
"timeout_seconds: 7200\n",
|
||||
"timeout_seconds: 7200\n idle_timeout_seconds: -5\n",
|
||||
)
|
||||
write_repo(self.repo, negative)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
flagged = MINIMAL_BLOCK.replace(
|
||||
"timeout_seconds: 7200\n",
|
||||
"timeout_seconds: 7200\n idle_timeout_seconds: true\n",
|
||||
)
|
||||
write_repo(self.repo, flagged)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
fractional = MINIMAL_BLOCK.replace(
|
||||
"timeout_seconds: 7200\n",
|
||||
"timeout_seconds: 7200\n idle_timeout_seconds: 1.5\n",
|
||||
)
|
||||
write_repo(self.repo, fractional)
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(self.repo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+101
-6
@@ -59,20 +59,24 @@ case "${1:-}" in
|
||||
[ -n "${AGENT_SYSTEM_DSN:-}" ] && printf 'AGENT_SYSTEM_DSN=%s\n' "$AGENT_SYSTEM_DSN"
|
||||
[ -n "${AGENT_SYSTEM_AUTH:-}" ] && printf 'AGENT_SYSTEM_AUTH=%s\n' "$AGENT_SYSTEM_AUTH"
|
||||
} > "$trace_dir/serve.txt"
|
||||
addr=""
|
||||
addr="" data=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
|
||||
case "$1" in
|
||||
--addr) addr="$2"; shift 2 ;;
|
||||
--data) data="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
port="${addr##*:}"; host="${addr%:*}"
|
||||
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 套件
|
||||
# 并行运行时泄漏端口占用(与 test_svc_recover 桩同规)。
|
||||
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
|
||||
import os, sys, threading, time
|
||||
# 共用 1880x 端口时泄漏占用(与 test_svc_recover 桩同规)。
|
||||
python3 - "$port" "$host" "$trace_dir" "$data" <<'PYSRV' >/dev/null 2>&1 &
|
||||
import json, os, sqlite3, sys, threading, time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
|
||||
port, host, trace, data = int(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def _ok(self, body=b'{"ok":true}'):
|
||||
self.send_response(200)
|
||||
@@ -89,7 +93,35 @@ class H(BaseHTTPRequestHandler):
|
||||
body = self.rfile.read(n).decode('utf-8', 'replace')
|
||||
with open(trace + '/put.txt', 'a') as f:
|
||||
f.write('AUTH=%s\nBODY=%s\n--\n' % (self.headers.get('Authorization') or '-', body))
|
||||
self._adopt(body)
|
||||
self._ok()
|
||||
def _adopt(self, body):
|
||||
# 模拟当前后端:系统设置里的非掩码主模型 key 成为系统行生效密钥。
|
||||
if os.environ.get('SVC_STUB_KEEP_OLD_SECRET') == '1':
|
||||
return
|
||||
db = os.path.join(data, 'agent-system.db')
|
||||
if not db or not os.path.exists(db):
|
||||
return
|
||||
try:
|
||||
incoming = json.loads(body)
|
||||
chat = incoming.get('models', {}).get('chat', {}).get('apiKey')
|
||||
except Exception:
|
||||
return
|
||||
if not chat:
|
||||
return
|
||||
con = sqlite3.connect(db, timeout=5)
|
||||
try:
|
||||
tables = {row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if 'secret_keys' not in tables:
|
||||
return
|
||||
con.execute("UPDATE secret_keys SET active=0 WHERE owner='' AND service='models.chat'")
|
||||
con.execute(
|
||||
"INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,1,?)",
|
||||
('key_seeded', '', 'models.chat', '播种', chat, '2026-10-15T00:00:00Z'),
|
||||
)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
ppid = os.getppid()
|
||||
@@ -750,6 +782,26 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.assertIn("apikey create --dsn %s --owner op --name svc-seed" % dsn, cli)
|
||||
self.assertIn("apikey revoke --dsn %s --owner op --name svc-seed" % dsn, cli)
|
||||
|
||||
def test_seed_writes_fallbacks_and_vision(self):
|
||||
self.register_pg("pg1", 18951, password="seed-pw-v")
|
||||
dsn = "postgresql://agent:seed-pw-v@127.0.0.1:18951/agent"
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18952,
|
||||
"--addr 127.0.0.1:18952 --data %s --plugins %s --auth auto --dsn %s"
|
||||
% (d / "data", d / "plugins", dsn))
|
||||
result = self.svc("dev", "start", env=self.model_env(
|
||||
SVC_MODEL_FALLBACKS="ZHIPU/GLM-5.3-FlashX,ZHIPU/GLM-5.3-Flash",
|
||||
SVC_VISION_MODEL="qwen3.8-flash",
|
||||
SVC_VISION_CAPABLE="deepseek-v4.1-flash,qwen3.8-flash",
|
||||
))
|
||||
self.assertIn("已播种设置:主模型", result.stdout)
|
||||
self.assertIn("回退链", result.stdout)
|
||||
self.assertIn("视觉模型", result.stdout)
|
||||
body_part = self.put_record().split("BODY=", 1)[1]
|
||||
self.assertIn('"fallbacks": "ZHIPU/GLM-5.3-FlashX,ZHIPU/GLM-5.3-Flash"', body_part)
|
||||
self.assertIn('"model": "qwen3.8-flash"', body_part)
|
||||
self.assertIn('"capable": "deepseek-v4.1-flash,qwen3.8-flash"', body_part)
|
||||
|
||||
def test_seed_owner_override_skips_account_lookup(self):
|
||||
port = self.available_port()
|
||||
self.register_pg("pg1", 18833, password="seed-pw-2")
|
||||
@@ -778,6 +830,49 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.assertIn('"model": "stub-model"', put.split("BODY=", 1)[1])
|
||||
self.assertNotIn("apikey", self.cli_record()) # 认证关闭不铸 key
|
||||
|
||||
def plant_old_chat_secret(self, name, value):
|
||||
import sqlite3
|
||||
db = self.runtime(name) / "data" / "agent-system.db"
|
||||
con = sqlite3.connect(db)
|
||||
con.execute(
|
||||
"CREATE TABLE secret_keys(id TEXT, owner TEXT, service TEXT, label TEXT, value TEXT, active INTEGER, created_at TEXT)")
|
||||
con.execute(
|
||||
"INSERT INTO secret_keys VALUES('key_old','','models.chat','迁入',?,1,'2026-10-11T00:00:00Z')",
|
||||
(value,))
|
||||
con.commit()
|
||||
con.close()
|
||||
return db
|
||||
|
||||
def test_seed_replaces_old_system_secret(self):
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18836,
|
||||
"--addr 127.0.0.1:18836 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"))
|
||||
db = self.plant_old_chat_secret("dev", "sk-old-secret")
|
||||
result = self.svc("dev", "start", env=self.model_env())
|
||||
self.assertIn("已播种设置:主模型", result.stdout)
|
||||
self.assertNotIn("生效密钥与环境变量不一致", result.stdout)
|
||||
self.assertNotIn("sk-old-secret", result.stdout)
|
||||
self.assertNotIn("sk-stub-model", result.stdout)
|
||||
import sqlite3
|
||||
active = sqlite3.connect(db).execute(
|
||||
"SELECT value FROM secret_keys WHERE owner='' AND service='models.chat' AND active=1"
|
||||
).fetchone()
|
||||
self.assertEqual(active[0], "sk-stub-model")
|
||||
|
||||
def test_seed_warns_when_old_secret_stays_active(self):
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18837,
|
||||
"--addr 127.0.0.1:18837 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"))
|
||||
self.plant_old_chat_secret("dev", "sk-old-secret")
|
||||
result = self.svc("dev", "start", env=self.model_env(SVC_STUB_KEEP_OLD_SECRET="1"))
|
||||
self.assertIn("已播种设置:主模型", result.stdout)
|
||||
self.assertIn("生效密钥与环境变量不一致", result.stdout)
|
||||
self.assertIn("models.chat fp=", result.stdout)
|
||||
self.assertNotIn("sk-old-secret", result.stdout)
|
||||
self.assertNotIn("sk-stub-model", result.stdout)
|
||||
|
||||
|
||||
# ---- 13. rdm 部署源(下游组合仓 Agent-System-RDM):物化/产物/工作台/播种跳过 ----
|
||||
|
||||
|
||||
@@ -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()
|
||||
+28
-9
@@ -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"):
|
||||
@@ -705,11 +708,27 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
self.assertTrue((wt / ".task/feature-new.md").is_file())
|
||||
card = (wt / ".task/feature-new.md").read_text()
|
||||
self.assertIn("make precheck", card)
|
||||
self.assertIn("交付模式:manual(前序人工决策,智能体 Review/合入)", card)
|
||||
self.assertIn("authorized-continuous", card)
|
||||
self.assertIn("交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)", card)
|
||||
self.assertIn("实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)", card)
|
||||
self.assertIn("人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收", card)
|
||||
self.assertNotIn("authorized-continuous", card)
|
||||
self.assertNotIn("cursor-per-unit", card)
|
||||
self.assertNotIn("Cursor unavailable means blocker only", card)
|
||||
self.assertNotIn("不得交回主模型代写", card)
|
||||
self.assertNotIn("never return to host", card)
|
||||
self.assertIn("流程依据:仓库根目录 policy/delivery.md", card)
|
||||
for field in ("授权范围", "授权来源", "授权终点", "停止条件"):
|
||||
self.assertIn(f"{field}:无", card)
|
||||
self.assertIn("## Agent 实施收据\n", card)
|
||||
self.assertNotIn("## Cursor 实施收据\n", card)
|
||||
agent_receipts = card.split("## Agent 实施收据\n", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertIn(
|
||||
"| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |",
|
||||
agent_receipts,
|
||||
)
|
||||
self.assertIn(
|
||||
"| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |",
|
||||
agent_receipts,
|
||||
)
|
||||
self.assertIn("host-fallback-authorized", agent_receipts)
|
||||
for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"):
|
||||
self.assertIn(field + ":", card)
|
||||
self.assertIn("## 交付调度\n", card)
|
||||
@@ -725,15 +744,15 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
self.assertIn("路径按仓库根目录定位,归档后不变", planning)
|
||||
self.assertIn("只引用唯一正文", planning)
|
||||
self.assertIn("| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 |", card)
|
||||
reviews = card.split("## 阶段评审\n", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertIn("| 阶段 | 对象版本 | 结论 | 评审人 | 时间 | 依据 |", reviews)
|
||||
reviews = card.split("## 阶段记录\n", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertIn("| 阶段 | 对象版本 | 状态 | 决策/执行者 | 时间 | 依据 |", reviews)
|
||||
for stage in ("需求拆解", "需求评估", "需求表确认", "方案设计", "实施计划", "实施与自检",
|
||||
"正式测试与验收", "代码 Review", "合入", "合入核验与收口"):
|
||||
row = next(line for line in reviews.splitlines() if line.startswith(f"| {stage} |"))
|
||||
self.assertEqual(row.split("|")[3].strip(), "未评审")
|
||||
self.assertEqual(row.split("|")[3].strip(), "待执行")
|
||||
self.assertNotIn("通过", reviews)
|
||||
self.assertIn("## 探索试验与迭代", card)
|
||||
for field in ("假设", "范围", "判断和停止条件", "批准依据", "结果", "变更影响", "需重审补测"):
|
||||
for field in ("假设", "范围", "判断和停止条件", "授权/决策依据", "结果", "变更影响", "需重审补测"):
|
||||
self.assertIn(field, card.split("## 探索试验与迭代\n", 1)[1].split("\n## ", 1)[0])
|
||||
self.assertIn("## 规划进展与下一步\n", card)
|
||||
next_step = card.split("## 规划进展与下一步\n", 1)[1].split("\n## ", 1)[0]
|
||||
|
||||
Reference in New Issue
Block a user