diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md new file mode 100644 index 00000000..50d5e150 --- /dev/null +++ b/.task/feature-agent-cli-enforcement.md @@ -0,0 +1,235 @@ +# feature/agent-cli-enforcement 任务卡 + +任务主记录:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/572;状态:交付中;owner:weichen + +## 基线与授权 + +- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee +- 工作区:/tmp/codex-cli-fallback-admin.82BLPc/repo-wt-feature-agent-cli-enforcement(worktree);分支:feature/agent-cli-enforcement + +- 授权与交付终点:强制实施/修复/测试使用 Cursor、agy、PI;每候选安全失败最多 3 次,全部安全耗尽后才允许一次 Codex CLI 紧急尝试;Codex 平时仅方案/计划/独立 Review;以 apply/admit 精确来源门禁阻止 host 直接实施。完成实现、独立测试、独立 Review、精确 HEAD CI、合入与安全收口;不修改业务代码、子模块或部署。 +- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进) +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md) +- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入) +- 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收 +- 当前阶段:最终候选 `d3c28d5a53d9e3205798b71c89ef1110e5996050` 已完成独立 PI 回归与独立 Codex Review;等待最终同步、PR HEAD CI、合入与收口 +- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) + +## 目标与非目标 + +目标:让 CLI 调用由配置、状态机和提交门禁强制,而不是依赖智能体自觉;CLI 在目标/范围/安全边界内可充分思考、检查、测试和迭代。非目标:不限制 CLI 的推理时长、工具次数、规划方式或首次写文件时间;不建设通用平台或修改业务功能。 + +## Agent 实施收据 + +按根目录 AGENT-EXECUTION.md 路由;正常实施/修复/测试候选仅 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次;全部安全耗尽后一次 Codex CLI 紧急尝试。host 不代写。成功实施只经 transport apply 或 pre-commit admit 的签名精确来源进入分支。直接 CLI 隔离会话的失败/成功按真实终态登记;没有标准收据时标明 `not-observable`,不补写。 + +| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---|---| +| U1 路由重试与 Codex 紧急档 | parser/controller/tests/config | AGENT-EXECUTION.md | implementation/test | Cursor→agy→PI | 多个全新 session | Cursor/agy/PI MiniMax | 完成;失败会话不计成功 | 隔离 clone 集成 | Codex 未触发 | direct CLI sessions;session IDs 见 host 日志 | +| U2 apply/admit + HMAC 来源 | controller/hook/Makefile/tests | AGENT-EXECUTION.md | implementation | Cursor | 全新 session | cursor-agent-stable / Auto | 完成 | 隔离 clone 精确 cherry-pick | 未触发 | direct CLI session;签名历史在 git common dir | +| U3 规范与模板 | AGENTS/policy/task template/tests | AGENT-EXECUTION.md | implementation/test | Cursor→PI | 各阶段全新 session | Cursor Auto;PI MiniMax | 完成;独立测试通过 | host 管理集成 | 未触发 | PI test session `01a14098-edd6-77fa-a4c2-e38b453a68ad` | +| U4 Review 阻塞修复 | terminal decode / failed-clean / local integrity / strict routes | AGENT-EXECUTION.md | repair | Cursor | 多个全新 session | cursor-agent-stable / Auto | 完成;每次由 host 审查 transport 后 apply | `6b00f4f`、`253bce3` | 未触发 | `/tmp/agent-cli-review-blockers-run.json`、`/tmp/agent-cli-terminal-finality-run.json` | +| U5 最终路由与路径修复 | config/admission/tests | AGENT-EXECUTION.md | repair | Cursor | 全新 session | cursor-agent-stable / Auto | 完成;首个不完整候选未集成,v2 通过 | `d3c28d5` | 未触发 | `/tmp/agent-cli-final-review-fixes-v2-run.json` | +| U6 独立回归 | agent-exec/governance tests | AGENT-EXECUTION.md | test | PI | 全新 session | PI / MiniMax | 通过;HEAD 与工作树未变化 | 无写入 | 未触发 | session ID not-observable;终态见阶段记录 | + +## 关联方向与规划 + +- 适用状态:待核对(无适用规划时说明原因) +- 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文) +- 对应里程碑:待核对 + +## 需求分析 + +| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | +|---|---|---|---|---|---|---| +| R1 | 正常实施/修复/测试只走 Cursor、agy、PI,每候选最多 3 次安全失败后切换 | 现有 agent_exec 状态机可扩展 | P0 | 配置与 controller 测试证明顺序、次数与 fail-stop | agent_exec | 已实现 | +| R2 | 全部正常候选安全耗尽后仅一次 Codex CLI 紧急尝试;无 host fallback | Codex adapter 已存在 | P0 | 紧急尝试单次、独立、失败 blocked、授权数为 0 | R1 | 已实现 | +| R3 | 不限制 CLI 思考/工具/首次写入,只用显式终态、总超时、真空闲超时结束 | supervisor 已跟踪 I/O 活动 | P0 | 规范与任务模板一致,测试覆盖 idle/total timeout | R1 | 已实现 | +| R4 | 实施代码必须有签名成功 transport 来源 | 已有 transport/history | P0 | apply/admit 校验 HMAC、commit/ref/parent/path/content;pre-commit 拒绝无来源 | R1 | 已实现 | + +## 方案与计划 + +- 方案引用:待填写(含对象版本) +- 实施计划引用:待填写(含增量与需求 ID) +- 测试计划引用:agent_exec/config/admission/task 聚焦测试;`make test-governance`;PR HEAD 三项 CI + +## 阶段记录 + +| 阶段 | 对象版本 | 状态 | 决策/执行者 | 时间 | 依据 | +|---|---|---|---|---|---| +| 需求拆解 | 待定 | 待执行 | — | — | — | +| 需求评估 | 待定 | 待执行 | — | — | — | +| 需求表确认 | 待定 | 待执行 | — | — | — | +| 方案设计 | 待定 | 待执行 | — | — | — | +| 实施计划 | 待定 | 待执行 | — | — | — | +| 实施与自检 | `818e17f` 及后续 Review 修复 | 通过(SELF) | Cursor/agy/PI sessions + host 集成 | 2026-10-15 | 路由、来源、规范均已实现;实际失败会话如实保留 | +| 正式测试与验收 | `d3c28d5a53d9e3205798b71c89ef1110e5996050` | 通过(REPLAY/SELF) | 全新 PI MiniMax 独立测试 session | 2026-10-15 | 110 项直接测试通过(1 skip);472 项治理测试通过(19 skips);validate/diff-check 通过;HEAD/工作树未变化 | +| 代码 Review | `bc5a119...d3c28d5` | 通过;阻塞全部关闭 | 多个全新独立 Codex CLI Review session | 2026-10-15 | 首轮 6 项、复审 4 项、终态唯一性 1 项、最终配置/路径 3 项均经独立 repair loop 关闭;最终 delta Review 无可执行回归 | +| 合入 | 待定 | 待执行 | — | — | — | +| 合入核验与收口 | 待定 | 待执行 | — | — | — | + +## 探索试验与迭代 + +| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 授权/决策依据 | 结果 | 变更影响 | 需重审补测 | +|---|---|---|---|---|---|---|---| +| 待定义 | — | — | — | 待核对 | NOT_RUN | 待评估 | 待评估 | + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | +|---|---|---|---| +| 启动 | 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee | 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee | 2026-10-15T14:48:10Z | +| Review 前 | bc5a1194984433c4098f1961f5280b3d9ee20995 | 529d8aa5c7fc5e19d6dab882ddd1903ec184afda | 2026-10-15T17:32:01Z | + +## 证据(Evidence) + + +| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) | +|---|---|---|---|---|---| + +## 交付调度 + +- 调度状态:未入队;调度记录:无 +- entry_id:无;attempt_id:无;窗口执行者:无 +- 固定候选:无(base/head、组件与相关配置引用实际尝试) +- 调度依据:policy/merge-queue.md;新模式启用前按既有交付门禁 +- 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合 + +## 结果与验证(完成后填写) + +- 结果:配置路由、每候选最多三次安全重试、一次 Codex 紧急档、稳定 Cursor 通道、终态事件解码、local-integrity apply/admit 与 pre-commit 门禁均已实现;本地 HMAC 明确仅为 SELF 完整性证据。 +- 验证:精确 HEAD `d3c28d5` 的 110 项直接测试与 472 项治理测试通过;最终 PI 回归无失败,独立 Codex delta Review 无可执行回归。 +- 影响与剩余项:只剩最终 task-sync、PR 精确 HEAD CI、合入及任务卡闭合。 + +## 规划进展与下一步 + +- 本次推进/剩余:待核对(依据实际成果更新适用方案/规划) +- 首选下一任务:待核对(无适用规划、已完成或受阻时如实说明,不虚构任务) +- 推荐依据:待填写 +- 前置条件/待确认:待核对 +- 预期产物/验收条件:待定义 +- 重复任务核对:待核对(已有/在途任务、PR 与规划记录) +- 启动授权:用户明确确认实施并要求加强强制机制;范围外新任务仍须授权 + +## 合入与清理 + +- Review 记录:首轮及后续复审均使用全新 Codex CLI session;最终候选 `d3c28d5a53d9e3205798b71c89ef1110e5996050` 相对 base `bc5a1194984433c4098f1961f5280b3d9ee20995` 的已知阻塞全部关闭;最终 commit delta Review 无可执行回归。 +- 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测) +- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果) +- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验) +- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置) + + +```json +{ + "schema": 1, + "source": "SELF", + "timestamp": "2026-10-15T14:47:09.872023+00:00", + "command": "make precheck", + "command_sha256": "8fd03cdee8adbb8562846a1a114cc0623779a8654412cd20aac9b495e7eb0450", + "cwd": "/tmp/codex-cli-fallback-admin.82BLPc/repo", + "common_dir": "/tmp/codex-cli-fallback-admin.82BLPc/repo/.git", + "before": { + "head": "04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee", + "branch": "main", + "target_branch": "main", + "target_head": "04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee", + "status": "", + "tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "untracked": {}, + "submodules": "-4f583ca3eeeb07a56a714c5086de84dcd6ad2980 deps/agent-command\n-343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-intent\n-c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-native\n-490b0b2dfa1567cb16ff7a7230fd78e52bc2938e deps/agent-squad\n-2f5b19872be2a4597838a4131ad75d1be089ee5e deps/development-guidelines\n-a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/error-code-system\n-49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/logging-system\n-ab12fea4745cf22fb7648c1c25b18433ac8a3650 modules/backend\n-1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/core\n-73bbb4cea686eb84826d5066bb4c44bf883c9365 modules/documents\n-781cde66675e6d9293004f19543500aa8e24bdb8 modules/frontend\n-c9187473fb1ae9e8f3946cdf9b67055ee732054a modules/ui\n" + }, + "after": { + "head": "04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee", + "branch": "main", + "target_branch": "main", + "target_head": "04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee", + "status": "", + "tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "untracked": {}, + "submodules": "-4f583ca3eeeb07a56a714c5086de84dcd6ad2980 deps/agent-command\n-343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-intent\n-c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-native\n-490b0b2dfa1567cb16ff7a7230fd78e52bc2938e deps/agent-squad\n-2f5b19872be2a4597838a4131ad75d1be089ee5e deps/development-guidelines\n-a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/error-code-system\n-49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/logging-system\n-ab12fea4745cf22fb7648c1c25b18433ac8a3650 modules/backend\n-1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/core\n-73bbb4cea686eb84826d5066bb4c44bf883c9365 modules/documents\n-781cde66675e6d9293004f19543500aa8e24bdb8 modules/frontend\n-c9187473fb1ae9e8f3946cdf9b67055ee732054a modules/ui\n" + }, + "after_error": null, + "exit_code": 0, + "output": "make[1]: Entering directory '/tmp/codex-cli-fallback-admin.82BLPc/repo'\n[repo] branch=main head=04acb027f6\n[remote] origin/main=04acb027f6 ahead=0 behind=0\n[tree] clean submodules_drift=0 uninit=12\n[warn] 12 个子模块未初始化——构建/测试前需物化(不阻断写入)\n[worktrees] linked=0\n[cards] 已合入未闭合任务卡:feature-rdm-duty-matrix.md fix-model-key-secret-overlay.md——make task-close 回填归档(主检出运行)\n[hooks] WARN core.hooksPath='未设置'——本地钩子未生效,先 make setup;本检出提交将绕过本地闸(远端准入仍在)\n[facts] policy/facts.md 6 条事实均在有效期内\n[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md\n[R0] 04acb027f6(完整:04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee)\n[worktree] before-sync: recursive component checks passed\n[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch\nmake[1]: Leaving directory '/tmp/codex-cli-fallback-admin.82BLPc/repo'\n", + "output_sha256": "e3db4c8d457c868b93804c5c9ce992408238c3ce2bc38268af0b764907d5fca1", + "receipt_sha256": "417a8e59a4d750ec1e81b61f92b2d71f123c5de628d0fb4e3d4d275453de32ed" +} +``` +| 同步(记录前对象) | bc5a1194984433c4098f1961f5280b3d9ee20995 | f0bcbe018dd07bd03371dae856f76bc1c2907fb1 | 2026-10-15T17:32:01Z | + + +```json +{ + "schema": 1, + "source": "SELF", + "timestamp": "2026-10-15T20:59:37.719080+00:00", + "command": "make test-governance", + "command_sha256": "9776a8dfcfb8f4c95abc94c40cae3a2c0e91e8f4b91fe95d98085c290e929158", + "cwd": "/tmp/codex-cli-fallback-admin.82BLPc/repo-wt-feature-agent-cli-enforcement", + "common_dir": "/tmp/codex-cli-fallback-admin.82BLPc/repo/.git", + "before": { + "head": "e288f00116fc2e986dfa411bfeea52c5fe8c594e", + "branch": "feature/agent-cli-enforcement", + "target_branch": "main", + "target_head": "bc5a1194984433c4098f1961f5280b3d9ee20995", + "status": "", + "tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "untracked": {}, + "submodules": " 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 2f5b19872be2a4597838a4131ad75d1be089ee5e deps/development-guidelines (heads/main)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/logging-system/deps/development-guidelines (26b562d)\n ab12fea4745cf22fb7648c1c25b18433ac8a3650 modules/backend (v0.5.0-23-gab12fea)\n 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 modules/backend/deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 modules/backend/deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e modules/backend/deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/backend/deps/agent-system-core (v0.4.0-3-g1a5ef6b)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/core (v0.4.0-3-g1a5ef6b)\n 73bbb4cea686eb84826d5066bb4c44bf883c9365 modules/documents (remotes/origin/feature/layered-python-env)\n 781cde66675e6d9293004f19543500aa8e24bdb8 modules/frontend (v0.4.0-34-g781cde6)\n c9187473fb1ae9e8f3946cdf9b67055ee732054a modules/ui (v0.4.0-9-gc918747)\n" + }, + "after": { + "head": "e288f00116fc2e986dfa411bfeea52c5fe8c594e", + "branch": "feature/agent-cli-enforcement", + "target_branch": "main", + "target_head": "bc5a1194984433c4098f1961f5280b3d9ee20995", + "status": "", + "tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "untracked": {}, + "submodules": " 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 2f5b19872be2a4597838a4131ad75d1be089ee5e deps/development-guidelines (heads/main)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/logging-system/deps/development-guidelines (26b562d)\n ab12fea4745cf22fb7648c1c25b18433ac8a3650 modules/backend (v0.5.0-23-gab12fea)\n 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 modules/backend/deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 modules/backend/deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e modules/backend/deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/backend/deps/agent-system-core (v0.4.0-3-g1a5ef6b)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/core (v0.4.0-3-g1a5ef6b)\n 73bbb4cea686eb84826d5066bb4c44bf883c9365 modules/documents (remotes/origin/feature/layered-python-env)\n 781cde66675e6d9293004f19543500aa8e24bdb8 modules/frontend (v0.4.0-34-g781cde6)\n c9187473fb1ae9e8f3946cdf9b67055ee732054a modules/ui (v0.4.0-9-gc918747)\n" + }, + "after_error": null, + "exit_code": 0, + "output": "make[1]: Entering directory '/tmp/codex-cli-fallback-admin.82BLPc/repo-wt-feature-agent-cli-enforcement'\nPYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v\ntest_all_unavailable_exhaustion_does_not_authorize_host (test_agent_exec.AgentExecControllerTests.test_all_unavailable_exhaustion_does_not_authorize_host) ... ok\ntest_builtin_event_envelopes_gate_success (test_agent_exec.AgentExecControllerTests.test_builtin_event_envelopes_gate_success)\nReal Cursor/agy/PI output envelopes decide the terminal; prose fails closed. ... ok\ntest_canonical_checkout_mutation_is_failed_unknown (test_agent_exec.AgentExecControllerTests.test_canonical_checkout_mutation_is_failed_unknown)\nWorker mutating canonical files/index while keeping private clone clean is blocked. ... ok\ntest_clean_exit_without_diff_and_network_is_failed_unknown (test_agent_exec.AgentExecControllerTests.test_clean_exit_without_diff_and_network_is_failed_unknown)\nAuthorized network with a clean no-diff exit is not failed-clean. ... ok\ntest_corrupt_worktree_git_pointer_yields_failed_unknown_receipt (test_agent_exec.AgentExecControllerTests.test_corrupt_worktree_git_pointer_yields_failed_unknown_receipt)\nCorrupted private-clone .git directory must produce a stable blocked receipt. ... ok\ntest_declared_offline_launched_failure_is_failed_unknown_and_stops (test_agent_exec.AgentExecControllerTests.test_declared_offline_launched_failure_is_failed_unknown_and_stops)\nnetwork=false/external_cost=false are declarations, not isolation proof. ... ok\ntest_emergency_profile_runs_once_after_regular_safe_exhaustion (test_agent_exec.AgentExecControllerTests.test_emergency_profile_runs_once_after_regular_safe_exhaustion) ... ok\ntest_failed_unknown_blocks_fallback (test_agent_exec.AgentExecControllerTests.test_failed_unknown_blocks_fallback) ... ok\ntest_git_admin_staged_config_ref_mutation_is_failed_unknown (test_agent_exec.AgentExecControllerTests.test_git_admin_staged_config_ref_mutation_is_failed_unknown)\nStaged index plus local config/ref mutation preserves workspace and stops. ... ok\ntest_idle_timeout_kills_silent_worker_before_total (test_agent_exec.AgentExecControllerTests.test_idle_timeout_kills_silent_worker_before_total) ... ok\ntest_large_stdin_with_early_stdout_does_not_deadlock (test_agent_exec.AgentExecControllerTests.test_large_stdin_with_early_stdout_does_not_deadlock)\nStdin must be select-driven so early stdout beyond pipe capacity cannot hang. ... ok\ntest_launched_failure_without_diff_is_failed_unknown_and_stops (test_agent_exec.AgentExecControllerTests.test_launched_failure_without_diff_is_failed_unknown_and_stops)\nNo diff and declared-offline packets still do not prove external cleanliness. ... ok\ntest_legacy_exhaustion_without_emergency_does_not_authorize_host (test_agent_exec.AgentExecControllerTests.test_legacy_exhaustion_without_emergency_does_not_authorize_host) ... ok\ntest_missing_profiles_fall_through_in_declared_order (test_agent_exec.AgentExecControllerTests.test_missing_profiles_fall_through_in_declared_order) ... ok\ntest_networked_nonzero_no_file_change_is_failed_unknown (test_agent_exec.AgentExecControllerTests.test_networked_nonzero_no_file_change_is_failed_unknown)\nnetwork=true nonzero exit with no file changes cannot be failed-clean. ... ok\ntest_progress_keeps_worker_alive_past_one_idle_interval (test_agent_exec.AgentExecControllerTests.test_progress_keeps_worker_alive_past_one_idle_interval) ... ok\ntest_receipt_redacts_environment_secrets (test_agent_exec.AgentExecControllerTests.test_receipt_redacts_environment_secrets) ... ok\ntest_rename_into_allowed_destination_cannot_hide_source (test_agent_exec.AgentExecControllerTests.test_rename_into_allowed_destination_cannot_hide_source)\nRename parsing must keep an out-of-scope source and its destination. ... ok\ntest_run_uses_fresh_process_and_worktree_per_attempt (test_agent_exec.AgentExecControllerTests.test_run_uses_fresh_process_and_worktree_per_attempt) ... ok\ntest_safe_unavailable_retries_profile_before_next_success (test_agent_exec.AgentExecControllerTests.test_safe_unavailable_retries_profile_before_next_success) ... ok\ntest_scope_expansion_preserves_workspace_and_blocks (test_agent_exec.AgentExecControllerTests.test_scope_expansion_preserves_workspace_and_blocks) ... ok\ntest_secret_crossing_raw_log_flush_boundary_is_redacted (test_agent_exec.AgentExecControllerTests.test_secret_crossing_raw_log_flush_boundary_is_redacted)\nExact env secrets must not leak when split across raw-log drain/flush edges. ... ok\ntest_sibling_canonical_ref_config_mutation_during_private_clone_succeeds (test_agent_exec.AgentExecControllerTests.test_sibling_canonical_ref_config_mutation_during_private_clone_succeeds)\nHost sibling branch/ref/config churn must not fail-stop a private clone. ... ok\ntest_stdin_and_stderr_progress_resets_idle_deadline (test_agent_exec.AgentExecControllerTests.test_stdin_and_stderr_progress_resets_idle_deadline) ... ok\ntest_success_creates_base_parented_transport_without_touching_canonical_head (test_agent_exec.AgentExecControllerTests.test_success_creates_base_parented_transport_without_touching_canonical_head) ... ok\ntest_summary_and_raw_log_caps_are_enforced (test_agent_exec.AgentExecControllerTests.test_summary_and_raw_log_caps_are_enforced) ... ok\ntest_symlink_allowed_path_escape_rejects_without_external_write (test_agent_exec.AgentExecControllerTests.test_symlink_allowed_path_escape_rejects_without_external_write)\nAllowed path through symlink escaping the worktree cannot touch outside. ... ok\ntest_timeout_kills_process_group_and_records_receipt (test_agent_exec.AgentExecControllerTests.test_timeout_kills_process_group_and_records_receipt) ... ok\ntest_transport_commit_does_not_require_configured_git_identity (test_agent_exec.AgentExecControllerTests.test_transport_commit_does_not_require_configured_git_identity) ... ok\ntest_transport_construction_failure_is_failed_unknown (test_agent_exec.AgentExecControllerTests.test_transport_construction_failure_is_failed_unknown)\nHost transport staging/commit-tree/update-ref failure stays recoverable. ... ok\ntest_untracked_and_binary_files_enter_transport_inventory (test_agent_exec.AgentExecControllerTests.test_untracked_and_binary_files_enter_transport_inventory) ... ok\ntest_validate_does_not_contact_provider (test_agent_exec.AgentExecControllerTests.test_validate_does_not_contact_provider) ... ok\ntest_worker_commit_or_head_move_is_rejected (test_agent_exec.AgentExecControllerTests.test_worker_commit_or_head_move_is_rejected)\nWorker HEAD/commit, staged index, and config/ref mutations are fail-stop. ... ok\ntest_worker_fail_stop_terminal_beats_success_and_no_change (test_agent_exec.AgentExecControllerTests.test_worker_fail_stop_terminal_beats_success_and_no_change)\nblocked, cancelled, and failed-unknown stop before success or no-change. ... ok\ntest_attestation_key_first_run_race_shares_one_key (test_agent_exec.AgentExecHistoryTests.test_attestation_key_first_run_race_shares_one_key) ... ok\ntest_attestation_key_symlink_is_not_followed (test_agent_exec.AgentExecHistoryTests.test_attestation_key_symlink_is_not_followed) ... ok\ntest_history_appends_safe_success_and_fallback_records (test_agent_exec.AgentExecHistoryTests.test_history_appends_safe_success_and_fallback_records) ... ok\ntest_history_failure_does_not_change_run_outcome (test_agent_exec.AgentExecHistoryTests.test_history_failure_does_not_change_run_outcome) ... ok\ntest_invalid_config_also_appends_one_history_record (test_agent_exec.AgentExecHistoryTests.test_invalid_config_also_appends_one_history_record) ... ok\ntest_success_history_is_mode_correct_and_signed (test_agent_exec.AgentExecHistoryTests.test_success_history_is_mode_correct_and_signed) ... ok\ntest_incompatible_opencode_is_unavailable_not_pass (test_agent_exec.RealAgentCliProbeTests.test_incompatible_opencode_is_unavailable_not_pass) ... ok\ntest_launched_failure_without_marker_is_unavailable_not_pass (test_agent_exec.RealAgentCliProbeTests.test_launched_failure_without_marker_is_unavailable_not_pass) ... ok\ntest_missing_opencode_is_unavailable_not_pass (test_agent_exec.RealAgentCliProbeTests.test_missing_opencode_is_unavailable_not_pass) ... ok\ntest_model_receipt_mismatch_is_not_pass (test_agent_exec.RealAgentCliProbeTests.test_model_receipt_mismatch_is_not_pass) ... ok\ntest_observable_verified_model_receipt_stays_attached_to_success (test_agent_exec.RealAgentCliProbeTests.test_observable_verified_model_receipt_stays_attached_to_success) ... ok\ntest_offline_success_checks_marker_transport_and_canonical (test_agent_exec.RealAgentCliProbeTests.test_offline_success_checks_marker_transport_and_canonical) ... ok\ntest_opt_in_real_profile_probe (test_agent_exec.RealAgentCliProbeTests.test_opt_in_real_profile_probe) ... skipped 'real CLI probe NOT_RUN: set RUN_REAL_AGENT_EXEC=1 and AGENT_EXEC_REAL_PROFILE to exactly one profile'\ntest_profile_selector_accepts_exactly_one_profile (test_agent_exec.RealAgentCliProbeTests.test_profile_selector_accepts_exactly_one_profile) ... ok\ntest_real_probe_refuses_without_opt_in (test_agent_exec.RealAgentCliProbeTests.test_real_probe_refuses_without_opt_in) ... ok\ntest_wrong_marker_is_not_pass (test_agent_exec.RealAgentCliProbeTests.test_wrong_marker_is_not_pass) ... ok\ntest_agy_stream_json_and_json_results_are_decoded (test_agent_exec.TerminalRecordDecodingTests.test_agy_stream_json_and_json_results_are_decoded) ... ok\ntest_cursor_blocked_record_is_decoded_as_fail_stop (test_agent_exec.TerminalRecordDecodingTests.test_cursor_blocked_record_is_decoded_as_fail_stop) ... ok\ntest_cursor_stream_json_result_is_decoded (test_agent_exec.TerminalRecordDecodingTests.test_cursor_stream_json_result_is_decoded) ... ok\ntest_cursor_user_echo_and_error_result_are_not_records (test_agent_exec.TerminalRecordDecodingTests.test_cursor_user_echo_and_error_result_are_not_records) ... ok\ntest_pi_json_final_assistant_message_is_decoded (test_agent_exec.TerminalRecordDecodingTests.test_pi_json_final_assistant_message_is_decoded) ... ok\ntest_prompt_states_schema_without_being_a_record (test_agent_exec.TerminalRecordDecodingTests.test_prompt_states_schema_without_being_a_record) ... ok\ntest_prose_missing_or_invalid_terminal_fails_closed (test_agent_exec.TerminalRecordDecodingTests.test_prose_missing_or_invalid_terminal_fails_closed) ... ok\ntest_raw_stdout_record_is_not_an_envelope_for_builtins (test_agent_exec.TerminalRecordDecodingTests.test_raw_stdout_record_is_not_an_envelope_for_builtins) ... ok\ntest_stdout_tail_keeps_final_envelope_behind_oversized_event (test_agent_exec.TerminalRecordDecodingTests.test_stdout_tail_keeps_final_envelope_behind_oversized_event) ... ok\ntest_terminal_record_must_be_sole_final_content (test_agent_exec.TerminalRecordDecodingTests.test_terminal_record_must_be_sole_final_content) ... ok\ntest_antigravity_run_everything_argv (test_agent_exec_adapters.AgentExecAdapterTests.test_antigravity_run_everything_argv) ... ok\ntest_claude_run_everything_argv (test_agent_exec_adapters.AgentExecAdapterTests.test_claude_run_everything_argv) ... ok\ntest_codex_uses_noninteractive_workspace_sandbox (test_agent_exec_adapters.AgentExecAdapterTests.test_codex_uses_noninteractive_workspace_sandbox) ... ok\ntest_cursor_run_everything_and_auto_model_argv (test_agent_exec_adapters.AgentExecAdapterTests.test_cursor_run_everything_and_auto_model_argv) ... ok\ntest_custom_adapter_builds_direct_argv_without_shell (test_agent_exec_adapters.AgentExecAdapterTests.test_custom_adapter_builds_direct_argv_without_shell) ... ok\ntest_fixed_model_uses_only_adapter_model_flag (test_agent_exec_adapters.AgentExecAdapterTests.test_fixed_model_uses_only_adapter_model_flag) ... ok\ntest_missing_opencode_is_unavailable (test_agent_exec_adapters.AgentExecAdapterTests.test_missing_opencode_is_unavailable) ... ok\ntest_pi_and_kimi_auto_model_omit_model_flag (test_agent_exec_adapters.AgentExecAdapterTests.test_pi_and_kimi_auto_model_omit_model_flag) ... ok\ntest_rejects_oversized_and_invalid_utf8_unit_packet (test_agent_exec_adapters.AgentExecAdapterTests.test_rejects_oversized_and_invalid_utf8_unit_packet) ... ok\ntest_unverified_run_everything_capability_is_unavailable (test_agent_exec_adapters.AgentExecAdapterTests.test_unverified_run_everything_capability_is_unavailable) ... ok\ntest_admit_accepts_exact_transport_paths (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_accepts_exact_transport_paths) ... ok\ntest_admit_accepts_host_managed_exemptions (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_accepts_host_managed_exemptions) ... ok\ntest_admit_covers_whitespace_path_only_with_exact_transport (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_covers_whitespace_path_only_with_exact_transport) ... ok\ntest_admit_rejects_different_content_at_transport_path (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_rejects_different_content_at_transport_path) ... ok\ntest_admit_rejects_rename_of_non_exempt_source_into_exempt_path (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_rejects_rename_of_non_exempt_source_into_exempt_path) ... ok\ntest_admit_rejects_staged_paths_without_provenance (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_rejects_staged_paths_without_provenance) ... ok\ntest_admit_rejects_tampered_record_and_moved_ref (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_rejects_tampered_record_and_moved_ref) ... ok\ntest_admit_rejects_whitespace_lookalikes_of_exempt_paths (test_agent_exec_admission.AgentExecAdmissionTests.test_admit_rejects_whitespace_lookalikes_of_exempt_paths) ... ok\ntest_apply_fast_forwards_clean_matching_transport (test_agent_exec_admission.AgentExecAdmissionTests.test_apply_fast_forwards_clean_matching_transport) ... ok\ntest_apply_redacts_secret_shaped_receipt_text (test_agent_exec_admission.AgentExecAdmissionTests.test_apply_redacts_secret_shaped_receipt_text) ... ok\ntest_apply_rejects_fabricated_unsigned_or_tampered_history (test_agent_exec_admission.AgentExecAdmissionTests.test_apply_rejects_fabricated_unsigned_or_tampered_history) ... ok\ntest_apply_rejects_without_changing_state (test_agent_exec_admission.AgentExecAdmissionTests.test_apply_rejects_without_changing_state) ... ok\ntest_make_agent_exec_apply_requires_receipt (test_agent_exec_admission.AgentExecAdmissionTests.test_make_agent_exec_apply_requires_receipt) ... ok\ntest_precommit_accepts_host_managed_exemptions (test_agent_exec_admission.AgentExecAdmissionTests.test_precommit_accepts_host_managed_exemptions) ... ok\ntest_precommit_rejects_unproven_paths_after_branch_checks (test_agent_exec_admission.AgentExecAdmissionTests.test_precommit_rejects_unproven_paths_after_branch_checks) ... ok\ntest_rename_and_copy_records_keep_non_exempt_source (test_agent_exec_admission.AgentExecAdmissionTests.test_rename_and_copy_records_keep_non_exempt_source)\nCopy/rename records must not drop a non-exempt source behind an exempt dest. ... ok\ntest_whitespace_paths_are_classified_exactly (test_agent_exec_admission.AgentExecAdmissionTests.test_whitespace_paths_are_classified_exactly) ... ok\ntest_accepts_fallback_to_host_never (test_agent_exec_config.AgentExecConfigTests.test_accepts_fallback_to_host_never) ... ok\ntest_checked_in_project_config_never_authorizes_host_fallback (test_agent_exec_config.AgentExecConfigTests.test_checked_in_project_config_never_authorizes_host_fallback) ... ok\ntest_configured_emergency_profile_must_exist_be_enabled_and_use_adapter_codex (test_agent_exec_config.AgentExecConfigTests.test_configured_emergency_profile_must_exist_be_enabled_and_use_adapter_codex) ... ok\ntest_custom_placeholders_must_fill_whole_argv_items (test_agent_exec_config.AgentExecConfigTests.test_custom_placeholders_must_fill_whole_argv_items) ... ok\ntest_emergency_exactness_does_not_apply_to_review (test_agent_exec_config.AgentExecConfigTests.test_emergency_exactness_does_not_apply_to_review) ... ok\ntest_emergency_normal_roles_allow_extra_disabled_and_keep_order (test_agent_exec_config.AgentExecConfigTests.test_emergency_normal_roles_allow_extra_disabled_and_keep_order) ... ok\ntest_emergency_normal_roles_reject_disabled_only_adapter (test_agent_exec_config.AgentExecConfigTests.test_emergency_normal_roles_reject_disabled_only_adapter) ... ok\ntest_emergency_normal_roles_reject_missing_required_adapter (test_agent_exec_config.AgentExecConfigTests.test_emergency_normal_roles_reject_missing_required_adapter) ... ok\ntest_emergency_normal_roles_reject_multiple_enabled_same_adapter (test_agent_exec_config.AgentExecConfigTests.test_emergency_normal_roles_reject_multiple_enabled_same_adapter) ... ok\ntest_emergency_profile_is_excluded_from_normal_roles (test_agent_exec_config.AgentExecConfigTests.test_emergency_profile_is_excluded_from_normal_roles) ... ok\ntest_emergency_profile_requires_max_attempts_exactly_three (test_agent_exec_config.AgentExecConfigTests.test_emergency_profile_requires_max_attempts_exactly_three) ... ok\ntest_exposes_max_attempts_per_profile_and_emergency_profile (test_agent_exec_config.AgentExecConfigTests.test_exposes_max_attempts_per_profile_and_emergency_profile) ... ok\ntest_fresh_process_per_unit_must_be_true (test_agent_exec_config.AgentExecConfigTests.test_fresh_process_per_unit_must_be_true) ... ok\ntest_idle_timeout_defaults_within_total_and_rejects_bounds (test_agent_exec_config.AgentExecConfigTests.test_idle_timeout_defaults_within_total_and_rejects_bounds) ... ok\ntest_legacy_all_unavailable_remains_parseable (test_agent_exec_config.AgentExecConfigTests.test_legacy_all_unavailable_remains_parseable) ... ok\ntest_loads_single_agent_execution_block (test_agent_exec_config.AgentExecConfigTests.test_loads_single_agent_execution_block) ... ok\ntest_max_attempts_per_profile_never_exceeds_three (test_agent_exec_config.AgentExecConfigTests.test_max_attempts_per_profile_never_exceeds_three) ... ok\ntest_model_auto_is_preserved_as_none (test_agent_exec_config.AgentExecConfigTests.test_model_auto_is_preserved_as_none) ... ok\ntest_normal_roles_reject_forbidden_adapters_and_alternate_codex_profile (test_agent_exec_config.AgentExecConfigTests.test_normal_roles_reject_forbidden_adapters_and_alternate_codex_profile) ... ok\ntest_numeric_limits_are_positive_and_bounded (test_agent_exec_config.AgentExecConfigTests.test_numeric_limits_are_positive_and_bounded) ... ok\ntest_quoted_unicode_string_round_trips (test_agent_exec_config.AgentExecConfigTests.test_quoted_unicode_string_round_trips) ... ok\ntest_rejects_indentation_jump (test_agent_exec_config.AgentExecConfigTests.test_rejects_indentation_jump) ... ok\ntest_rejects_missing_or_duplicate_blocks (test_agent_exec_config.AgentExecConfigTests.test_rejects_missing_or_duplicate_blocks) ... ok\ntest_rejects_shell_command_and_secret_shaped_values (test_agent_exec_config.AgentExecConfigTests.test_rejects_shell_command_and_secret_shaped_values) ... ok\ntest_rejects_unknown_profile_reference (test_agent_exec_config.AgentExecConfigTests.test_rejects_unknown_profile_reference) ... ok\ntest_rejects_unknown_schema_and_unknown_keys (test_agent_exec_config.AgentExecConfigTests.test_rejects_unknown_schema_and_unknown_keys) ... ok\ntest_review_may_include_emergency_profile (test_agent_exec_config.AgentExecConfigTests.test_review_may_include_emergency_profile) ... ok\ntest_role_order_does_not_follow_profile_order (test_agent_exec_config.AgentExecConfigTests.test_role_order_does_not_follow_profile_order) ... ok\ntest_strict_emergency_mode_rejects_custom_normal_role_adapters (test_agent_exec_config.AgentExecConfigTests.test_strict_emergency_mode_rejects_custom_normal_role_adapters) ... ok\ntest_without_emergency_custom_normal_roles_are_rejected (test_agent_exec_config.AgentExecConfigTests.test_without_emergency_custom_normal_roles_are_rejected) ... ok\ntest_without_emergency_disabled_extras_are_ignored_and_order_kept (test_agent_exec_config.AgentExecConfigTests.test_without_emergency_disabled_extras_are_ignored_and_order_kept) ... ok\ntest_without_emergency_normal_roles_need_exact_enabled_adapter_set (test_agent_exec_config.AgentExecConfigTests.test_without_emergency_normal_roles_need_exact_enabled_adapter_set) ... ok\ntest_without_emergency_review_may_use_custom_adapter (test_agent_exec_config.AgentExecConfigTests.test_without_emergency_review_may_use_custom_adapter) ... ok\ntest_candidate_cannot_read_checkout_credentials (test_ci_credentials.CheckoutTests.test_candidate_cannot_read_checkout_credentials) ... ok\ntest_candidate_submodule_update_commands_are_rejected (test_ci_credentials.CheckoutTests.test_candidate_submodule_update_commands_are_rejected) ... ok\ntest_credential_destination_confined_to_injected_server (test_ci_credentials.CheckoutTests.test_credential_destination_confined_to_injected_server) ... ok\ntest_external_or_unlisted_urls_are_rejected_before_component_fetch (test_ci_credentials.CheckoutTests.test_external_or_unlisted_urls_are_rejected_before_component_fetch) ... ok\ntest_inherited_template_hook_does_not_execute_with_credentials (test_ci_credentials.CheckoutTests.test_inherited_template_hook_does_not_execute_with_credentials) ... ok\ntest_nested_external_url_is_rejected_before_authenticated_fetch (test_ci_credentials.CheckoutTests.test_nested_external_url_is_rejected_before_authenticated_fetch) ... ok\ntest_rdm_pin_fetch_is_three_shas_on_the_allowlist (test_ci_credentials.CheckoutTests.test_rdm_pin_fetch_is_three_shas_on_the_allowlist) ... ok\ntest_recursive_gitlinks_materialize_exact_sha_without_persisting_credentials (test_ci_credentials.CheckoutTests.test_recursive_gitlinks_materialize_exact_sha_without_persisting_credentials) ... ok\ntest_redirects_do_not_forward_credential_or_fetch_unlisted_target (test_ci_credentials.CheckoutTests.test_redirects_do_not_forward_credential_or_fetch_unlisted_target) ... ok\ntest_after_snapshot_failure_keeps_command_exit_and_output (test_evidence.EvidenceTests.test_after_snapshot_failure_keeps_command_exit_and_output) ... ok\ntest_all_persisted_and_displayed_text_is_sanitized (test_evidence.EvidenceTests.test_all_persisted_and_displayed_text_is_sanitized) ... ok\ntest_authorization_forms_are_redacted_in_capture_console_and_import (test_evidence.EvidenceTests.test_authorization_forms_are_redacted_in_capture_console_and_import) ... ok\ntest_authorization_forms_are_redacted_on_direct_card_capture (test_evidence.EvidenceTests.test_authorization_forms_are_redacted_on_direct_card_capture) ... ok\ntest_before_after_heads_do_not_relabel_changed_command (test_evidence.EvidenceTests.test_before_after_heads_do_not_relabel_changed_command) ... ok\ntest_capture_does_not_overwrite_an_existing_receipt (test_evidence.EvidenceTests.test_capture_does_not_overwrite_an_existing_receipt) ... ok\ntest_capture_without_card_and_import_without_rerun (test_evidence.EvidenceTests.test_capture_without_card_and_import_without_rerun) ... ok\ntest_child_failure_code_is_preserved (test_evidence.EvidenceTests.test_child_failure_code_is_preserved) ... ok\ntest_command_line_token_arguments_are_redacted (test_evidence.EvidenceTests.test_command_line_token_arguments_are_redacted) ... ok\ntest_dirty_and_untracked_contents_have_object_digests (test_evidence.EvidenceTests.test_dirty_and_untracked_contents_have_object_digests) ... ok\ntest_failed_child_exit_survives_receipt_write_failure (test_evidence.EvidenceTests.test_failed_child_exit_survives_receipt_write_failure) ... ok\ntest_fixed_candidate_capture_preserves_checkout (test_evidence.EvidenceTests.test_fixed_candidate_capture_preserves_checkout) ... ok\ntest_full_output_is_preserved_not_only_tail (test_evidence.EvidenceTests.test_full_output_is_preserved_not_only_tail) ... ok\ntest_import_missing_card_fails_without_losing_receipt (test_evidence.EvidenceTests.test_import_missing_card_fails_without_losing_receipt) ... ok\ntest_import_rejects_unredacted_authorization_with_valid_checksums (test_evidence.EvidenceTests.test_import_rejects_unredacted_authorization_with_valid_checksums) ... ok\ntest_import_retry_is_idempotent (test_evidence.EvidenceTests.test_import_retry_is_idempotent) ... ok\ntest_invalid_field_types_and_forged_source_are_rejected (test_evidence.EvidenceTests.test_invalid_field_types_and_forged_source_are_rejected) ... ok\ntest_known_secret_environment_value_is_redacted_without_label (test_evidence.EvidenceTests.test_known_secret_environment_value_is_redacted_without_label) ... ok\ntest_linked_worktree_import_preserves_source_identity (test_evidence.EvidenceTests.test_linked_worktree_import_preserves_source_identity) ... ok\ntest_missing_card_does_not_execute (test_evidence.EvidenceTests.test_missing_card_does_not_execute) ... ok\ntest_missing_required_snapshot_fields_are_rejected (test_evidence.EvidenceTests.test_missing_required_snapshot_fields_are_rejected) ... ok\ntest_nested_cwd_records_untracked_files_from_repository_root (test_evidence.EvidenceTests.test_nested_cwd_records_untracked_files_from_repository_root) ... ok\ntest_output_digest_checked_even_if_receipt_digest_recomputed (test_evidence.EvidenceTests.test_output_digest_checked_even_if_receipt_digest_recomputed) ... ok\ntest_pipeline_failure_is_recorded_and_returned (test_evidence.EvidenceTests.test_pipeline_failure_is_recorded_and_returned) ... ok\ntest_quoted_credentials_with_spaces_are_fully_redacted (test_evidence.EvidenceTests.test_quoted_credentials_with_spaces_are_fully_redacted) ... ok\ntest_receipt_write_failure_cannot_claim_success (test_evidence.EvidenceTests.test_receipt_write_failure_cannot_claim_success) ... ok\ntest_recorder_environment_does_not_leak_into_nested_commands (test_evidence.EvidenceTests.test_recorder_environment_does_not_leak_into_nested_commands) ... ok\ntest_source_cannot_be_promoted_by_environment (test_evidence.EvidenceTests.test_source_cannot_be_promoted_by_environment) ... ok\ntest_tamper_is_rejected_before_card_append (test_evidence.EvidenceTests.test_tamper_is_rejected_before_card_append) ... ok\ntest_unrelated_repo_cannot_import (test_evidence.EvidenceTests.test_unrelated_repo_cannot_import) ... ok\ntest_validate_precheck_checks_result_and_current_base (test_evidence.EvidenceTests.test_validate_precheck_checks_result_and_current_base) ... ok\ntest_validate_precheck_rejects_successful_arbitrary_command (test_evidence.EvidenceTests.test_validate_precheck_rejects_successful_arbitrary_command) ... ok\ntest_validation_purpose_is_not_injected_into_measured_command (test_evidence.EvidenceTests.test_validation_purpose_is_not_injected_into_measured_command) ... ok\ntest_pin_is_three_fixed_shas (test_lifecycle_pin.RdmPinTests.test_pin_is_three_fixed_shas) ... ok\ntest_rejects_url_and_short_sha (test_lifecycle_pin.RdmPinTests.test_rejects_url_and_short_sha) ... ok\ntest_conflicted_task_does_not_block_an_independent_healthy_task (test_merge_queue_trial.QueueTrialTests.test_conflicted_task_does_not_block_an_independent_healthy_task) ... ok\ntest_external_target_advance_requires_a_new_candidate (test_merge_queue_trial.QueueTrialTests.test_external_target_advance_requires_a_new_candidate) ... ok\ntest_sequential_delivery_does_not_sync_waiting_branches (test_merge_queue_trial.QueueTrialTests.test_sequential_delivery_does_not_sync_waiting_branches) ... ok\ntest_addr_change_keeps_database_and_auth (test_svc.SvcDbRecoveryTests.test_addr_change_keeps_database_and_auth) ... ok\ntest_auth_preflight_aligns_with_effective_env_dsn (test_svc.SvcDbRecoveryTests.test_auth_preflight_aligns_with_effective_env_dsn) ... ok\ntest_batch_update_stops_second_service_despite_prior_pidcheck (test_svc.SvcDbRecoveryTests.test_batch_update_stops_second_service_despite_prior_pidcheck)\n批量 update 不得用上一服务的 SVC_PIDCHECK 误判下一服务进程。 ... ok\ntest_dbref_binding_survives_bare_restart_and_redeploy (test_svc.SvcDbRecoveryTests.test_dbref_binding_survives_bare_restart_and_redeploy) ... ok\ntest_explicit_unbind_blocks_residual_env_dsn (test_svc.SvcDbRecoveryTests.test_explicit_unbind_blocks_residual_env_dsn) ... ok\ntest_external_dsn_and_conffile_recover_on_bare_start (test_svc.SvcDbRecoveryTests.test_external_dsn_and_conffile_recover_on_bare_start) ... ok\ntest_legacy_exec_dsn_survives_consecutive_restarts (test_svc.SvcDbRecoveryTests.test_legacy_exec_dsn_survives_consecutive_restarts) ... ok\ntest_mock_conflict_and_legacy_binary_refuse (test_svc.SvcDbRecoveryTests.test_mock_conflict_and_legacy_binary_refuse) ... ok\ntest_rdm_companion_missing_web_fails_deploy (test_svc.SvcDbRecoveryTests.test_rdm_companion_missing_web_fails_deploy) ... ok\ntest_rdm_source_commits_and_update_track (test_svc.SvcDbRecoveryTests.test_rdm_source_commits_and_update_track) ... [main 77f5c50] rdm v2\n 1 file changed, 1 insertion(+)\n create mode 100644 marker\nTo /tmp/svc-db-regress-241lesjf/rdmremote.git\n 13293de..77f5c50 main -> main\nok\ntest_rdm_source_deploy_end_to_end (test_svc.SvcDbRecoveryTests.test_rdm_source_deploy_end_to_end) ... ok\ntest_refusals_keep_registration_without_sqlite_fallback (test_svc.SvcDbRecoveryTests.test_refusals_keep_registration_without_sqlite_fallback) ... ok\ntest_restart_refusal_happens_before_stopping_running_service (test_svc.SvcDbRecoveryTests.test_restart_refusal_happens_before_stopping_running_service) ... ok\ntest_seed_owner_override_skips_account_lookup (test_svc.SvcDbRecoveryTests.test_seed_owner_override_skips_account_lookup) ... ok\ntest_seed_puts_with_temporary_bearer_key_when_auth_on (test_svc.SvcDbRecoveryTests.test_seed_puts_with_temporary_bearer_key_when_auth_on) ... ok\ntest_seed_puts_without_credentials_when_auth_off (test_svc.SvcDbRecoveryTests.test_seed_puts_without_credentials_when_auth_off) ... ok\ntest_seed_replaces_old_system_secret (test_svc.SvcDbRecoveryTests.test_seed_replaces_old_system_secret) ... ok\ntest_seed_warns_when_old_secret_stays_active (test_svc.SvcDbRecoveryTests.test_seed_warns_when_old_secret_stays_active) ... ok\ntest_seed_writes_fallbacks_and_vision (test_svc.SvcDbRecoveryTests.test_seed_writes_fallbacks_and_vision) ... ok\ntest_sequential_restarts_no_cross_dsn_and_no_secret_leak (test_svc.SvcDbRecoveryTests.test_sequential_restarts_no_cross_dsn_and_no_secret_leak) ... ok\ntest_source_switch_requires_flag (test_svc.SvcDbRecoveryTests.test_source_switch_requires_flag) ... ok\ntest_static_fresh_deploy_registers_pidcheck_and_starts (test_svc.SvcDbRecoveryTests.test_static_fresh_deploy_registers_pidcheck_and_starts)\n全新 static --dir:登记须含 pidcheck;首启探活不得把 http.server 判死。 ... ok\ntest_update_preserves_binding (test_svc.SvcDbRecoveryTests.test_update_preserves_binding) ... ok\ntest_backup_custom_out_not_pruned (test_svc_backup.SvcAgentBackupTests.test_backup_custom_out_not_pruned) ... ok\ntest_backup_event_recorded (test_svc_backup.SvcAgentBackupTests.test_backup_event_recorded) ... ok\ntest_backup_live_writer_consistent_and_outside_runtime (test_svc_backup.SvcAgentBackupTests.test_backup_live_writer_consistent_and_outside_runtime)\n活跃 WAL 写入者:快照可读、行在、integrity ok;-wal/-shm 不入快照。 ... ok\n/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.8/lib/python3.14/tracemalloc.py:415: ResourceWarning: unclosed database in \n class Snapshot:\nResourceWarning: Enable tracemalloc to get the object allocation traceback\n/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.8/lib/python3.14/unittest/suite.py:107: ResourceWarning: unclosed file <_io.TextIOWrapper name=4 encoding='UTF-8'>\n for index, test in enumerate(self):\nResourceWarning: Enable tracemalloc to get the object allocation traceback\ntest_backup_manifest_sha_matches (test_svc_backup.SvcAgentBackupTests.test_backup_manifest_sha_matches) ... ok\ntest_backup_non_sqlite_db_kept_raw_without_leftovers (test_svc_backup.SvcAgentBackupTests.test_backup_non_sqlite_db_kept_raw_without_leftovers) ... ok\ntest_backup_rejects_postgres_with_db_hint (test_svc_backup.SvcAgentBackupTests.test_backup_rejects_postgres_with_db_hint) ... ok\ntest_backup_requires_registration (test_svc_backup.SvcAgentBackupTests.test_backup_requires_registration) ... ok\ntest_backup_target_exists_refused (test_svc_backup.SvcAgentBackupTests.test_backup_target_exists_refused) ... ok\ntest_backup_without_sqlite3_refuses (test_svc_backup.SvcAgentBackupTests.test_backup_without_sqlite3_refuses)\nPATH 无 sqlite3 → 拒绝(不回退裸拷贝)。重建与真实 PATH 等价的工具目录、 ... ok\ntest_retention_and_listing (test_svc_backup.SvcAgentBackupTests.test_retention_and_listing) ... ok\ntest_rm_purge_removes_snapshots (test_svc_backup.SvcAgentBackupTests.test_rm_purge_removes_snapshots) ... ok\ntest_forward_only_requires_confirm_and_backup (test_svc_deploy_txn.DeployTxnTests.test_forward_only_requires_confirm_and_backup) ... ok\ntest_schema_incompatible_refuses_before_stop (test_svc_deploy_txn.DeployTxnTests.test_schema_incompatible_refuses_before_stop) ... ok\ntest_replace_failure_keeps_original (test_svc_identity.AtomicWriteTests.test_replace_failure_keeps_original) ... ok\ntest_I3_start_ensures_sqlite_identity (test_svc_identity.IdentityTests.test_I3_start_ensures_sqlite_identity) ... ok\ntest_I3_wrong_db_identity_blocks_start (test_svc_identity.IdentityTests.test_I3_wrong_db_identity_blocks_start) ... ok\ntest_T1_bare_restart_stable_identity (test_svc_identity.IdentityTests.test_T1_bare_restart_stable_identity) ... ok\ntest_T2_port_change_keeps_anchors (test_svc_identity.IdentityTests.test_T2_port_change_keeps_anchors) ... ok\ntest_T3_data_drift_rejected (test_svc_identity.IdentityTests.test_T3_data_drift_rejected) ... ok\ntest_T4a_leftover_new_file_ignored (test_svc_identity.IdentityTests.test_T4a_leftover_new_file_ignored) ... ok\ntest_T4b_corrupt_manifest_blocks (test_svc_identity.IdentityTests.test_T4b_corrupt_manifest_blocks) ... ok\ntest_T5a_missing_marker_blocks (test_svc_identity.IdentityTests.test_T5a_missing_marker_blocks) ... ok\ntest_T5b_foreign_marker_blocks (test_svc_identity.IdentityTests.test_T5b_foreign_marker_blocks) ... ok\ntest_T5c_lost_manifest_blocks_not_rebuild (test_svc_identity.IdentityTests.test_T5c_lost_manifest_blocks_not_rebuild) ... ok\ntest_T6_cwd_independent_identity (test_svc_identity.IdentityTests.test_T6_cwd_independent_identity) ... ok\ntest_U1_bare_start_missing_row_is_event_init_recovers (test_svc_identity.IdentityTests.test_U1_bare_start_missing_row_is_event_init_recovers)\n普通启动只验证不补行:DB 行缺失 → 拒绝且不建空行(缺行即事件); ... ok\ntest_U1_capability_probe_no_false_positive_no_hang (test_svc_identity.IdentityTests.test_U1_capability_probe_no_false_positive_no_hang)\n能力探测无副作用:含 ensure 字样的服务态门禁报错不算能力(旧实现 ... ok\ntest_U1_update_ensures_missing_row_before_stop (test_svc_identity.IdentityTests.test_U1_update_ensures_missing_row_before_stop)\n已建立身份后删行:update 在停服前拒绝,不自动补行,旧进程保持。 ... ok\ntest_U1_update_refuses_identityless_candidate_keeps_old_state (test_svc_identity.IdentityTests.test_U1_update_refuses_identityless_candidate_keeps_old_state)\nmanifest 治理实例 × 无 identity 候选(RDM 旧形态):停服前拒绝—— ... ok\ntest_Z_real_pg_not_run (test_svc_identity.IdentityTests.test_Z_real_pg_not_run) ... skipped '真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG=1 + AGENT_PG_TEST_DSN)'\ntest_recover_fails_when_port_stolen (test_svc_identity.IdentityTests.test_recover_fails_when_port_stolen) ... ok\ntest_recover_ignores_never_started_service (test_svc_identity.IdentityTests.test_recover_ignores_never_started_service) ... ok\ntest_recover_noop_while_running (test_svc_identity.IdentityTests.test_recover_noop_while_running) ... ok\ntest_recover_reserved_name_rejected (test_svc_identity.IdentityTests.test_recover_reserved_name_rejected) ... ok\ntest_recover_respects_explicit_stop (test_svc_identity.IdentityTests.test_recover_respects_explicit_stop) ... ok\ntest_recover_restarts_sigkilled_service (test_svc_identity.IdentityTests.test_recover_restarts_sigkilled_service) ... ok\ntest_recover_skips_when_lock_held (test_svc_identity.IdentityTests.test_recover_skips_when_lock_held) ... ok\ntest_start_reclaims_own_orphan_without_pidfile (test_svc_identity.IdentityTests.test_start_reclaims_own_orphan_without_pidfile)\n模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。 ... ok\ntest_start_records_autostart_and_stop_clears (test_svc_identity.IdentityTests.test_start_records_autostart_and_stop_clears) ... ok\ntest_start_rejects_foreign_port_occupant (test_svc_identity.IdentityTests.test_start_rejects_foreign_port_occupant)\n外部 python http.server 占口:start 必须失败并提示非本服务。 ... ok\ntest_stop_on_dead_process_clears_autostart (test_svc_identity.IdentityTests.test_stop_on_dead_process_clears_autostart) ... ok\ntest_watchdog_print_plan (test_svc_identity.IdentityTests.test_watchdog_print_plan) ... ok\ntest_caller_identity_env_does_not_reach_serve (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_caller_identity_env_does_not_reach_serve) ... ok\ntest_recover_fails_when_port_stolen (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_recover_fails_when_port_stolen) ... ok\ntest_recover_ignores_never_started_service (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_recover_ignores_never_started_service) ... ok\ntest_recover_noop_while_running (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_recover_noop_while_running) ... ok\ntest_recover_reserved_name_rejected (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_recover_reserved_name_rejected) ... ok\ntest_recover_respects_explicit_stop (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_recover_respects_explicit_stop) ... ok\ntest_recover_restarts_sigkilled_service (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_recover_restarts_sigkilled_service) ... ok\ntest_recover_skips_when_lock_held (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_recover_skips_when_lock_held) ... ok\ntest_start_reclaims_own_orphan_without_pidfile (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_start_reclaims_own_orphan_without_pidfile)\n模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。 ... ok\ntest_start_records_autostart_and_stop_clears (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_start_records_autostart_and_stop_clears) ... ok\ntest_start_rejects_foreign_port_occupant (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_start_rejects_foreign_port_occupant)\n外部 python http.server 占口:start 必须失败并提示非本服务。 ... ok\ntest_stop_on_dead_process_clears_autostart (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_stop_on_dead_process_clears_autostart) ... ok\ntest_two_bare_restarts_keep_identity (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_two_bare_restarts_keep_identity) ... ok\ntest_watchdog_print_plan (test_svc_lifecycle_isolation.LifecycleIsolationTests.test_watchdog_print_plan) ... ok\ntest_deploy_reuses_selection_and_gates_clearing (test_svc_plugins.PluginComposeTests.test_deploy_reuses_selection_and_gates_clearing) ... ok\ntest_dry_run_prints_plugin_sha_without_listening (test_svc_plugins.PluginComposeTests.test_dry_run_prints_plugin_sha_without_listening) ... ok\ntest_help_plugins_and_rejects_with_off_deploy (test_svc_plugins.PluginComposeTests.test_help_plugins_and_rejects_with_off_deploy) ... ok\ntest_prompt_selects_numbered_plugin_on_first_deploy (test_svc_plugins.PluginComposeTests.test_prompt_selects_numbered_plugin_on_first_deploy) ... ok\ntest_unknown_plugin_and_two_web_and_rdm_are_rejected (test_svc_plugins.PluginComposeTests.test_unknown_plugin_and_two_web_and_rdm_are_rejected) ... ok\ntest_addr_defaults_to_default_addr (test_svc_precheck.AgentListenRejectedPrecheck.test_addr_defaults_to_default_addr) ... ok\ntest_incident_combo_not_rejected (test_svc_precheck.AgentListenRejectedPrecheck.test_incident_combo_not_rejected) ... ok\ntest_insecure_not_rejected (test_svc_precheck.AgentListenRejectedPrecheck.test_insecure_not_rejected) ... ok\ntest_loopback_not_rejected (test_svc_precheck.AgentListenRejectedPrecheck.test_loopback_not_rejected) ... ok\ntest_nonloop_auth_off_with_dsn_rejected (test_svc_precheck.AgentListenRejectedPrecheck.test_nonloop_auth_off_with_dsn_rejected) ... ok\ntest_nonloop_no_dsn_rejected (test_svc_precheck.AgentListenRejectedPrecheck.test_nonloop_no_dsn_rejected) ... ok\ntest_auth_off_beats_dsn (test_svc_precheck.DbAuthEnabledMatrix.test_auth_off_beats_dsn) ... ok\ntest_auth_on_explicit (test_svc_precheck.DbAuthEnabledMatrix.test_auth_on_explicit) ... ok\ntest_auto_with_effective_dsn_is_authed (test_svc_precheck.DbAuthEnabledMatrix.test_auto_with_effective_dsn_is_authed) ... ok\ntest_auto_without_dsn_is_denied (test_svc_precheck.DbAuthEnabledMatrix.test_auto_without_dsn_is_denied) ... ok\ntest_no_auth_no_dsn_denied (test_svc_precheck.DbAuthEnabledMatrix.test_no_auth_no_dsn_denied) ... ok\ntest_broken_dbref_refuses (test_svc_precheck.ResolveDbConfigFixtures.test_broken_dbref_refuses) ... ok\ntest_legacy_exec_dsn_migrates_to_recoverable_keys (test_svc_precheck.ResolveDbConfigFixtures.test_legacy_exec_dsn_migrates_to_recoverable_keys) ... ok\ntest_legacy_exec_dsn_without_instance_goes_to_dsn_key (test_svc_precheck.ResolveDbConfigFixtures.test_legacy_exec_dsn_without_instance_goes_to_dsn_key) ... ok\ntest_migration_is_idempotent (test_svc_precheck.ResolveDbConfigFixtures.test_migration_is_idempotent) ... ok\ntest_no_dsn_no_dbref_is_sqlite (test_svc_precheck.ResolveDbConfigFixtures.test_no_dsn_no_dbref_is_sqlite) ... ok\ntest_post_g17_exec_restores_dsn_from_dbref (test_svc_precheck.ResolveDbConfigFixtures.test_post_g17_exec_restores_dsn_from_dbref) ... ok\ntest_env_config_resets_identity_env (test_svc_precheck.ResolverStateIsolation.test_env_config_resets_identity_env) ... ok\ntest_env_config_resets_pidcheck_runtime_state (test_svc_precheck.ResolverStateIsolation.test_env_config_resets_pidcheck_runtime_state) ... ok\ntest_env_config_resets_resolver_state (test_svc_precheck.ResolverStateIsolation.test_env_config_resets_resolver_state) ... ok\ntest_help_topic_lists_rdm_admin (test_svc_rdm_admin.RdmAdminTests.test_help_topic_lists_rdm_admin) ... ok\ntest_recover_fails_when_port_stolen (test_svc_rdm_admin.RdmAdminTests.test_recover_fails_when_port_stolen) ... ok\ntest_recover_ignores_never_started_service (test_svc_rdm_admin.RdmAdminTests.test_recover_ignores_never_started_service) ... ok\ntest_recover_noop_while_running (test_svc_rdm_admin.RdmAdminTests.test_recover_noop_while_running) ... ok\ntest_recover_reserved_name_rejected (test_svc_rdm_admin.RdmAdminTests.test_recover_reserved_name_rejected) ... ok\ntest_recover_respects_explicit_stop (test_svc_rdm_admin.RdmAdminTests.test_recover_respects_explicit_stop) ... ok\ntest_recover_restarts_sigkilled_service (test_svc_rdm_admin.RdmAdminTests.test_recover_restarts_sigkilled_service) ... ok\ntest_recover_skips_when_lock_held (test_svc_rdm_admin.RdmAdminTests.test_recover_skips_when_lock_held) ... ok\ntest_refuses_when_base_db_missing (test_svc_rdm_admin.RdmAdminTests.test_refuses_when_base_db_missing) ... ok\ntest_refuses_when_rdm_db_missing (test_svc_rdm_admin.RdmAdminTests.test_refuses_when_rdm_db_missing) ... ok\ntest_show_empty_ledger_hint (test_svc_rdm_admin.RdmAdminTests.test_show_empty_ledger_hint) ... ok\ntest_show_known_owner_no_warning (test_svc_rdm_admin.RdmAdminTests.test_show_known_owner_no_warning) ... ok\ntest_show_prints_ledger_and_flags_unknown_owner (test_svc_rdm_admin.RdmAdminTests.test_show_prints_ledger_and_flags_unknown_owner) ... ok\ntest_start_reclaims_own_orphan_without_pidfile (test_svc_rdm_admin.RdmAdminTests.test_start_reclaims_own_orphan_without_pidfile)\n模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。 ... ok\ntest_start_records_autostart_and_stop_clears (test_svc_rdm_admin.RdmAdminTests.test_start_records_autostart_and_stop_clears) ... ok\ntest_start_rejects_foreign_port_occupant (test_svc_rdm_admin.RdmAdminTests.test_start_rejects_foreign_port_occupant)\n外部 python http.server 占口:start 必须失败并提示非本服务。 ... ok\ntest_status_lists_rdm_admin_row_and_warning (test_svc_rdm_admin.RdmAdminTests.test_status_lists_rdm_admin_row_and_warning) ... ok\ntest_status_silent_without_rdm_db (test_svc_rdm_admin.RdmAdminTests.test_status_silent_without_rdm_db) ... ok\ntest_stop_on_dead_process_clears_autostart (test_svc_rdm_admin.RdmAdminTests.test_stop_on_dead_process_clears_autostart) ... ok\ntest_transfer_idempotent_when_already_owner (test_svc_rdm_admin.RdmAdminTests.test_transfer_idempotent_when_already_owner) ... ok\ntest_transfer_rejects_empty_ledger (test_svc_rdm_admin.RdmAdminTests.test_transfer_rejects_empty_ledger) ... ok\ntest_transfer_rejects_owner_missing_from_base_users (test_svc_rdm_admin.RdmAdminTests.test_transfer_rejects_owner_missing_from_base_users) ... ok\ntest_transfer_rewrites_single_row_with_provenance (test_svc_rdm_admin.RdmAdminTests.test_transfer_rewrites_single_row_with_provenance) ... ok\ntest_unknown_subcommand_usage_exit_2 (test_svc_rdm_admin.RdmAdminTests.test_unknown_subcommand_usage_exit_2) ... ok\ntest_watchdog_print_plan (test_svc_rdm_admin.RdmAdminTests.test_watchdog_print_plan) ... ok\ntest_U3_real_agent_identity_lifecycle_sqlite (test_svc_realcombo.RealComboTests.test_U3_real_agent_identity_lifecycle_sqlite)\n真实组合二进制:领养 ensure → verify → 两次裸重启身份/锚点稳定; ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_U3_real_pg_identity_env_delivery (test_svc_realcombo.RealComboTests.test_U3_real_pg_identity_env_delivery)\n真实 PostgreSQL:AGENT_SYSTEM_DSN 环境交付 ensure/verify(凭据不进 ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_U3_real_rdm_binary_lifecycle_sqlite (test_svc_realcombo.RealComboTests.test_U3_real_rdm_binary_lifecycle_sqlite)\n真实 RDM 组合二进制:identity 接线(Backend 公共 CLI)→ serve 门禁 → ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_U3_real_rdm_full_deploy_track (test_svc_realcombo.RealComboTests.test_U3_real_rdm_full_deploy_track)\n全轨:materialize(真实 make build)→ 候选身份门禁 → release 切换 → ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_fails_when_port_stolen (test_svc_realcombo.RealComboTests.test_recover_fails_when_port_stolen) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_ignores_never_started_service (test_svc_realcombo.RealComboTests.test_recover_ignores_never_started_service) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_noop_while_running (test_svc_realcombo.RealComboTests.test_recover_noop_while_running) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_reserved_name_rejected (test_svc_realcombo.RealComboTests.test_recover_reserved_name_rejected) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_respects_explicit_stop (test_svc_realcombo.RealComboTests.test_recover_respects_explicit_stop) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_restarts_sigkilled_service (test_svc_realcombo.RealComboTests.test_recover_restarts_sigkilled_service) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_skips_when_lock_held (test_svc_realcombo.RealComboTests.test_recover_skips_when_lock_held) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_start_reclaims_own_orphan_without_pidfile (test_svc_realcombo.RealComboTests.test_start_reclaims_own_orphan_without_pidfile)\n模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。 ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_start_records_autostart_and_stop_clears (test_svc_realcombo.RealComboTests.test_start_records_autostart_and_stop_clears) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_start_rejects_foreign_port_occupant (test_svc_realcombo.RealComboTests.test_start_rejects_foreign_port_occupant)\n外部 python http.server 占口:start 必须失败并提示非本服务。 ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_stop_on_dead_process_clears_autostart (test_svc_realcombo.RealComboTests.test_stop_on_dead_process_clears_autostart) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_watchdog_print_plan (test_svc_realcombo.RealComboTests.test_watchdog_print_plan) ... skipped '真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)'\ntest_recover_fails_when_port_stolen (test_svc_recover.SvcAutoRecoverTests.test_recover_fails_when_port_stolen) ... ok\ntest_recover_ignores_never_started_service (test_svc_recover.SvcAutoRecoverTests.test_recover_ignores_never_started_service) ... ok\ntest_recover_noop_while_running (test_svc_recover.SvcAutoRecoverTests.test_recover_noop_while_running) ... ok\ntest_recover_reserved_name_rejected (test_svc_recover.SvcAutoRecoverTests.test_recover_reserved_name_rejected) ... ok\ntest_recover_respects_explicit_stop (test_svc_recover.SvcAutoRecoverTests.test_recover_respects_explicit_stop) ... ok\ntest_recover_restarts_sigkilled_service (test_svc_recover.SvcAutoRecoverTests.test_recover_restarts_sigkilled_service) ... ok\ntest_recover_skips_when_lock_held (test_svc_recover.SvcAutoRecoverTests.test_recover_skips_when_lock_held) ... ok\ntest_start_reclaims_own_orphan_without_pidfile (test_svc_recover.SvcAutoRecoverTests.test_start_reclaims_own_orphan_without_pidfile)\n模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。 ... ok\ntest_start_records_autostart_and_stop_clears (test_svc_recover.SvcAutoRecoverTests.test_start_records_autostart_and_stop_clears) ... ok\ntest_start_rejects_foreign_port_occupant (test_svc_recover.SvcAutoRecoverTests.test_start_rejects_foreign_port_occupant)\n外部 python http.server 占口:start 必须失败并提示非本服务。 ... ok\ntest_stop_on_dead_process_clears_autostart (test_svc_recover.SvcAutoRecoverTests.test_stop_on_dead_process_clears_autostart) ... ok\ntest_watchdog_print_plan (test_svc_recover.SvcAutoRecoverTests.test_watchdog_print_plan) ... ok\ntest_I3_start_ensures_sqlite_identity (test_svc_u2.U2ReleaseTests.test_I3_start_ensures_sqlite_identity) ... ok\ntest_I3_wrong_db_identity_blocks_start (test_svc_u2.U2ReleaseTests.test_I3_wrong_db_identity_blocks_start) ... ok\ntest_T1_bare_restart_stable_identity (test_svc_u2.U2ReleaseTests.test_T1_bare_restart_stable_identity) ... ok\ntest_T2_port_change_keeps_anchors (test_svc_u2.U2ReleaseTests.test_T2_port_change_keeps_anchors) ... ok\ntest_T3_data_drift_rejected (test_svc_u2.U2ReleaseTests.test_T3_data_drift_rejected) ... ok\ntest_T4a_leftover_new_file_ignored (test_svc_u2.U2ReleaseTests.test_T4a_leftover_new_file_ignored) ... ok\ntest_T4b_corrupt_manifest_blocks (test_svc_u2.U2ReleaseTests.test_T4b_corrupt_manifest_blocks) ... ok\ntest_T5a_missing_marker_blocks (test_svc_u2.U2ReleaseTests.test_T5a_missing_marker_blocks) ... ok\ntest_T5b_foreign_marker_blocks (test_svc_u2.U2ReleaseTests.test_T5b_foreign_marker_blocks) ... ok\ntest_T5c_lost_manifest_blocks_not_rebuild (test_svc_u2.U2ReleaseTests.test_T5c_lost_manifest_blocks_not_rebuild) ... ok\ntest_T6_cwd_independent_identity (test_svc_u2.U2ReleaseTests.test_T6_cwd_independent_identity) ... ok\ntest_U1_bare_start_missing_row_is_event_init_recovers (test_svc_u2.U2ReleaseTests.test_U1_bare_start_missing_row_is_event_init_recovers)\n普通启动只验证不补行:DB 行缺失 → 拒绝且不建空行(缺行即事件); ... ok\ntest_U1_capability_probe_no_false_positive_no_hang (test_svc_u2.U2ReleaseTests.test_U1_capability_probe_no_false_positive_no_hang)\n能力探测无副作用:含 ensure 字样的服务态门禁报错不算能力(旧实现 ... ok\ntest_U1_update_ensures_missing_row_before_stop (test_svc_u2.U2ReleaseTests.test_U1_update_ensures_missing_row_before_stop)\n已建立身份后删行:update 在停服前拒绝,不自动补行,旧进程保持。 ... ok\ntest_U1_update_refuses_identityless_candidate_keeps_old_state (test_svc_u2.U2ReleaseTests.test_U1_update_refuses_identityless_candidate_keeps_old_state)\nmanifest 治理实例 × 无 identity 候选(RDM 旧形态):停服前拒绝—— ... ok\ntest_U2_build_failure_keeps_old_running (test_svc_u2.U2ReleaseTests.test_U2_build_failure_keeps_old_running) ... ok\ntest_U2_fault_after_switch_rolls_back_on_next_start (test_svc_u2.U2ReleaseTests.test_U2_fault_after_switch_rolls_back_on_next_start) ... ok\ntest_U2_health_timeout_rolls_back (test_svc_u2.U2ReleaseTests.test_U2_health_timeout_rolls_back) ... ok\ntest_U2_journal_interrupt_resolved_on_start (test_svc_u2.U2ReleaseTests.test_U2_journal_interrupt_resolved_on_start) ... ok\ntest_U2_layout_switch_and_rollback_on_start_failure (test_svc_u2.U2ReleaseTests.test_U2_layout_switch_and_rollback_on_start_failure) ... ok\ntest_U2_legacy_layout_bare_lifecycle_still_works (test_svc_u2.U2ReleaseTests.test_U2_legacy_layout_bare_lifecycle_still_works) ... ok\ntest_U2_schema_advance_blocks_rollback (test_svc_u2.U2ReleaseTests.test_U2_schema_advance_blocks_rollback) ... ok\ntest_Z_real_pg_not_run (test_svc_u2.U2ReleaseTests.test_Z_real_pg_not_run) ... skipped '真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG=1 + AGENT_PG_TEST_DSN)'\ntest_recover_fails_when_port_stolen (test_svc_u2.U2ReleaseTests.test_recover_fails_when_port_stolen) ... ok\ntest_recover_ignores_never_started_service (test_svc_u2.U2ReleaseTests.test_recover_ignores_never_started_service) ... ok\ntest_recover_noop_while_running (test_svc_u2.U2ReleaseTests.test_recover_noop_while_running) ... ok\ntest_recover_reserved_name_rejected (test_svc_u2.U2ReleaseTests.test_recover_reserved_name_rejected) ... ok\ntest_recover_respects_explicit_stop (test_svc_u2.U2ReleaseTests.test_recover_respects_explicit_stop) ... ok\ntest_recover_restarts_sigkilled_service (test_svc_u2.U2ReleaseTests.test_recover_restarts_sigkilled_service) ... ok\ntest_recover_skips_when_lock_held (test_svc_u2.U2ReleaseTests.test_recover_skips_when_lock_held) ... ok\ntest_start_reclaims_own_orphan_without_pidfile (test_svc_u2.U2ReleaseTests.test_start_reclaims_own_orphan_without_pidfile)\n模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。 ... ok\ntest_start_records_autostart_and_stop_clears (test_svc_u2.U2ReleaseTests.test_start_records_autostart_and_stop_clears) ... ok\ntest_start_rejects_foreign_port_occupant (test_svc_u2.U2ReleaseTests.test_start_rejects_foreign_port_occupant)\n外部 python http.server 占口:start 必须失败并提示非本服务。 ... ok\ntest_stop_on_dead_process_clears_autostart (test_svc_u2.U2ReleaseTests.test_stop_on_dead_process_clears_autostart) ... ok\ntest_watchdog_print_plan (test_svc_u2.U2ReleaseTests.test_watchdog_print_plan) ... ok\ntest_T1_user_list_binds_manifest_data (test_svc_user_binding.UserBindingTests.test_T1_user_list_binds_manifest_data) ... ok\ntest_T2_reject_override_data_flag (test_svc_user_binding.UserBindingTests.test_T2_reject_override_data_flag) ... ok\ntest_T2b_reject_literal_default_data (test_svc_user_binding.UserBindingTests.test_T2b_reject_literal_default_data) ... ok\ntest_T3_missing_data_root_rejected (test_svc_user_binding.UserBindingTests.test_T3_missing_data_root_rejected) ... ok\ntest_T4_no_flock_rejects_user_and_deploy (test_svc_user_binding.UserBindingTests.test_T4_no_flock_rejects_user_and_deploy) ... ok\ntest_T5_identity_regression_smoke (test_svc_user_binding.UserBindingTests.test_T5_identity_regression_smoke) ... ok\ntest_recover_fails_when_port_stolen (test_svc_user_binding.UserBindingTests.test_recover_fails_when_port_stolen) ... ok\ntest_recover_ignores_never_started_service (test_svc_user_binding.UserBindingTests.test_recover_ignores_never_started_service) ... ok\ntest_recover_noop_while_running (test_svc_user_binding.UserBindingTests.test_recover_noop_while_running) ... ok\ntest_recover_reserved_name_rejected (test_svc_user_binding.UserBindingTests.test_recover_reserved_name_rejected) ... ok\ntest_recover_respects_explicit_stop (test_svc_user_binding.UserBindingTests.test_recover_respects_explicit_stop) ... ok\ntest_recover_restarts_sigkilled_service (test_svc_user_binding.UserBindingTests.test_recover_restarts_sigkilled_service) ... ok\ntest_recover_skips_when_lock_held (test_svc_user_binding.UserBindingTests.test_recover_skips_when_lock_held) ... ok\ntest_start_reclaims_own_orphan_without_pidfile (test_svc_user_binding.UserBindingTests.test_start_reclaims_own_orphan_without_pidfile)\n模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。 ... ok\ntest_start_records_autostart_and_stop_clears (test_svc_user_binding.UserBindingTests.test_start_records_autostart_and_stop_clears) ... ok\ntest_start_rejects_foreign_port_occupant (test_svc_user_binding.UserBindingTests.test_start_rejects_foreign_port_occupant)\n外部 python http.server 占口:start 必须失败并提示非本服务。 ... ok\ntest_stop_on_dead_process_clears_autostart (test_svc_user_binding.UserBindingTests.test_stop_on_dead_process_clears_autostart) ... ok\ntest_watchdog_print_plan (test_svc_user_binding.UserBindingTests.test_watchdog_print_plan) ... ok\ntest_full_help_mentions_user_binding (test_svc_user_help.UserHelpTests.test_full_help_mentions_user_binding) ... ok\ntest_help_accounts_lists_six_commands (test_svc_user_help.UserHelpTests.test_help_accounts_lists_six_commands) ... ok\ntest_topics_and_short_and_lifecycle_mention_accounts (test_svc_user_help.UserHelpTests.test_topics_and_short_and_lifecycle_mention_accounts) ... ok\ntest_ide_lock_in_git_dir_does_not_block_finish (test_task.FinishGuardFixTests.test_ide_lock_in_git_dir_does_not_block_finish) ... ok\ntest_local_default_branch_at_origin_main_does_not_block (test_task.FinishGuardFixTests.test_local_default_branch_at_origin_main_does_not_block)\n克隆默认分支停在 origin/main 顶端(pin 为 feature 顶端时不在其历史内) ... ok\ntest_local_unique_commit_still_preserved (test_task.FinishGuardFixTests.test_local_unique_commit_still_preserved) ... ok\ntest_real_git_lock_still_blocks_finish (test_task.FinishGuardFixTests.test_real_git_lock_still_blocks_finish) ... ok\ntest_remote_tracking_refs_do_not_block_finish (test_task.FinishGuardFixTests.test_remote_tracking_refs_do_not_block_finish) ... ok\ntest_close_backfills_card_landed_as_squash (test_task.TaskLifecycleTests.test_close_backfills_card_landed_as_squash) ... /home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.8/lib/python3.14/unittest/case.py:704: ResourceWarning: unclosed \n function, args, kwargs = self._cleanups.pop()\nResourceWarning: Enable tracemalloc to get the object allocation traceback\nok\ntest_close_ignores_squash_claim_when_sha_not_on_main (test_task.TaskLifecycleTests.test_close_ignores_squash_claim_when_sha_not_on_main) ... /home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.8/lib/python3.14/unittest/case.py:704: ResourceWarning: unclosed \n function, args, kwargs = self._cleanups.pop()\nResourceWarning: Enable tracemalloc to get the object allocation traceback\nok\ntest_finish_abandon_retains_local_remote_tips_and_reason (test_task.TaskLifecycleTests.test_finish_abandon_retains_local_remote_tips_and_reason) ... ok\ntest_finish_abandon_without_reason_preserves_checkout_and_refs (test_task.TaskLifecycleTests.test_finish_abandon_without_reason_preserves_checkout_and_refs) ... ok\ntest_finish_clean_worktree_has_accurate_remote_result (test_task.TaskLifecycleTests.test_finish_clean_worktree_has_accurate_remote_result) ... ok\ntest_finish_deletes_only_merged_remote_branch (test_task.TaskLifecycleTests.test_finish_deletes_only_merged_remote_branch) ... ok\ntest_finish_preserves_component_commits_off_the_pinned_head (test_task.TaskLifecycleTests.test_finish_preserves_component_commits_off_the_pinned_head) ... ok\ntest_finish_preserves_component_untracked_file (test_task.TaskLifecycleTests.test_finish_preserves_component_untracked_file) ... ok\ntest_finish_preserves_linked_component_worktrees_at_the_pinned_head (test_task.TaskLifecycleTests.test_finish_preserves_linked_component_worktrees_at_the_pinned_head) ... ok\ntest_finish_preserves_linked_nested_component_worktree (test_task.TaskLifecycleTests.test_finish_preserves_linked_nested_component_worktree) ... ok\ntest_finish_preserves_locked_worktree (test_task.TaskLifecycleTests.test_finish_preserves_locked_worktree) ... ok\ntest_finish_preserves_main_and_active_index_lock (test_task.TaskLifecycleTests.test_finish_preserves_main_and_active_index_lock) ... ok\ntest_finish_preserves_modified_untracked_and_ignored_files (test_task.TaskLifecycleTests.test_finish_preserves_modified_untracked_and_ignored_files) ... ok\ntest_finish_preserves_remote_unmerged_tip (test_task.TaskLifecycleTests.test_finish_preserves_remote_unmerged_tip) ... ok\ntest_finish_recycles_clean_submodule_without_shared_config_changes (test_task.TaskLifecycleTests.test_finish_recycles_clean_submodule_without_shared_config_changes) ... ok\ntest_finish_rejects_subdirectory_without_deleting_it (test_task.TaskLifecycleTests.test_finish_rejects_subdirectory_without_deleting_it) ... ok\ntest_finish_remote_deletion_lease_preserves_concurrent_update (test_task.TaskLifecycleTests.test_finish_remote_deletion_lease_preserves_concurrent_update) ... ok\ntest_finish_requires_writer_exit_confirmation (test_task.TaskLifecycleTests.test_finish_requires_writer_exit_confirmation) ... ok\ntest_long_merge_history_keeps_merged_card_visible_and_closable (test_task.TaskLifecycleTests.test_long_merge_history_keeps_merged_card_visible_and_closable) ... ok\ntest_post_merge_is_silent_outside_main_checkout (test_task.TaskLifecycleTests.test_post_merge_is_silent_outside_main_checkout) ... ok\ntest_post_merge_main_merge_only_reminds_owner (test_task.TaskLifecycleTests.test_post_merge_main_merge_only_reminds_owner) ... ok\ntest_post_merge_main_pull_only_reminds_owner (test_task.TaskLifecycleTests.test_post_merge_main_pull_only_reminds_owner) ... ok\ntest_precheck_facts_row_with_escaped_pipe_stays_fresh (test_task.TaskLifecycleTests.test_precheck_facts_row_with_escaped_pipe_stays_fresh) ... ok\ntest_precheck_feature_behind_requires_sync (test_task.TaskLifecycleTests.test_precheck_feature_behind_requires_sync) ... ok\ntest_precheck_rejects_unreachable_authority (test_task.TaskLifecycleTests.test_precheck_rejects_unreachable_authority) ... ok\ntest_precheck_routes_existing_project_delivery_policy (test_task.TaskLifecycleTests.test_precheck_routes_existing_project_delivery_policy) ... ok\ntest_start_imports_precheck_and_creates_four_part_commit (test_task.TaskLifecycleTests.test_start_imports_precheck_and_creates_four_part_commit) ... ok\ntest_start_offline_does_not_create_worktree (test_task.TaskLifecycleTests.test_start_offline_does_not_create_worktree) ... ok\ntest_start_preserves_merged_local_and_remote_branch_names (test_task.TaskLifecycleTests.test_start_preserves_merged_local_and_remote_branch_names) ... ok\ntest_start_rejects_receipt_when_authoritative_base_changed (test_task.TaskLifecycleTests.test_start_rejects_receipt_when_authoritative_base_changed) ... ok\ntest_start_requires_precheck_receipt_before_creating_worktree (test_task.TaskLifecycleTests.test_start_requires_precheck_receipt_before_creating_worktree) ... ok\ntest_start_stacked_branch_records_parent_and_main_baseline (test_task.TaskLifecycleTests.test_start_stacked_branch_records_parent_and_main_baseline) ... ok\ntest_sync_commit_failure_never_reports_synced (test_task.TaskLifecycleTests.test_sync_commit_failure_never_reports_synced) ... ok\ntest_sync_conflict_is_not_reported_as_synced (test_task.TaskLifecycleTests.test_sync_conflict_is_not_reported_as_synced) ... ok\ntest_sync_does_not_execute_json_receipt_command (test_task.TaskLifecycleTests.test_sync_does_not_execute_json_receipt_command) ... ok\ntest_sync_keeps_json_evidence_fresh_after_metadata_only_changes (test_task.TaskLifecycleTests.test_sync_keeps_json_evidence_fresh_after_metadata_only_changes) ... ok\ntest_sync_keeps_unchanged_component_branch (test_task.TaskLifecycleTests.test_sync_keeps_unchanged_component_branch) ... ok\ntest_sync_materializes_nested_detached_components (test_task.TaskLifecycleTests.test_sync_materializes_nested_detached_components) ... ok\ntest_sync_materializes_updated_component (test_task.TaskLifecycleTests.test_sync_materializes_updated_component) ... ok\ntest_sync_new_base_reports_actual_final_head (test_task.TaskLifecycleTests.test_sync_new_base_reports_actual_final_head) ... ok\ntest_sync_preserves_component_feature_branch (test_task.TaskLifecycleTests.test_sync_preserves_component_feature_branch) ... ok\ntest_sync_preserves_dirty_child (test_task.TaskLifecycleTests.test_sync_preserves_dirty_child) ... ok\ntest_sync_preserves_dirty_parent_and_staged_changes (test_task.TaskLifecycleTests.test_sync_preserves_dirty_parent_and_staged_changes) ... ok\ntest_sync_preserves_nested_feature_branch_when_parent_pin_changes (test_task.TaskLifecycleTests.test_sync_preserves_nested_feature_branch_when_parent_pin_changes) ... ok\ntest_sync_reads_legacy_commands_with_escaped_pipes (test_task.TaskLifecycleTests.test_sync_reads_legacy_commands_with_escaped_pipes) ... ok\ntest_sync_rejects_unreachable_target_without_changing_head (test_task.TaskLifecycleTests.test_sync_rejects_unreachable_target_without_changing_head) ... ok\ntest_sync_same_base_preserves_head_and_card (test_task.TaskLifecycleTests.test_sync_same_base_preserves_head_and_card) ... ok\ntest_sync_still_warns_for_stale_legacy_table_evidence (test_task.TaskLifecycleTests.test_sync_still_warns_for_stale_legacy_table_evidence) ... ok\ntest_sync_warns_for_malformed_json_evidence (test_task.TaskLifecycleTests.test_sync_warns_for_malformed_json_evidence) ... ok\ntest_sync_warns_for_missing_json_receipt_fields_even_with_valid_checksum (test_task.TaskLifecycleTests.test_sync_warns_for_missing_json_receipt_fields_even_with_valid_checksum) ... ok\ntest_sync_warns_for_unresolved_json_evidence_head (test_task.TaskLifecycleTests.test_sync_warns_for_unresolved_json_evidence_head) ... ok\ntest_sync_warns_for_unresolved_legacy_evidence_head (test_task.TaskLifecycleTests.test_sync_warns_for_unresolved_legacy_evidence_head) ... ok\ntest_sync_warns_when_json_after_state_contains_uncommitted_code (test_task.TaskLifecycleTests.test_sync_warns_when_json_after_state_contains_uncommitted_code) ... ok\ntest_sync_warns_when_json_before_state_contains_uncommitted_code (test_task.TaskLifecycleTests.test_sync_warns_when_json_before_state_contains_uncommitted_code) ... ok\ntest_sync_warns_when_json_evidence_predates_noncard_changes (test_task.TaskLifecycleTests.test_sync_warns_when_json_evidence_predates_noncard_changes) ... ok\ntest_verify_rejects_recursive_drift_even_when_root_has_local_edits (test_task.TaskLifecycleTests.test_verify_rejects_recursive_drift_even_when_root_has_local_edits) ... ok\ntest_verify_rejects_uninitialized_component (test_task.TaskLifecycleTests.test_verify_rejects_uninitialized_component) ... ok\ntest_local_precheck_allows_known_lag_without_changing_head (test_task_recovery.TaskRecoveryTests.test_local_precheck_allows_known_lag_without_changing_head) ... ok\ntest_local_precheck_never_advertises_delivery_even_when_current (test_task_recovery.TaskRecoveryTests.test_local_precheck_never_advertises_delivery_even_when_current) ... ok\ntest_local_precheck_rejects_dirty_state_and_unknown_remote (test_task_recovery.TaskRecoveryTests.test_local_precheck_rejects_dirty_state_and_unknown_remote) ... ok\ntest_local_precheck_rejects_foreign_registration (test_task_recovery.TaskRecoveryTests.test_local_precheck_rejects_foreign_registration) ... ok\ntest_local_precheck_rejects_invalid_baseline (test_task_recovery.TaskRecoveryTests.test_local_precheck_rejects_invalid_baseline) ... ok\ntest_local_precheck_rejects_main_and_missing_registration (test_task_recovery.TaskRecoveryTests.test_local_precheck_rejects_main_and_missing_registration) ... ok\ntest_local_precheck_rejects_wrong_branch_registration (test_task_recovery.TaskRecoveryTests.test_local_precheck_rejects_wrong_branch_registration) ... ok\ntest_local_receipt_cannot_start_a_task_or_resume_for_delivery (test_task_recovery.TaskRecoveryTests.test_local_receipt_cannot_start_a_task_or_resume_for_delivery) ... ok\ntest_local_resume_imports_original_receipt_without_sync (test_task_recovery.TaskRecoveryTests.test_local_resume_imports_original_receipt_without_sync) ... ok\ntest_local_resume_rejects_divergent_target_before_import (test_task_recovery.TaskRecoveryTests.test_local_resume_rejects_divergent_target_before_import) ... ok\ntest_local_resume_rejects_target_rewind_before_import (test_task_recovery.TaskRecoveryTests.test_local_resume_rejects_target_rewind_before_import) ... ok\ntest_make_resume_exports_the_selected_mode_and_receipt (test_task_recovery.TaskRecoveryTests.test_make_resume_exports_the_selected_mode_and_receipt) ... ok\ntest_resume_rejects_another_worktree_with_same_head (test_task_recovery.TaskRecoveryTests.test_resume_rejects_another_worktree_with_same_head) ... ok\ntest_resume_rejects_changed_source_head (test_task_recovery.TaskRecoveryTests.test_resume_rejects_changed_source_head) ... ok\ntest_strict_resume_imports_current_ready_receipt (test_task_recovery.TaskRecoveryTests.test_strict_resume_imports_current_ready_receipt) ... ok\ntest_strict_resume_rejects_target_advance (test_task_recovery.TaskRecoveryTests.test_strict_resume_rejects_target_advance) ... ok\ntest_unknown_validation_purpose_is_rejected (test_task_recovery.TaskRecoveryTests.test_unknown_validation_purpose_is_rejected) ... ok\ntest_unknown_work_mode_is_rejected (test_task_recovery.TaskRecoveryTests.test_unknown_work_mode_is_rejected) ... ok\ntest_deleting_main_symlink_to_outside_is_denied (test_write_guard.WriteGuardTests.test_deleting_main_symlink_to_outside_is_denied) ... ok\ntest_detached_and_bad_branches_are_denied (test_write_guard.WriteGuardTests.test_detached_and_bad_branches_are_denied) ... ok\ntest_every_patch_file_and_move_destination_is_checked (test_write_guard.WriteGuardTests.test_every_patch_file_and_move_destination_is_checked) ... ok\ntest_feature_missing_parent_is_allowed (test_write_guard.WriteGuardTests.test_feature_missing_parent_is_allowed) ... ok\ntest_invalid_json_is_denied (test_write_guard.WriteGuardTests.test_invalid_json_is_denied) ... ok\ntest_missing_parent_on_main_is_denied (test_write_guard.WriteGuardTests.test_missing_parent_on_main_is_denied) ... ok\ntest_missing_unknown_and_malformed_write_input_are_denied (test_write_guard.WriteGuardTests.test_missing_unknown_and_malformed_write_input_are_denied) ... ok\ntest_non_git_destination_is_explicitly_outside_scope (test_write_guard.WriteGuardTests.test_non_git_destination_is_explicitly_outside_scope) ... ok\ntest_nonwrite_tools_are_outside_scope (test_write_guard.WriteGuardTests.test_nonwrite_tools_are_outside_scope) ... ok\ntest_relative_path_uses_hook_cwd_not_process_cwd (test_write_guard.WriteGuardTests.test_relative_path_uses_hook_cwd_not_process_cwd) ... ok\ntest_symlink_into_main_with_missing_parents_is_denied (test_write_guard.WriteGuardTests.test_symlink_into_main_with_missing_parents_is_denied) ... ok\ntest_valid_multi_file_patch_on_feature_is_allowed (test_write_guard.WriteGuardTests.test_valid_multi_file_patch_on_feature_is_allowed) ... ok\n\n----------------------------------------------------------------------\nRan 472 tests in 291.742s\n\nOK (skipped=19)\nmake[1]: Leaving directory '/tmp/codex-cli-fallback-admin.82BLPc/repo-wt-feature-agent-cli-enforcement'\n", + "output_sha256": "982f7c439a4230872c1d4190d407dddfb399f7faaf65f9eb72fb0a8cebb85b5d", + "receipt_sha256": "a6588f0a3204b382cb10857b3183bc953db5ee5496b6bb134afe7dd3ced4db4b" +} +``` + + +```json +{ + "schema": 1, + "source": "SELF", + "timestamp": "2026-10-15T21:04:47.692970+00:00", + "command": "make task-sync", + "command_sha256": "6c0e3c8fbb3f43d01548180c9d9c147ce1c1c32ba1e78d3383961de470fe0d52", + "cwd": "/tmp/codex-cli-fallback-admin.82BLPc/repo-wt-feature-agent-cli-enforcement", + "common_dir": "/tmp/codex-cli-fallback-admin.82BLPc/repo/.git", + "before": { + "head": "2fcb4c72e8cabb6e6057abb031e4d74060f8650c", + "branch": "feature/agent-cli-enforcement", + "target_branch": "main", + "target_head": "bc5a1194984433c4098f1961f5280b3d9ee20995", + "status": "", + "tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "untracked": {}, + "submodules": " 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 2f5b19872be2a4597838a4131ad75d1be089ee5e deps/development-guidelines (heads/main)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/logging-system/deps/development-guidelines (26b562d)\n ab12fea4745cf22fb7648c1c25b18433ac8a3650 modules/backend (v0.5.0-23-gab12fea)\n 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 modules/backend/deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 modules/backend/deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e modules/backend/deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/backend/deps/agent-system-core (v0.4.0-3-g1a5ef6b)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/core (v0.4.0-3-g1a5ef6b)\n 73bbb4cea686eb84826d5066bb4c44bf883c9365 modules/documents (remotes/origin/feature/layered-python-env)\n 781cde66675e6d9293004f19543500aa8e24bdb8 modules/frontend (v0.4.0-34-g781cde6)\n c9187473fb1ae9e8f3946cdf9b67055ee732054a modules/ui (v0.4.0-9-gc918747)\n" + }, + "after": { + "head": "2fcb4c72e8cabb6e6057abb031e4d74060f8650c", + "branch": "feature/agent-cli-enforcement", + "target_branch": "main", + "target_head": "bc5a1194984433c4098f1961f5280b3d9ee20995", + "status": "", + "tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "untracked": {}, + "submodules": " 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 2f5b19872be2a4597838a4131ad75d1be089ee5e deps/development-guidelines (heads/main)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 deps/logging-system/deps/development-guidelines (26b562d)\n ab12fea4745cf22fb7648c1c25b18433ac8a3650 modules/backend (v0.5.0-23-gab12fea)\n 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 modules/backend/deps/agent-command (heads/main)\n ed2af3e6048928b1fd0ba6da1f9d0c1bafec82d4 modules/backend/deps/agent-command/deps/development-guidelines (remotes/origin/feature/delivery-mechanism-5-ged2af3e)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e modules/backend/deps/agent-squad (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n c348441691e8bdab784a03aa9be64d53dacff341 modules/backend/deps/agent-squad/deps/agent-native (heads/main)\n 343b1e6d9a5b58e47bf7e594826221036ae8175c modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent (heads/main)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/agent-intent/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/agent-native/deps/logging-system/deps/development-guidelines (26b562d)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/development-guidelines (26b562d)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/agent-squad/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/agent-squad/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/agent-squad/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/backend/deps/agent-system-core (v0.4.0-3-g1a5ef6b)\n a3ee67bba269e1276c14e22c026171ecf2cf6190 modules/backend/deps/error-code-system (a3ee67b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/error-code-system/deps/development-guidelines (26b562d)\n 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 modules/backend/deps/logging-system (49b5d8b)\n 26b562d2e34fbc560711296a309c91c74ffc55b1 modules/backend/deps/logging-system/deps/development-guidelines (26b562d)\n 1a5ef6b75cc74088786487d82b6f6b70bf977280 modules/core (v0.4.0-3-g1a5ef6b)\n 73bbb4cea686eb84826d5066bb4c44bf883c9365 modules/documents (remotes/origin/feature/layered-python-env)\n 781cde66675e6d9293004f19543500aa8e24bdb8 modules/frontend (v0.4.0-34-g781cde6)\n c9187473fb1ae9e8f3946cdf9b67055ee732054a modules/ui (v0.4.0-9-gc918747)\n" + }, + "after_error": null, + "exit_code": 0, + "output": "make[1]: Entering directory '/tmp/codex-cli-fallback-admin.82BLPc/repo-wt-feature-agent-cli-enforcement'\n[worktree] before-sync: recursive component checks passed\n[worktree] before-update: recursive component checks passed\n[worktree] materialize: recursive component checks passed\n[worktree] after-sync: recursive component checks passed\n[evidence] WARN JSON receipt line 123: unverified: recorded component state was not at its pinned SHA ——关键检查须重跑并以 make evidence 重新写入\n[synced] sync_base=bc5a1194984433c4098f1961f5280b3d9ee20995 tested_head=2fcb4c72e8cabb6e6057abb031e4d74060f8650c checked_at=2026-10-15T21:05:23Z changed=0\n[next] 跑正式验收(make test 等),证据写入任务卡\nmake[1]: Leaving directory '/tmp/codex-cli-fallback-admin.82BLPc/repo-wt-feature-agent-cli-enforcement'\n", + "output_sha256": "1143a3282d3770f6d1f57c1822c37838eb28a644fa9413311c620a506bab6c1f", + "receipt_sha256": "013fd8eed31c25c52afe36b4e2c8f70c5cbbaac7ebada630c5e75994648db3de" +} +``` diff --git a/AGENT-EXECUTION.md b/AGENT-EXECUTION.md index 1a8f2023..662f22b1 100644 --- a/AGENT-EXECUTION.md +++ b/AGENT-EXECUTION.md @@ -7,11 +7,19 @@ 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`. +Normal implementation, repair, and test routes stay Cursor, Antigravity +(`agy`), and PI. Each of those profiles may be attempted up to +`max_attempts_per_profile`, which is exactly 3 whenever `emergency_profile` +is configured. Only after those candidates safely exhaust their attempts does +the enabled emergency `codex` profile run once. Codex remains out of the three +normal role lists and may also appear in review. `fallback_to_host` is +`never`: the controller does not emit `host-fallback-authorized` and does not +increment host fallback authorizations. Safe exhaustion without an emergency +candidate ends `unavailable` or `blocked`, and that result does not authorize +the host to write the unit. The legacy value `all-unavailable` remains +parseable and likewise grants no host write authority. Cursor uses +`cursor-agent-stable`. `model: auto` lets each CLI use its configured default +model; PI therefore uses the operator-selected MiniMax default. ```agent-execution schema: agent-execution/v1 @@ -19,23 +27,25 @@ schema: agent-execution/v1 execution: fresh_process_per_unit: true permission: run-everything - fallback_to_host: all-unavailable + fallback_to_host: never max_parallel: 3 timeout_seconds: 7200 idle_timeout_seconds: 600 summary_bytes: 16384 raw_output_bytes: 10485760 + max_attempts_per_profile: 3 + emergency_profile: codex roles: implementation: [cursor, antigravity, pi] repair: [cursor, antigravity, pi] test: [pi, antigravity, cursor] - review: [pi, antigravity, cursor] + review: [pi, antigravity, cursor, codex] profiles: cursor: adapter: cursor - executable: cursor-agent + executable: cursor-agent-stable model: auto enabled: true @@ -50,4 +60,10 @@ profiles: executable: pi model: auto enabled: true + + codex: + adapter: codex + executable: codex + model: auto + enabled: true ``` diff --git a/AGENTS.md b/AGENTS.md index c76ed91c..00ac747b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,10 @@ policy/delivery.md。 低影响噪音直接忽略;Review/迭代循环只围绕阻塞项,阻塞项闭合即结束。 - 测试与风险成比例:实施中只跑最小直接检查;完整/长耗时套件仅在最终候选、规范明确要求或 实际影响面确有必要时运行一次。禁止在每个小文档、配置或 adapter 修改后重复全量测试。 -- 编排成本不得超过任务本身。平凡文档/配置单元最多使用一个有界 CLI 进程;除非用户明确要求 - 或已有证据证明是当前目标的直接阻塞,否则不得新增嵌套控制器、额外 worktree、复杂收据体系 - 或新架构。执行路由是手段,不能成为推迟用户产物的理由。 +- 编排成本不得超过任务本身。实施、修复与测试按 AGENT-EXECUTION.md 的候选和尝试上限调度; + 不限制单次 CLI 的推理时长、工具次数、规划方式或首次写文件时间。除非用户明确要求或已有证据 + 证明是当前目标的直接阻塞,否则不得新增嵌套控制器、额外 worktree、复杂收据体系或新架构。 + 执行路由是手段,不能成为推迟用户产物的理由。 ## 任务目标与范围(强制) @@ -41,11 +42,27 @@ policy/delivery.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、 - 证据、平台准入与高后果双钥匙规则。 +- 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准。每个候选对 + 安全可恢复失败(`unavailable`,或外部无副作用已被机制证明的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。 + 单元包的 `network=false`/`external_cost=false` 只是声明,不是证明;没有强制隔离或可信对账时, + 已启动的失败尝试即使私有克隆可精确恢复也记 `failed-unknown`。成功还须从该 CLI 实际事件信封解出 + 有效的 `completed` 终态记录(schema 写在 worker 提示词中);缺失或无效即 fail closed。 + 三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划 + 或独立 Review,不进入这三个正常角色。`fallback_to_host` 为 `never`。安全耗尽不发出 + host-fallback-authorized,终态为 unavailable 或 blocked,不授权 host 撰写该单元。旧值 + `all-unavailable` 仍可解析,同样不产生 host 撰写授权。 +- 显式 fail-stop 立即停止,不重试、不换候选、不启动紧急尝试:`failed-unknown`、`scope-expansion`、 + `blocked`、`cancelled`、无效配置、未对账 Git 状态与安全失败。总超时,以及 stdin/stdout/stderr + 都没有活动的空闲超时,归入 `failed-unknown`。 +- 一次尝试只在显式终态失败、进程退出、总超时或上述空闲超时时结束。CLI 可在目标、范围与安全边界内 + 自主检查、计划、测试并迭代。 +- 实施变更只经成功 transport 由 `scripts/agent_exec.py apply` 集成,或由 pre-commit `admit` 按精确内容 + 做本地完整性核对。本地 HMAC 历史与该钩子只是本地完整性/篡改证据(SELF),不是 GATE、可信身份, + 也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任只来自精确 HEAD 的 CI 或独立 REPLAY。 + host 直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与 + `docs/superpowers/plans/**`。host 不直接撰写实施单元。细则见 + [policy/delivery.md](policy/delivery.md) 与 [policy/governance.md](policy/governance.md)。 + 不削弱 goal-first、严格范围、先可用、独立 Review、证据、平台准入与高后果双钥匙规则。 ### 阶段与会话隔离(强制) diff --git a/Makefile b/Makefile index f1246f76..158dd4df 100644 --- a/Makefile +++ b/Makefile @@ -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 agent-exec-validate +.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 agent-exec-apply agent-exec-admit version: @echo "agent-system $(VERSION)" @@ -78,10 +78,19 @@ help: @echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)" @echo "make status submodule 状态" @echo "make agent-exec-validate 校验 AGENT-EXECUTION.md(agent_exec validate)" + @echo "make agent-exec-apply RECEIPT= 快进当前分支到成功收据的 transport" + @echo "make agent-exec-admit 校验暂存路径具备 transport 来源或属于 host 豁免" agent-exec-validate: PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py validate --repo . +agent-exec-apply: + @if [ -z "$(RECEIPT)" ]; then echo "usage: make agent-exec-apply RECEIPT="; exit 2; fi + PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py apply --repo . --receipt "$(RECEIPT)" + +agent-exec-admit: + PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py admit --repo . + sync: git submodule update --init --recursive diff --git a/policy/adoption.md b/policy/adoption.md index 88377f87..ba4e1388 100644 --- a/policy/adoption.md +++ b/policy/adoption.md @@ -18,7 +18,7 @@ | 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(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | goal-first:目标/范围/终点一次确认,范围内连续推进验证、Review、合入和安全收口;实施写入按 agent-per-unit(配置 AGENT-EXECUTION.md;外部 Agent 一单元一新进程;host 仅在有效 host-fallback-authorized 收据后可写实施变更);仅真实决策点需人工;任务卡提供记录,post-merge 默认只提示,行为回归见 tests/test_task.py | +| policy/delivery.md(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | goal-first:目标/范围/终点一次确认,范围内连续推进验证、Review、合入和安全收口;实施写入按 agent-per-unit(配置 AGENT-EXECUTION.md;实施/修复/测试正常候选仅为 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时只用于规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成须成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施;fallback_to_host 为 never;安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked);仅真实决策点需人工;任务卡提供记录,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 | diff --git a/policy/delivery.md b/policy/delivery.md index ba5067af..e921b171 100644 --- a/policy/delivery.md +++ b/policy/delivery.md @@ -20,8 +20,9 @@ - Markdown、配置、提示词、链接和说明等简单修改默认走最小闭环:定位 → 修改 → 最小直接验证 → 报告。不得为此新建平台、框架、自动化系统、嵌套控制器、额外 worktree、复杂收据体系、 新 PR 模板或新的阶段审批。 -- Agent 路由、任务卡和证据工具都是交付手段,不是交付目标。候选 CLI 迟迟不产出时按配置及时 - 切换或按既有回退规则处理;不得转而长期建设或加固路由系统,导致用户要求的简单产物一直未交付。 +- Agent 路由、任务卡和证据工具都是交付手段,不是交付目标。推理时间、工具次数、规划方式和尚未写文件 + 都不结束一次尝试。尝试只在显式终态失败、进程退出、总超时,或 stdin/stdout/stderr 都没有活动的 + 空闲超时时结束。不得转而长期建设或加固路由系统,导致用户要求的简单产物一直未交付。 ### 最小可用即停,问题按是否阻塞分流 @@ -38,8 +39,9 @@ 本仓规范明确要求、真实影响面覆盖广,或最小测试不足以判断回归的情况。 - 禁止在每个小文档、配置、提示词或 adapter 修改后重复完整套件;已经针对同一候选取得且对象 未变化的有效结果不得因记录、格式或非阻塞意见重复执行。 -- 编排成本不得超过任务本身。平凡单元最多启动一个有界 CLI 进程;主模型负责计划、范围与审查, - 但角色分工不得推迟简单产物。检测到实现粒度与任务不匹配时,立即降级到最小充分做法。 +- 编排成本不得超过任务本身。实施、修复与测试按 AGENT-EXECUTION.md 的候选和每候选最多 3 次全新尝试 + 调度;主模型负责计划、范围与审查,角色分工不得推迟简单产物。检测到实现粒度与任务不匹配时, + 立即降级到最小充分做法。不限制单次 CLI 的推理时长、工具次数、规划方式或首次写文件时间。 - 移除机械人工停顿不等于增加机械 Agent 停顿。真实业务取舍、授权变化、高后果操作和必须由用户 判定的验收仍请求人工;除此之外,不为计划、工具、自审或非阻塞完善反复等待。 @@ -69,7 +71,31 @@ 产生仓库变更的实施工作拆成可独立实施的单元。任务卡默认记 `实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。执行路由以根目录 [AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 为唯一权威;按角色声明的候选顺序在新进程与 -隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程。 +隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程,也不得 resume 同一次尝试。 + +实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败 +最多 3 次全新尝试:`unavailable`,或外部无副作用已被机制证明的 `failed-clean`。三次仍属这类失败后才进入 +下一候选。单元包的 `network=false` 与 `external_cost=false` 只是声明,不证明外部干净;当前没有强制的 +网络/外部费用隔离或可信对账,所以已启动的失败尝试即使私有克隆可精确恢复,也记为 `failed-unknown` +并保留现场。三个正常候选都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试;这次不按每候选 3 次计算。 +紧急尝试若仍是安全可恢复失败,运行停在 `blocked`。host 不接过该单元继续撰写。 + +Codex 平时只用于规划、方案设计、实施计划,或独立 Review。执行配置没有单独的 plan 角色; +上述规划与设计工作可以直接使用 Codex CLI。`review` 角色的候选可以包含 Codex,并按该角色自己的 +顺序和尝试上限执行。Codex 不出现在实施、修复、测试的正常候选里;这三个角色上的一次紧急尝试是唯一例外。 + +显式 fail-stop 立即停止整次运行,不重试、不换候选、不启动紧急尝试:`failed-unknown`、 +`scope-expansion`、`blocked`、`cancelled`、无效配置、未对账的 Git 状态与安全失败。总超时, +以及 stdin/stdout/stderr 都没有活动的空闲超时,归入 `failed-unknown`。 + +一次尝试只在显式终态失败、进程退出、总超时或该空闲超时时结束。推理时长、工具次数、规划方式和 +首次写文件时间都不是结束条件。CLI 可在该单元的目标、范围与安全边界内自主检查、计划、测试并迭代。 + +worker 提示词写明终态记录的 JSON Schema(`terminal_status`、`summary`、`changed_files`、`evidence`、 +`scope_expansion`)。控制器只从该 CLI 实际事件信封解码:Cursor `stream-json` 的 `result` 事件、 +agy `stream-json` 的 `result` 事件或 `json` 输出对象、PI `json` 模式最后一条 assistant `message_end`。 +退出码 0 且有改动时,只有有效的 `completed` 记录才可能成功;记录缺失、无效或仅有散文均记 +`failed-unknown`。`blocked`、`cancelled`、`failed-unknown` 记录直接按该终态停止,不得成功。 主模型(host)负责计划、拆解、有界单元包、强制范围、检查与审阅实际 diff、运行权威验证、 规范提交、管理 PR/CI/合入、写入证据与任务管理元数据,以及安全清理。权威验证、集成、合入与 @@ -82,11 +108,23 @@ `run-everything` 仅在隔离单元内取消逐步命令批准提示;不扩大用户授权范围,也不授予 worker 规范 Git、推送、PR、合入、部署或清理权威。 -当前 host **仅当** `scripts/agent_exec.py run` 对该精确单元发出有效 -`host-fallback-authorized` 收据后,方可直接撰写该单元的仓库变更。全部候选须已安全耗尽 -(`unavailable` 或已精确恢复的 `failed-clean`)。`failed-unknown`、`scope-expansion`、无效配置、 -未对账 Git 状态与安全失败均不授权回退。规划、只读调查、证据捕获、控制器/任务卡元数据与集成 -操作属于 host 管理工作,不递归创建另一实施单元。 +实施结果进入当前分支,必须来自一次成功 transport,由 host 执行 +`scripts/agent_exec.py apply` 在干净分支上快进;或者本次暂存内容通过 pre-commit 调用的 +`admit`:非豁免路径与该成功 transport 的路径和精确内容一致,且 transport 的父提交是当前 HEAD。 +apply/admit 依赖的本地 HMAC 执行历史与 checkout 钩子只是本地完整性/篡改证据(SELF):同一主机 +OS 用户可读取密钥或绕过钩子,因此它不是 GATE、可信身份或防恶意主体的保护。本地记录缺失或无效 +仍拒绝;交付信任只来自精确 HEAD 的必需 CI(GATE)或独立 REPLAY。 +host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/specs/**` 和 +`docs/superpowers/plans/**`。除此之外,host 不直接撰写实施单元。 + +安全耗尽不发出 `host-fallback-authorized`,`host_fallback_authorizations` 保持为 0。 +未配置紧急候选时终态为 unavailable 或 blocked,不授权 host 撰写实施单元。现行 +[AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 的 `fallback_to_host` 是 `never`。旧值 +`all-unavailable` 仍可解析,但同样不产生 host 撰写授权。已归档任务卡里的旧表述保持原样, +不改写成现行规则。 + +规划、只读调查、证据捕获、任务卡与 superpowers 规格/计划,以及 apply 或 admit 通过之后的集成、 +验证、提交和 PR/CI/合入,属于 host 管理工作,不递归创建另一实施单元。 本条不重新引入逐阶段人工门禁;goal-first 的决策点、严格范围、先可用再迭代、独立 Review、 证据、平台准入与高后果双钥匙规则继续有效。 diff --git a/policy/governance.md b/policy/governance.md index 329b82f5..a1f266ec 100644 --- a/policy/governance.md +++ b/policy/governance.md @@ -8,17 +8,29 @@ 智能体审阅者及对象版本;权威验证与合入仍由 host/主模型执行,worker 局部自检不替代。 本地 evidence 仅记录 SELF 命令,不能证明授权/人工决策或提升为 CI/独立审阅证据。 -### Agent 实施收据与回退 +### Agent 实施收据与来源 每次外部实施尝试须在任务卡「Agent 实施收据」记录:配置摘要(`AGENT-EXECUTION.md` digest)、 -角色、声明候选顺序、attempt、CLI/模型、终态、transport、fallback,以及源码外原始收据路径。 -初始生成状态为 `NOT_RUN`。原始事件流与超限输出保留在源码外;未知终态(`failed-unknown`) -与 `scope-expansion` 的隔离工作区须保留待对账,不得静默丢弃。清理须有精确恢复或保留现场的 -可复核证据。 +角色、声明候选顺序、attempt、CLI/模型、终态、transport、fallback(只表示该行是否为 Codex +紧急尝试),以及源码外原始收据路径。初始生成状态为 `NOT_RUN`。原始事件流与超限输出保留在 +源码外;未知终态(`failed-unknown`)与 `scope-expansion` 的隔离工作区须保留待对账,不得静默丢弃。 +清理须有精确恢复或保留现场的可复核证据。 -当前 host 仅在 `scripts/agent_exec.py run` 对该精确单元发出有效 `host-fallback-authorized` -收据后,方可撰写该单元的仓库变更;该收据不得由叙述或局部失败推断。`failed-unknown`、 -`scope-expansion`、无效配置、未对账 Git 状态与安全失败均不授权回退。 +实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败 +最多 3 次全新尝试,全部安全耗尽后才允许一次 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、 +实施计划或独立 Review。`fallback_to_host` 为 `never`。安全耗尽不发出 host-fallback-authorized, +终态为 unavailable 或 blocked,不授权 host 撰写实施单元。显式 fail-stop 立即停止。一次尝试只在显式终态失败、进程退出、总超时, +或 stdin/stdout/stderr 都没有活动的空闲超时时结束;推理时长、工具次数、规划方式和首次写文件时间 +都不是结束条件。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。路由细则见 +[迭代交付](delivery.md)。 + +实施变更进入仓库,只通过成功 transport 的 `scripts/agent_exec.py apply`,或 pre-commit `admit` +对暂存内容的本地完整性核对。本地 HMAC 执行历史与该钩子只记作本地完整性/篡改证据(SELF), +不是 GATE、可信身份,也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任来自精确 HEAD 的 +CI 或独立 REPLAY。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` +与 `docs/superpowers/plans/**`。host 不直接撰写实施单元。`failed-unknown`、`scope-expansion`、 +无效配置、未对账 Git 状态与安全失败都不授权继续路由或代写。`network=false`/`external_cost=false` +声明不证明外部干净,已启动的失败尝试在没有强制隔离或可信对账时记 `failed-unknown`。 ## 启动与恢复 diff --git a/policy/tool-adaptation-matrix.md b/policy/tool-adaptation-matrix.md index fe18d621..296a4135 100644 --- a/policy/tool-adaptation-matrix.md +++ b/policy/tool-adaptation-matrix.md @@ -14,7 +14,7 @@ | 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 阶段记录与迭代 | 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/平台准入 | +| A8 阶段记录与迭代 | goal-first + agent-per-unit(AGENT-EXECUTION.md);模板默认连续执行,仅真实决策点需人工,没有全流程授权强制;实施/修复/测试仅 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时仅规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成经成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施;fallback_to_host 为 never;安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked | policy/delivery.md、AGENT-EXECUTION.md;任务卡分别记录阶段状态、决策点、Agent 实施收据(配置摘要/角色/候选/attempt/CLI-模型/终态/transport/紧急尝试/源码外路径)、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对 goal-first/agent-per-unit 模板、正常路由、尝试上限、fail-stop、空闲超时与 apply/admit 来源表述,及钩子不自动写入;`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` 固定对象、权限、覆盖拒绝、原样导入与幂等 | diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py index be71f6b3..d2aca91e 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -1,19 +1,30 @@ -"""Recoverable agent-exec controller: validate, isolate, supervise, receipt, transport.""" +"""Recoverable agent-exec controller: validate, run, apply transports, admit staged content. + +Local history MACs and the pre-commit ``admit`` check are local integrity and +tamper evidence only (SELF). They are not GATE, not a trusted identity, and do +not protect against the same OS user. Delivery trust comes from exact-HEAD CI +or an independent REPLAY. +""" from __future__ import annotations import argparse +import contextlib +import errno import fcntl import hashlib +import hmac import json import os import re import select import shutil import signal +import stat import subprocess import sys import time import uuid +from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -24,7 +35,11 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) -from scripts.agent_exec_adapters import build_invocation, probe_adapter +from scripts.agent_exec_adapters import ( + build_invocation, + decode_terminal_record, + probe_adapter, +) from scripts.agent_exec_config import ( DEFAULT_IDLE_TIMEOUT_SECONDS, SECRET_VALUE_RE, @@ -47,6 +62,9 @@ EXIT_SCOPE_EXPANSION = 5 EXIT_CANCELLED = 6 EXIT_HOST_FALLBACK = 10 +_EMERGENCY_ROLES = frozenset({"implementation", "repair", "test"}) +_SAFE_EXHAUST_STATUSES = frozenset({"unavailable", "failed-clean"}) + # Transport commits must not depend on machine-global Git identity. TRANSPORT_AUTHOR_NAME = "agent-exec-transport" TRANSPORT_AUTHOR_EMAIL = "agent-exec-transport@localhost.invalid" @@ -149,6 +167,8 @@ class AttemptRecord: workspace_preserved: bool = False transport_ref: str | None = None observed_version: str | None = None + attempt_ordinal: int = 1 + emergency_fallback: bool = False def main(argv: list[str] | None = None) -> int: @@ -164,6 +184,22 @@ def main(argv: list[str] | None = None) -> int: p_run.add_argument("--unit", required=True) p_run.add_argument("--receipt", required=True) + p_apply = sub.add_parser( + "apply", + help="Fast-forward the current branch to a success transport", + ) + p_apply.add_argument("--repo", required=True) + p_apply.add_argument("--receipt", required=True) + + p_admit = sub.add_parser( + "admit", + help=( + "Locally check staged paths against a success transport or host " + "exemption (local integrity evidence, not GATE)" + ), + ) + p_admit.add_argument("--repo", required=True) + args = parser.parse_args(argv) if args.command == "validate": return cmd_validate(Path(args.repo)) @@ -174,6 +210,10 @@ def main(argv: list[str] | None = None) -> int: unit_path=Path(args.unit), receipt_path=Path(args.receipt), ) + if args.command == "apply": + return cmd_apply(Path(args.repo), Path(args.receipt)) + if args.command == "admit": + return cmd_admit(Path(args.repo)) return EXIT_INVALID_CONFIG @@ -193,6 +233,46 @@ def cmd_validate(repo: Path) -> int: return EXIT_SUCCESS +def _apply_attempt_route( + attempt: AttemptRecord, + *, + attempt_id: str, + parent: str, + terminal: str, + reason: str | None, + changed_paths: list[str], + transport: dict[str, Any] | None, +) -> tuple[str, str, str | None, list[str], dict[str, Any] | None]: + status = attempt.terminal_status + if status == "success": + return ( + "stop", + "success", + None, + list(attempt.changed_paths), + { + "ref": attempt.transport_ref, + "parent": parent, + "attempt_id": attempt_id, + }, + ) + if status in _SAFE_EXHAUST_STATUSES: + return ("continue", "unavailable", attempt.reason, changed_paths, transport) + if status == "scope-expansion": + return ( + "stop", + "scope-expansion", + attempt.reason, + list(attempt.changed_paths), + transport, + ) + if status in {"failed-unknown", "blocked"}: + return ("stop", status, attempt.reason, changed_paths, transport) + if status == "cancelled": + return ("stop", "cancelled", attempt.reason, changed_paths, transport) + return ("next-profile", terminal, reason, changed_paths, transport) + + def cmd_run( repo: Path, *, @@ -292,81 +372,86 @@ def cmd_run( host_fallback_authorizations = 0 stop = False + max_attempts = cfg.execution.max_attempts_per_profile for profile_name in profiles: if stop: break profile = cfg.profiles[profile_name] - attempt_id = uuid.uuid4().hex[:12] - attempt = _run_attempt( - repo=repo, - cfg=cfg, - profile=profile, - unit=unit, - unit_path=unit_path, - base=base, - run_id=run_id, - attempt_id=attempt_id, - run_dir=run_dir, - secret_values=secret_values, - ) - attempts.append(attempt) - - if attempt.terminal_status == "success": - terminal = "success" - reason = None - changed_paths = list(attempt.changed_paths) - transport = { - "ref": attempt.transport_ref, - "parent": base["head"], - "attempt_id": attempt_id, - } - stop = True - break - if attempt.terminal_status == "unavailable": - terminal = "unavailable" - reason = attempt.reason - continue - if attempt.terminal_status == "failed-clean": - terminal = "unavailable" - reason = attempt.reason - continue - if attempt.terminal_status == "scope-expansion": - terminal = "scope-expansion" - reason = attempt.reason - changed_paths = list(attempt.changed_paths) - stop = True - break - if attempt.terminal_status in {"failed-unknown", "blocked"}: - terminal = attempt.terminal_status - reason = attempt.reason - stop = True - break - if attempt.terminal_status == "cancelled": - terminal = "cancelled" - reason = attempt.reason - stop = True + for attempt_ordinal in range(1, max_attempts + 1): + attempt_id = uuid.uuid4().hex[:12] + attempt = _run_attempt( + repo=repo, + cfg=cfg, + profile=profile, + unit=unit, + unit_path=unit_path, + base=base, + run_id=run_id, + attempt_id=attempt_id, + run_dir=run_dir, + secret_values=secret_values, + ) + attempt.attempt_ordinal = attempt_ordinal + attempt.emergency_fallback = False + attempts.append(attempt) + action, terminal, reason, changed_paths, transport = _apply_attempt_route( + attempt, + attempt_id=attempt_id, + parent=base["head"], + terminal=terminal, + reason=reason, + changed_paths=changed_paths, + transport=transport, + ) + if action == "continue": + continue + stop = action == "stop" break exhausted_ok = ( not stop and attempts - and all(a.terminal_status in {"unavailable", "failed-clean"} for a in attempts) - and len(attempts) == len(profiles) + and all(a.terminal_status in _SAFE_EXHAUST_STATUSES for a in attempts) + and len(attempts) == len(profiles) * max_attempts ) - if exhausted_ok and cfg.execution.fallback_to_host == "all-unavailable": - terminal = "host-fallback-authorized" - host_fallback_authorizations = 1 - host_scope = { - "allowed_paths": list(unit.allowed_paths), - "unit_id": unit.unit_id, - "goal": unit.goal, - "verification": list(unit.verification), - "constraints": list(unit.constraints), - "network": unit.network, - "external_cost": unit.external_cost, - } - reason = "all configured profiles exhausted with unavailable or failed-clean" + emergency_name = cfg.execution.emergency_profile + if exhausted_ok and emergency_name and role in _EMERGENCY_ROLES: + emergency_profile = cfg.profiles.get(emergency_name) + if emergency_profile is None: + terminal = "blocked" + reason = f"emergency profile {emergency_name!r} is not configured" + else: + attempt_id = uuid.uuid4().hex[:12] + attempt = _run_attempt( + repo=repo, + cfg=cfg, + profile=emergency_profile, + unit=unit, + unit_path=unit_path, + base=base, + run_id=run_id, + attempt_id=attempt_id, + run_dir=run_dir, + secret_values=secret_values, + ) + attempt.attempt_ordinal = 1 + attempt.emergency_fallback = True + attempts.append(attempt) + action, terminal, reason, changed_paths, transport = _apply_attempt_route( + attempt, + attempt_id=attempt_id, + parent=base["head"], + terminal=terminal, + reason=reason, + changed_paths=changed_paths, + transport=transport, + ) + if action == "continue": + terminal = "blocked" + reason = attempt.reason + # No host write authority after exhaustion. Emergency safe-failure is + # `blocked`; exhaustion without an emergency candidate stays `unavailable`. summary = _bounded_summary( _build_run_summary(terminal, attempts, reason), cfg.execution.summary_bytes, @@ -719,7 +804,7 @@ def _run_attempt( pointer_delta = _snapshot_delta(pointer_before, pointer_after) canonical_delta = _snapshot_delta(canonical_before, canonical_after) - worker_result = _load_worker_result(result_path, proc_meta["stdout_tail"]) + worker_result = _load_worker_result(profile.adapter, result_path, proc_meta) summary = _bounded_summary( proc_meta.get("summary_source") or (worker_result or {}).get("summary") or "", cfg.execution.summary_bytes, @@ -840,7 +925,27 @@ def _run_attempt( ) clean_exit = proc_meta["exit_code"] == 0 - # Never report success from worker prose alone — require exit + admin + in-scope diff + transport. + # Worker fail-stop terminals dominate success and no-change classification. + # A zero exit or an in-scope diff must not launder blocked/cancelled/unknown. + worker_stop = _worker_fail_stop_status(worker_result) + if worker_stop: + return _preserved( + worker_stop, + f"worker terminal_status {worker_stop}", + cleanup_extra={"reason": "worker fail-stop terminal"}, + ) + + completed = bool( + worker_result and worker_result.get("terminal_status") == "completed" + ) + if clean_exit and changed and not completed: + return _preserved( + "failed-unknown", + "worker exited 0 with changes but no valid completed terminal record", + cleanup_extra={"reason": "missing or invalid terminal record"}, + ) + + # Success requires exit 0 + completed terminal record + clean admin + in-scope diff + transport. if clean_exit and changed and not out_of_scope: # Recheck path bounds immediately before host-owned transport. try: @@ -893,95 +998,23 @@ def _run_attempt( observed_version=probe.observed_version, ) - if clean_exit and not changed: - restored = _restore_worktree(repo, worktree, base["head"]) - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="failed-clean", - reason="worker exited 0 without in-scope changes", - worktree=str(worktree), - pid=proc_meta["pid"], - exit_code=proc_meta["exit_code"], - changed_paths=[], - inventory_paths=[], - raw_log=str(raw_log), - summary=summary, - cleanup={"exact_restore": restored}, - observed_version=probe.observed_version, - ) - - # Nonzero / aborted without trustworthy external reconciliation. - if _external_side_effects_unproven(unit, worker_result): - return _preserved( - "failed-unknown", - ( - f"worker failed with exit {proc_meta['exit_code']}; " - "external side effects cannot be reconciled" - ), - cleanup_extra={ - "reason": "network or external_cost authorized without trustworthy receipt", - "network": unit.network, - "external_cost": unit.external_cost, - }, - ) - - # Controlled case: network=false and external_cost=false may become failed-clean - # only after exact worktree restoration proves no unknown authorized external object. - if changed: - restored = _restore_worktree(repo, worktree, base["head"]) - if restored: - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="failed-clean", - reason=f"worker failed with exit {proc_meta['exit_code']}; restored", - worktree=str(worktree), - pid=proc_meta["pid"], - exit_code=proc_meta["exit_code"], - changed_paths=changed, - inventory_paths=list(changed), - raw_log=str(raw_log), - summary=summary, - cleanup={ - "exact_restore": True, - "external_objects_reconciled": True, - "network": False, - "external_cost": False, - }, - observed_version=probe.observed_version, - ) - return _preserved( - "failed-unknown", - "worker failed; exact restore not proven", - ) - - restored = _restore_worktree(repo, worktree, base["head"]) - return AttemptRecord( - attempt_id=attempt_id, - profile=profile.name, - adapter=profile.adapter, - terminal_status="failed-clean", - reason=f"worker failed with exit {proc_meta['exit_code']}", - worktree=str(worktree), - pid=proc_meta["pid"], - exit_code=proc_meta["exit_code"], - changed_paths=[], - inventory_paths=[], - raw_log=str(raw_log), - summary=summary, - cleanup={ - "exact_restore": restored, - "external_objects_reconciled": True, - "network": False, - "external_cost": False, + # Launched attempts have no enforced network or external-cost isolation, and + # the unit's declared network/external_cost booleans are not proof. A + # restored private clone therefore cannot make a launched failure clean. + if clean_exit: + reason = "worker exited 0 without in-scope changes" + else: + reason = f"worker failed with exit {proc_meta['exit_code']}" + return _preserved( + "failed-unknown", + f"{reason}; external side effects are not mechanically reconciled", + cleanup_extra={ + "reason": "external side effects not mechanically reconciled", + "declared_network": unit.network, + "declared_external_cost": unit.external_cost, }, - observed_version=probe.observed_version, ) - def _supervise( argv: tuple[str, ...], *, @@ -1005,7 +1038,7 @@ def _supervise( exit_code: int | None = None sig: int | None = None pid: int | None = None - stdout_tail = _ByteTail(OUTPUT_TAIL_BYTES) + stdout_tail = _StdoutTail(OUTPUT_TAIL_BYTES) stderr_tail = _ByteTail(OUTPUT_TAIL_BYTES) # Bounded prefix: raw_cap + exact-secret lookahead. Redact the complete # prefix once, write at most raw_cap, then discard further drained bytes. @@ -1278,6 +1311,7 @@ def _supervise( "signal": sig, "stdout_tail": stdout_text, "stderr_tail": stderr_text, + "terminal_lines": stdout_tail.terminal_lines(), "summary_source": summary_source, } @@ -1300,6 +1334,74 @@ class _ByteTail: return bytes(self._data).decode("utf-8", "replace") +TERMINAL_LINE_MAX_BYTES = 1 << 20 +TERMINAL_LINE_KEEP = 32 +_TERMINAL_ENVELOPE_RE = re.compile( + rb'"(?:type|event)"\s*:\s*"(?:result|message_end)"' +) +_TERMINAL_HINT_RE = re.compile(rb'terminal_status|"is_error"') + + +class _StdoutTail(_ByteTail): + """Byte tail that also keeps recent complete lines that may be terminal envelopes. + + A final envelope can sit behind a later event larger than the byte tail + (for example PI ``agent_end``), so candidate lines are kept separately. + An oversized envelope line is recorded as ``None`` so decoding fails closed. + """ + + def __init__(self, limit: int) -> None: + super().__init__(limit) + self._line = bytearray() + self._overflow_head: bytes | None = None + self._lines: list[bytes | None] = [] + + def extend(self, chunk: bytes) -> None: + super().extend(chunk) + start = 0 + while start <= len(chunk): + nl = chunk.find(b"\n", start) + piece = chunk[start:] if nl == -1 else chunk[start:nl] + self._append_piece(piece) + if nl == -1: + return + self._end_line() + start = nl + 1 + + def _append_piece(self, piece: bytes) -> None: + if self._overflow_head is not None: + return + if len(self._line) + len(piece) > TERMINAL_LINE_MAX_BYTES: + self._overflow_head = bytes(self._line[:256] + piece[:256])[:256] + self._line.clear() + return + self._line.extend(piece) + + def _end_line(self) -> None: + if self._overflow_head is not None: + if _TERMINAL_ENVELOPE_RE.search(self._overflow_head): + self._keep(None) + self._overflow_head = None + return + line = bytes(self._line) + self._line.clear() + if _TERMINAL_ENVELOPE_RE.search(line) or _TERMINAL_HINT_RE.search(line): + self._keep(line) + + def _keep(self, line: bytes | None) -> None: + self._lines.append(line) + if len(self._lines) > TERMINAL_LINE_KEEP: + del self._lines[0] + + def terminal_lines(self) -> list[str | None]: + if self._line or self._overflow_head is not None: + self._end_line() + return [ + None if line is None else line.decode("utf-8", "replace") + for line in self._lines + ] + + def _kill_process_group(pid: int) -> None: try: os.killpg(pid, signal.SIGKILL) @@ -1325,6 +1427,42 @@ def _filtered_env() -> dict[str, str]: return out +_WORKER_FAIL_STOP = frozenset({"blocked", "cancelled", "failed-unknown"}) + + +def _worker_fail_stop_status(worker_result: dict[str, Any] | None) -> str | None: + """Return a worker terminal that must fail-stop before success or no-change.""" + if not isinstance(worker_result, dict): + return None + raw = worker_result.get("terminal_status") + if not isinstance(raw, str): + return None + status = raw.strip().replace("_", "-") + if status in _WORKER_FAIL_STOP: + return status + return None + + +def _parse_name_status(text: str) -> list[tuple[str, tuple[str, ...]]]: + """Parse git name-status text into ``(status, paths)`` records. + + Rename (``R``) and copy (``C``) records keep both the source and the + destination. Keeping only the destination would hide deletion of an + out-of-scope or non-exempt source that was moved into an allowed path. + """ + records: list[tuple[str, tuple[str, ...]]] = [] + for line in text.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status = parts[0] + if status[:1] in {"R", "C"} and len(parts) >= 3: + records.append((status, (parts[1], parts[2]))) + elif len(parts) >= 2: + records.append((status, (parts[1],))) + return records + + def _inventory_changes(worktree: Path) -> dict[str, Any]: """Inventory tracked, staged, untracked, deleted, renamed, mode, and binary changes. @@ -1334,23 +1472,20 @@ def _inventory_changes(worktree: Path) -> dict[str, Any]: details: list[dict[str, Any]] = [] def _consume_name_status(text: str, *, prefix: str) -> None: - for line in text.splitlines(): - if not line.strip(): - continue - parts = line.split("\t") - status = parts[0] - if status.startswith("R") and len(parts) >= 3: - paths.append(parts[2]) + for status, rec_paths in _parse_name_status(text): + paths.extend(rec_paths) + kind = status[:1] + if kind in {"R", "C"} and len(rec_paths) == 2: + label = "renamed" if kind == "R" else "copied" details.append( { - "status": f"{prefix}renamed", - "from": parts[1], - "path": parts[2], + "status": f"{prefix}{label}", + "from": rec_paths[0], + "path": rec_paths[1], } ) - elif len(parts) >= 2: - paths.append(parts[1]) - details.append({"status": f"{prefix}{status[:1]}", "path": parts[1]}) + else: + details.append({"status": f"{prefix}{kind}", "path": rec_paths[0]}) # Explicit staged inventory (index vs HEAD) — fail-visible, never silent. staged = _git( @@ -1554,19 +1689,6 @@ def _worktree_pointer_snapshot( return snap -def _external_side_effects_unproven( - unit: UnitPacket, worker_result: dict[str, Any] | None -) -> bool: - """Nonzero/aborted workers cannot be failed-clean when external effects are open. - - failed-clean requires exact restore plus affirmative evidence that no authorized - external object remains unknown. Units with network/external_cost, or without a - trustworthy structured terminal receipt proving reconciliation, stay unknown. - """ - del worker_result # Worker prose alone never proves external reconciliation. - return bool(unit.network or unit.external_cost) - - def _is_within_root(path: Path, root: Path) -> bool: try: path.resolve().relative_to(root.resolve()) @@ -1676,20 +1798,6 @@ def _create_transport_commit( return ref -def _restore_worktree(repo: Path, worktree: Path, base_head: str) -> bool: - """Exact-restore the private clone then remove it after verification.""" - try: - _git(worktree, "reset", "--hard", base_head) - _git(worktree, "clean", "-fdx") - head = _git(worktree, "rev-parse", "HEAD").stdout.strip() - dirty = _git(worktree, "status", "--porcelain").stdout.strip() - ok = head == base_head and not dirty - _safe_remove_worktree(repo, worktree) - return ok - except Exception: - return False - - def _safe_remove_worktree(repo: Path, worktree: Path) -> None: """Remove a private attempt clone directory (not a linked worktree).""" del repo # Private clones are not registered linked worktrees. @@ -1698,29 +1806,21 @@ def _safe_remove_worktree(repo: Path, worktree: Path) -> None: def _load_worker_result( - result_path: Path, stdout_tail: str + adapter: str, result_path: Path, proc_meta: dict[str, Any] ) -> dict[str, Any] | None: + """Return the worker's valid terminal record, or None when absent/invalid.""" + result_text: str | None = None if result_path.is_file(): try: - data = json.loads(result_path.read_text(encoding="utf-8")) - if isinstance(data, dict): - return data - except (OSError, json.JSONDecodeError): - pass - # Try last JSON object in stdout. - text = stdout_tail.strip() - if not text: - return None - for candidate in (text, text[text.rfind("{") :] if "{" in text else ""): - if not candidate.startswith("{"): - continue - try: - data = json.loads(candidate) - if isinstance(data, dict): - return data - except json.JSONDecodeError: - continue - return None + result_text = result_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + result_text = None + return decode_terminal_record( + adapter, + event_lines=proc_meta.get("terminal_lines") or [], + stdout_tail=proc_meta.get("stdout_tail") or "", + result_text=result_text, + ) def _base_receipt( @@ -1742,6 +1842,8 @@ def _base_receipt( "attempt_id": a.attempt_id, "profile": a.profile, "adapter": a.adapter, + "attempt_ordinal": a.attempt_ordinal, + "emergency_fallback": a.emergency_fallback, "terminal_status": a.terminal_status, "reason": a.reason, "worktree": a.worktree, @@ -1814,11 +1916,18 @@ def _finish_run( ) history_path: Path | None = None try: - history_path = _append_execution_history(repo, receipt, secret_values) - receipt["history"] = { + history_path, integrity_mac = _append_execution_history( + repo, receipt, secret_values + ) + recorded: dict[str, Any] = { "path": str(history_path), "status": "recorded", + "record_id": receipt.get("run_id"), + "run_id": receipt.get("run_id"), } + if integrity_mac: + recorded["local_integrity_mac"] = integrity_mac + receipt["history"] = recorded except Exception as exc: receipt["history"] = { "path": str(history_path) if history_path is not None else None, @@ -1838,32 +1947,32 @@ def _append_execution_history( repo: Path, receipt: dict[str, Any], secret_values: set[str], -) -> Path: - common = _git( - repo.resolve(), - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - check=False, - ) - if common.returncode != 0 or not common.stdout.strip(): - raise RuntimeError( - "unable to resolve git common dir: " - + (common.stderr.strip() or common.stdout.strip()) - ) - common_dir = Path(common.stdout.strip()) - if not common_dir.is_absolute(): - common_dir = (repo.resolve() / common_dir).resolve() - history_dir = common_dir / "agent-exec" +) -> tuple[Path, str | None]: + repo = repo.resolve() + history_dir = _git_common_dir(repo) / "agent-exec" if history_dir.is_symlink(): raise RuntimeError("agent-exec history directory must not be a symlink") history_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + if history_dir.is_symlink() or not history_dir.is_dir(): + raise RuntimeError("agent-exec history directory must not be a symlink") os.chmod(history_dir, 0o700) history_path = history_dir / "execution-history.jsonl" - record = _execution_history_record(repo, receipt) - line = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - line = _redact_text(line, secret_values) + "\n" - fd = os.open(history_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + if history_path.is_symlink(): + raise RuntimeError("agent-exec history file must not be a symlink") + integrity_mac: str | None = None + if receipt.get("terminal_status") == "success": + _bind_success_transport_commit(repo, receipt) + key = _ensure_local_integrity_key(repo) + record = _execution_history_record(repo, receipt) + line, integrity_mac = _integrity_history_line(record, key, secret_values) + else: + record = _execution_history_record(repo, receipt) + line = _redact_text(_canonical_json(record), secret_values) + "\n" + fd = os.open( + history_path, + os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, + ) try: os.fchmod(fd, 0o600) with os.fdopen(fd, "a", encoding="utf-8", closefd=False) as handle: @@ -1876,7 +1985,7 @@ def _append_execution_history( fcntl.flock(fd, fcntl.LOCK_UN) finally: os.close(fd) - return history_path + return history_path, integrity_mac def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, Any]: @@ -1894,6 +2003,8 @@ def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, "attempt_id": attempt.get("attempt_id"), "profile": profile_name, "adapter": attempt.get("adapter"), + "attempt_ordinal": attempt.get("attempt_ordinal"), + "emergency_fallback": bool(attempt.get("emergency_fallback")), "observed_version": attempt.get("observed_version"), "model_requested": requested or "auto", "model_observed": "not-observable", @@ -1914,6 +2025,9 @@ def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, "ref": transport.get("ref"), "parent": transport.get("parent"), } + commit = transport.get("commit") + if isinstance(commit, str) and commit: + safe_transport["commit"] = commit terminal = receipt.get("terminal_status") return { "schema": HISTORY_SCHEMA, @@ -2045,5 +2159,581 @@ def _cap_bytes(data: bytes, limit: int) -> bytes: return data[:limit] +# Host may commit task cards and superpowers docs without a worker transport. +_HOST_MANAGED_PREFIXES = ( + ".task/", + "docs/superpowers/specs/", + "docs/superpowers/plans/", +) +_EMBEDDED_SECRET_RE = re.compile(r"(?i)(?:bearer\s+\S+|sk-[A-Za-z0-9_\-]{8,})") + + +def _reject(message: str, secrets: set[str], code: int = EXIT_BLOCKED) -> int: + print(_redact_text(message, secrets), file=sys.stderr) + return code + + +def _secret_set(*texts: str) -> set[str]: + found: set[str] = set() + _collect_secret_values(found) + for text in texts: + found.update(_EMBEDDED_SECRET_RE.findall(text)) + return found + + +def _normalize_repo_path(path: str) -> str: + """Normalize a repo-relative path without eating dotted names such as ``.task``. + + Leading/trailing whitespace is part of a valid Git path and is preserved. + """ + norm = path.replace("\\", "/") + while norm.startswith("./"): + norm = norm[2:] + return norm + + +def _host_managed(path: str) -> bool: + norm = _normalize_repo_path(path) + if not norm or ".." in Path(norm).parts: + return False + return any(norm.startswith(prefix) for prefix in _HOST_MANAGED_PREFIXES) + + +def _non_exempt(paths: list[str]) -> set[str]: + return {_normalize_repo_path(path) for path in paths if not _host_managed(path)} + + +def _same_repo(repo: Path, raw: object) -> bool: + if not isinstance(raw, str) or not raw.strip(): + return False + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = repo / candidate + try: + return candidate.resolve() == repo.resolve() + except OSError: + return False + + +def _resolve_commit(repo: Path, rev: str) -> str | None: + if not rev or rev.startswith("-") or "\n" in rev or "\x00" in rev: + return None + proc = _git(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}", check=False) + if proc.returncode != 0: + return None + resolved = proc.stdout.strip() + return resolved or None + + +def _commit_parents(repo: Path, commit: str) -> list[str] | None: + proc = _git(repo, "rev-list", "--parents", "-n", "1", commit, check=False) + if proc.returncode != 0: + return None + parts = proc.stdout.split() + if not parts or parts[0] != commit: + return None + return parts[1:] + + +def _name_status_paths(text: str) -> list[str]: + paths: list[str] = [] + for _status, rec_paths in _parse_name_status(text): + paths.extend(rec_paths) + return paths + + +def _diff_paths(repo: Path, parent: str, commit: str) -> list[str]: + proc = _git( + repo, + "diff", + "--name-status", + "--find-renames", + parent, + commit, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "transport diff failed") + return _name_status_paths(proc.stdout) + + +def _staged_paths(repo: Path) -> list[str]: + proc = _git( + repo, + "diff", + "--cached", + "--name-status", + "--find-renames", + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "staged diff failed") + return _name_status_paths(proc.stdout) + + +def _index_matches_commit(repo: Path, commit: str, paths: set[str]) -> bool: + """True when each named index path has the same content as commit.""" + if not paths: + return True + proc = _git( + repo, + "diff", + "--cached", + "--quiet", + commit, + "--", + *sorted(paths), + check=False, + ) + return proc.returncode == 0 + + +def _valid_transport_ref(ref: object) -> str | None: + if not isinstance(ref, str) or not ref.startswith("refs/agent-exec/"): + return None + if ".." in ref.split("/") or ref.endswith("/"): + return None + return ref + + +# Legacy on-disk names keep existing local history readable. The MAC is local +# integrity/tamper evidence only: any process running as the same OS user can +# read the key and forge records, so it is never GATE or a trusted identity. +_LOCAL_INTEGRITY_KEY_NAME = "attestation.key" +_LOCAL_INTEGRITY_FIELD = "attestation" +_LOCAL_INTEGRITY_KEY_BYTES = 32 + + +def _git_common_dir(repo: Path) -> Path: + repo = repo.resolve() + common = _git( + repo, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + check=False, + ) + if common.returncode != 0 or not common.stdout.strip(): + raise RuntimeError( + "unable to resolve git common dir: " + + (common.stderr.strip() or common.stdout.strip() or "empty") + ) + common_dir = Path(common.stdout.strip()) + if not common_dir.is_absolute(): + common_dir = (repo / common_dir).resolve() + return common_dir + + +def _canonical_json(data: dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _local_integrity_mac(key: bytes, record: dict[str, Any]) -> str: + body = {field: value for field, value in record.items() if field != _LOCAL_INTEGRITY_FIELD} + return hmac.new(key, _canonical_json(body).encode("utf-8"), hashlib.sha256).hexdigest() + + +def _local_integrity_ok(key: bytes, record: dict[str, Any]) -> bool: + presented = record.get(_LOCAL_INTEGRITY_FIELD) + if not isinstance(presented, str) or len(presented) != 64: + return False + return hmac.compare_digest(_local_integrity_mac(key, record), presented) + + +def _integrity_history_line( + record: dict[str, Any], key: bytes, secret_values: set[str] +) -> tuple[str, str]: + text = _redact_text(_canonical_json(record), secret_values) + body = json.loads(text) + if not isinstance(body, dict): + raise RuntimeError("history record redaction produced non-object JSON") + mac = _local_integrity_mac(key, body) + body[_LOCAL_INTEGRITY_FIELD] = mac + return _canonical_json(body) + "\n", mac + + +def _bind_success_transport_commit(repo: Path, receipt: dict[str, Any]) -> None: + transport = receipt.get("transport") + if not isinstance(transport, dict): + raise RuntimeError("success history requires transport") + ref = transport.get("ref") + parent = transport.get("parent") + if not isinstance(ref, str) or not isinstance(parent, str): + raise RuntimeError("success history requires transport ref and parent") + commit = _resolve_commit(repo, ref) + resolved_parent = _resolve_commit(repo, parent) + if commit is None or resolved_parent is None: + raise RuntimeError("success transport ref did not resolve") + if _commit_parents(repo, commit) != [resolved_parent]: + raise RuntimeError("success transport parent does not match the commit") + transport["commit"] = commit + transport["parent"] = resolved_parent + + +def _symlink_oserror(exc: OSError, message: str) -> OSError | RuntimeError: + if exc.errno == errno.ELOOP: + return RuntimeError(message) + return exc + + +@contextlib.contextmanager +def _locked_agent_exec_dir(repo: Path, *, create: bool) -> Iterator[int | None]: + """Lock the real agent-exec directory. Missing dirs yield None unless creating. + + Callers share this lock so the first key write cannot race another reader. + """ + history_dir = _git_common_dir(repo) / "agent-exec" + if history_dir.is_symlink(): + raise RuntimeError("agent-exec history directory must not be a symlink") + if not create and not history_dir.exists(): + yield None + return + if create: + history_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + if history_dir.is_symlink() or not history_dir.is_dir(): + raise RuntimeError("agent-exec history directory must not be a symlink") + os.chmod(history_dir, 0o700) + elif not history_dir.is_dir(): + raise RuntimeError("agent-exec history directory must not be a symlink") + try: + fd = os.open( + history_dir, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + ) + except OSError as exc: + raise _symlink_oserror( + exc, "agent-exec history directory must not be a symlink" + ) from exc + try: + info = os.fstat(fd) + if not stat.S_ISDIR(info.st_mode): + raise RuntimeError("agent-exec history directory must not be a symlink") + if stat.S_IMODE(info.st_mode) != 0o700: + raise RuntimeError("agent-exec history directory mode must be 0700") + fcntl.flock(fd, fcntl.LOCK_EX) + try: + yield fd + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def _read_key_at(dir_fd: int) -> bytes: + fd = os.open( + _LOCAL_INTEGRITY_KEY_NAME, + os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=dir_fd, + ) + try: + current = os.fstat(fd) + if not stat.S_ISREG(current.st_mode): + raise RuntimeError("local integrity key must be a regular file") + if stat.S_IMODE(current.st_mode) != 0o600: + raise RuntimeError("local integrity key mode must be 0600") + if current.st_size != _LOCAL_INTEGRITY_KEY_BYTES: + raise RuntimeError("local integrity key must be 32 bytes") + os.lseek(fd, 0, os.SEEK_SET) + key = os.read(fd, _LOCAL_INTEGRITY_KEY_BYTES) + if len(key) != _LOCAL_INTEGRITY_KEY_BYTES: + raise RuntimeError("local integrity key must be 32 bytes") + return key + finally: + os.close(fd) + + +def _key_is_symlink(dir_fd: int) -> bool: + try: + info = os.stat(_LOCAL_INTEGRITY_KEY_NAME, dir_fd=dir_fd, follow_symlinks=False) + except FileNotFoundError: + return False + return stat.S_ISLNK(info.st_mode) + + +def _read_local_integrity_key(repo: Path) -> bytes | None: + """Read the local integrity key. Never creates one and never follows links.""" + with _locked_agent_exec_dir(repo, create=False) as dir_fd: + if dir_fd is None: + return None + if _key_is_symlink(dir_fd): + raise RuntimeError("local integrity key must not be a symlink") + try: + return _read_key_at(dir_fd) + except FileNotFoundError: + return None + except OSError as exc: + raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc + + +def _ensure_local_integrity_key(repo: Path) -> bytes: + """Create or read the 32-byte key under the Git common dir. + + The MAC lets later apply/admit checks detect accidental or casual edits of + local history. It is not an identity: the same OS user can read the key. + """ + with _locked_agent_exec_dir(repo, create=True) as dir_fd: + if dir_fd is None: + raise RuntimeError("agent-exec history directory is unavailable") + if _key_is_symlink(dir_fd): + raise RuntimeError("local integrity key must not be a symlink") + flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC + try: + fd = os.open(_LOCAL_INTEGRITY_KEY_NAME, flags, 0o600, dir_fd=dir_fd) + except OSError as exc: + raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc + try: + os.fchmod(fd, 0o600) + current = os.fstat(fd) + if not stat.S_ISREG(current.st_mode): + raise RuntimeError("local integrity key must be a regular file") + if current.st_size == 0: + key = os.urandom(_LOCAL_INTEGRITY_KEY_BYTES) + written = os.write(fd, key) + if written != _LOCAL_INTEGRITY_KEY_BYTES: + raise RuntimeError("local integrity key write was short") + os.fsync(fd) + return key + if current.st_size != _LOCAL_INTEGRITY_KEY_BYTES: + raise RuntimeError("local integrity key must be 32 bytes") + os.lseek(fd, 0, os.SEEK_SET) + key = os.read(fd, _LOCAL_INTEGRITY_KEY_BYTES) + if len(key) != _LOCAL_INTEGRITY_KEY_BYTES: + raise RuntimeError("local integrity key must be 32 bytes") + if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600: + raise RuntimeError("local integrity key mode must be 0600") + return key + finally: + os.close(fd) + + +def _history_file(repo: Path) -> Path | None: + history_dir = _git_common_dir(repo) / "agent-exec" + history_path = history_dir / "execution-history.jsonl" + if history_dir.is_symlink() or history_path.is_symlink(): + return None + return history_path + + +def _load_history_records(repo: Path) -> list[dict[str, Any]]: + history_path = _history_file(repo) + if history_path is None or not history_path.is_file(): + return [] + records: list[dict[str, Any]] = [] + for line in history_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(data, dict): + records.append(data) + return records + + +def _transport_covers( + repo: Path, + record: dict[str, Any], + *, + head: str, + required: set[str], + key: bytes | None, +) -> bool: + if key is None or not _local_integrity_ok(key, record): + return False + if record.get("schema") != HISTORY_SCHEMA: + return False + if record.get("terminal_status") != "success": + return False + if not _same_repo(repo, record.get("repo")): + return False + transport = record.get("transport") + if not isinstance(transport, dict): + return False + ref = _valid_transport_ref(transport.get("ref")) + parent = transport.get("parent") + recorded_commit = transport.get("commit") + if ref is None or not isinstance(parent, str) or not isinstance(recorded_commit, str): + return False + if _resolve_commit(repo, ref) != recorded_commit: + return False + if _resolve_commit(repo, parent) != head: + return False + if _commit_parents(repo, recorded_commit) != [head]: + return False + changed = record.get("changed_paths") + if not isinstance(changed, list) or any(not isinstance(path, str) for path in changed): + return False + diff_paths = _diff_paths(repo, head, recorded_commit) + if _non_exempt(changed) != required or _non_exempt(diff_paths) != required: + return False + return _index_matches_commit(repo, recorded_commit, required) + + +def _apply_local_integrity_ok( + repo: Path, + receipt: dict[str, Any], + *, + commit: str, + base_head: str, +) -> bool: + key = _read_local_integrity_key(repo) + if key is None: + return False + run_id = receipt.get("run_id") + transport = receipt.get("transport") + changed = receipt.get("changed_paths") + if not isinstance(run_id, str) or not run_id or not isinstance(transport, dict): + return False + ref = transport.get("ref") + parent = transport.get("parent") + claimed = transport.get("commit") + if not isinstance(ref, str) or parent != base_head or claimed != commit: + return False + if _resolve_commit(repo, ref) != commit: + return False + if _commit_parents(repo, commit) != [base_head]: + return False + if not isinstance(changed, list) or any(not isinstance(path, str) for path in changed): + return False + if _non_exempt(changed) != _non_exempt(_diff_paths(repo, base_head, commit)): + return False + for record in _load_history_records(repo): + if record.get("run_id") != run_id or record.get("terminal_status") != "success": + continue + if not _same_repo(repo, record.get("repo")): + continue + record_paths = record.get("changed_paths") + if record_paths != changed: + continue + rec_transport = record.get("transport") + if not isinstance(rec_transport, dict): + continue + if rec_transport.get("ref") != ref or rec_transport.get("parent") != parent: + continue + if rec_transport.get("commit") != commit: + continue + if _local_integrity_ok(key, record): + return True + return False + + +def cmd_apply(repo: Path, receipt_path: Path) -> int: + """Fast-forward the clean current branch onto one success transport.""" + repo = repo.resolve() + text = "" + try: + text = receipt_path.read_text(encoding="utf-8") + receipt = json.loads(text) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + return _reject( + f"apply: invalid receipt: {exc}", + _secret_set(text), + EXIT_INVALID_CONFIG, + ) + secrets = _secret_set(text) + if not isinstance(receipt, dict) or receipt.get("schema") != RECEIPT_SCHEMA: + return _reject("apply: invalid receipt", secrets, EXIT_INVALID_CONFIG) + if not _same_repo(repo, receipt.get("repo")): + return _reject("apply: receipt repo mismatch", secrets) + if receipt.get("terminal_status") != "success": + message = "apply: non-success receipt" + reason = receipt.get("reason") + if isinstance(reason, str) and reason: + message = f"{message}: {reason}" + return _reject(message, secrets) + + base = receipt.get("base") + base_head = base.get("head") if isinstance(base, dict) else None + if not isinstance(base_head, str): + return _reject("apply: stale HEAD", secrets) + resolved_base = _resolve_commit(repo, base_head) + head = _resolve_commit(repo, "HEAD") + status = _git( + repo, "status", "--porcelain", "--untracked-files=all", check=False + ) + if status.returncode != 0: + return _reject( + f"apply: dirty worktree: {status.stderr.strip()}", + secrets, + ) + if status.stdout.strip(): + return _reject("apply: dirty worktree", secrets) + branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD", check=False) + if branch.returncode != 0 or branch.stdout.strip() in {"", "HEAD"}: + return _reject("apply: detached HEAD", secrets) + if head is None or resolved_base is None or head != resolved_base: + return _reject("apply: stale HEAD", secrets) + + transport = receipt.get("transport") + if not isinstance(transport, dict): + return _reject("apply: missing transport", secrets) + ref = _valid_transport_ref(transport.get("ref")) + parent = transport.get("parent") + if ref is None or not isinstance(parent, str) or not parent: + return _reject("apply: missing transport", secrets) + commit = _resolve_commit(repo, ref) + if commit is None: + return _reject("apply: missing transport", secrets) + if ( + _resolve_commit(repo, parent) != resolved_base + or _commit_parents(repo, commit) != [resolved_base] + ): + return _reject("apply: mismatched transport", secrets) + + try: + authorized = _apply_local_integrity_ok( + repo, receipt, commit=commit, base_head=resolved_base + ) + except Exception as exc: + return _reject(f"apply: no local integrity record: {exc}", secrets) + if not authorized: + return _reject("apply: no local integrity record", secrets) + + merge = _git(repo, "merge", "--ff-only", "--quiet", commit, check=False) + if merge.returncode != 0: + return _reject( + "apply: fast-forward failed: " + (merge.stderr.strip() or merge.stdout.strip()), + secrets, + ) + print(f"apply: fast-forwarded {branch.stdout.strip()} {head}..{commit}") + return EXIT_SUCCESS + + +def cmd_admit(repo: Path) -> int: + """Accept staged host-managed paths, or paths exactly covered by one transport. + + A pass is local integrity evidence (SELF), not GATE; invalid or missing + local records still reject. + """ + repo = repo.resolve() + secrets = _secret_set() + try: + staged = _staged_paths(repo) + required = _non_exempt(staged) + if not required: + print("admit: ok") + return EXIT_SUCCESS + head = _resolve_commit(repo, "HEAD") + if head is None: + return _reject("admit: no local integrity record", secrets) + history_text = "" + history_path = _history_file(repo) + if history_path is not None and history_path.is_file(): + history_text = history_path.read_text(encoding="utf-8") + secrets = _secret_set(history_text) + key = _read_local_integrity_key(repo) + for record in _load_history_records(repo): + if _transport_covers( + repo, record, head=head, required=required, key=key + ): + print("admit: ok") + return EXIT_SUCCESS + except Exception as exc: + return _reject(f"admit: no local integrity record: {exc}", secrets) + listed = ", ".join(sorted(required)) + return _reject(f"admit: no local integrity record for {listed}", secrets) + + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/agent_exec_adapters.py b/scripts/agent_exec_adapters.py index 12fdbda0..26aac68f 100644 --- a/scripts/agent_exec_adapters.py +++ b/scripts/agent_exec_adapters.py @@ -5,11 +5,14 @@ does not contact providers or launch real agent runs. """ from __future__ import annotations +import json import re import shutil import subprocess +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path +from typing import Any from scripts.agent_exec_config import ( ALLOWED_PLACEHOLDERS, @@ -23,6 +26,34 @@ _PROBE_TIMEOUT_SECONDS = 5 MAX_UNIT_PACKET_BYTES = 262_144 UNIT_PACKET_DISPLAY_MARKER = "" +TERMINAL_STATUSES = ( + "completed", + "failed", + "blocked", + "cancelled", + "failed-unknown", + "scope-expansion", +) +TERMINAL_RECORD_SCHEMA: dict[str, Any] = { + "type": "object", + "required": [ + "terminal_status", + "summary", + "changed_files", + "evidence", + "scope_expansion", + ], + "properties": { + "terminal_status": {"enum": list(TERMINAL_STATUSES)}, + "summary": {"type": "string"}, + "changed_files": {"type": "array", "items": {"type": "string"}}, + "evidence": {"type": "array", "items": {"type": "string"}}, + "scope_expansion": {"type": ["string", "null"]}, + }, +} +# Bounds the backwards brace scan over one final message. +_MAX_TERMINAL_TEXT_CHARS = 262_144 + _PLACEHOLDER_MAP = { "${workspace}": "workspace", "${unit_packet}": "unit_packet", @@ -378,17 +409,194 @@ def _read_unit_packet_text(path: Path) -> str: def _build_bounded_unit_prompt(packet_text: str) -> str: + schema = json.dumps(TERMINAL_RECORD_SCHEMA, separators=(",", ":")) 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" + "--- TERMINAL RECORD ---\n\n" + "End your final message with exactly one terminal record: a single-line " + "JSON object that validates against this JSON Schema:\n" + f"{schema}\n" + "Use terminal_status completed only when the unit is fully implemented. " + "Use blocked, cancelled, failed-unknown, failed, or scope-expansion " + "otherwise. A missing or invalid terminal record fails the attempt.\n\n" "--- BOUNDED IMPLEMENTATION UNIT PACKET ---\n\n" f"{packet_text}" ) +def extract_terminal_record(text: str | None) -> dict[str, Any] | None: + """Return the sole terminal record that ends one final message. + + Only whitespace may follow the record, and no other valid record may + precede it; anything else fails closed. + """ + if not isinstance(text, str) or not text: + return None + text = text[-_MAX_TERMINAL_TEXT_CHARS:].rstrip() + decoder = json.JSONDecoder() + + def decode_at(pos: int) -> tuple[object, int]: + try: + return decoder.raw_decode(text, pos) + except json.JSONDecodeError: + return None, -1 + + pos = text.rfind("{") + while pos != -1: + candidate, end = decode_at(pos) + if end == len(text) and isinstance(candidate, dict): + break + pos = text.rfind("{", 0, pos) + else: + return None + record = _valid_terminal_record(candidate) + if record is None: + return None + earlier = text.rfind("{", 0, pos) + while earlier != -1: + if _valid_terminal_record(decode_at(earlier)[0]) is not None: + return None + earlier = text.rfind("{", 0, earlier) + return record + + +def _valid_terminal_record(obj: object) -> dict[str, Any] | None: + if not isinstance(obj, dict): + return None + status = obj.get("terminal_status") + if not isinstance(status, str): + return None + status = status.strip().replace("_", "-") + if status not in TERMINAL_STATUSES: + return None + if not isinstance(obj.get("summary"), str): + return None + for key in ("changed_files", "evidence"): + items = obj.get(key) + if not isinstance(items, list) or any(not isinstance(i, str) for i in items): + return None + if "scope_expansion" not in obj or not ( + obj["scope_expansion"] is None or isinstance(obj["scope_expansion"], str) + ): + return None + record = dict(obj) + record["terminal_status"] = status + return record + + +def decode_terminal_record( + adapter: str, + *, + event_lines: Sequence[str | None], + stdout_tail: str, + result_text: str | None, +) -> dict[str, Any] | None: + """Decode the worker terminal record from the adapter's real output. + + ``event_lines`` are recent complete stdout lines that may carry a terminal + envelope, oldest first; ``None`` marks an envelope line too large to keep. + Event-stream adapters are read only from their final assistant/result + envelope, so prompt echoes and tool output never count as a record. + """ + if adapter in ("cursor", "claude"): + return extract_terminal_record(_cursor_final_text(event_lines)) + if adapter == "antigravity": + return extract_terminal_record(_agy_final_text(event_lines, stdout_tail)) + if adapter == "pi": + return extract_terminal_record(_pi_final_text(event_lines)) + record = extract_terminal_record(result_text) + if record is None: + record = extract_terminal_record(stdout_tail) + return record + + +def _event_objects(event_lines: Sequence[str | None]) -> list[dict[str, Any] | None]: + """Newest-first parsed events; ``None`` keeps the oversized-envelope marker.""" + out: list[dict[str, Any] | None] = [] + for line in reversed(event_lines): + if line is None: + out.append(None) + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(obj, dict): + out.append(obj) + return out + + +def _cursor_final_text(event_lines: Sequence[str | None]) -> str | None: + for obj in _event_objects(event_lines): + if obj is None: + return None + if obj.get("type") != "result": + continue + if obj.get("is_error") is not False or obj.get("subtype") != "success": + return None + text = obj.get("result") + return text if isinstance(text, str) else None + return None + + +def _agy_final_text( + event_lines: Sequence[str | None], stdout_tail: str +) -> str | None: + candidates = _event_objects(event_lines) + try: + whole = json.loads(stdout_tail.strip()) + except json.JSONDecodeError: + whole = None + if isinstance(whole, dict): + candidates.append(whole) + for obj in candidates: + if obj is None: + return None + if obj.get("event") == "result": + payload = obj.get("result") + elif "event" not in obj and "response" in obj: + payload = obj + else: + continue + if not isinstance(payload, dict): + return None + if payload.get("is_error") or payload.get("error"): + return None + text = payload.get("response") + return text if isinstance(text, str) else None + return None + + +def _pi_final_text(event_lines: Sequence[str | None]) -> str | None: + for obj in _event_objects(event_lines): + if obj is None: + return None + if obj.get("type") != "message_end": + continue + message = obj.get("message") + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + if message.get("stopReason") in ("error", "aborted"): + return None + content = message.get("content") + if isinstance(content, str): + return content + if not isinstance(content, list): + return None + return "".join( + block["text"] + for block in content + if isinstance(block, dict) + and block.get("type") == "text" + and isinstance(block.get("text"), str) + ) + return None + + def _expand_argv( items: tuple[str, ...], values: dict[str, str], @@ -480,11 +688,15 @@ def _sanitize_display( __all__ = [ "MAX_UNIT_PACKET_BYTES", + "TERMINAL_RECORD_SCHEMA", + "TERMINAL_STATUSES", "UNIT_PACKET_DISPLAY_MARKER", "AdapterProbe", "AdapterSpec", "BUILTIN_SPECS", "Invocation", "build_invocation", + "decode_terminal_record", + "extract_terminal_record", "probe_adapter", ] diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py index 84aa59b0..405e8d84 100644 --- a/scripts/agent_exec_config.py +++ b/scripts/agent_exec_config.py @@ -23,7 +23,11 @@ ALLOWED_PLACEHOLDERS = frozenset( PLACEHOLDER_RE = re.compile(r"\$\{[A-Za-z0-9_]+\}") PERMISSION_VALUES = frozenset({"run-everything"}) -FALLBACK_VALUES = frozenset({"all-unavailable"}) +FALLBACK_VALUES = frozenset({"all-unavailable", "never"}) +# Implementation/repair/test roles each need exactly one enabled profile per +# adapter here, with or without emergency_profile; review is unrestricted. +NORMAL_ROLE_ADAPTERS = frozenset({"cursor", "antigravity", "pi"}) +MAX_ATTEMPTS_PER_PROFILE = 3 PROMPT_TRANSPORTS = frozenset({"stdin", "positional", "flag"}) OUTPUT_FORMATS = frozenset({"json", "stream-json", "jsonl", "text"}) @@ -54,9 +58,13 @@ EXECUTION_KEYS = frozenset( "idle_timeout_seconds", "summary_bytes", "raw_output_bytes", + "max_attempts_per_profile", + "emergency_profile", } ) -EXECUTION_REQUIRED = EXECUTION_KEYS - frozenset({"idle_timeout_seconds"}) +EXECUTION_REQUIRED = EXECUTION_KEYS - frozenset( + {"idle_timeout_seconds", "max_attempts_per_profile", "emergency_profile"} +) PROFILE_KEYS = frozenset({"adapter", "executable", "model", "enabled"}) CUSTOM_ADAPTER_KEYS = frozenset( { @@ -84,6 +92,8 @@ class ExecutionPolicy: summary_bytes: int raw_output_bytes: int idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS + max_attempts_per_profile: int = 1 + emergency_profile: str | None = None @dataclass(frozen=True) @@ -337,6 +347,10 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig: if profile.adapter not in custom_adapters: raise ConfigError(f"unknown adapter: {profile.adapter!r}") + _validate_emergency_profile(execution, profiles, roles) + _validate_normal_role_adapters(profiles, roles) + _validate_normal_role_exactness(profiles, roles) + return ExecutionConfig( schema=schema, execution=execution, @@ -391,6 +405,23 @@ def _build_execution(raw: Any) -> ExecutionPolicy: if raw_output_bytes < summary_bytes: raise ConfigError("raw_output_bytes must be >= summary_bytes") + if "max_attempts_per_profile" in raw: + max_attempts_per_profile = _positive_bounded( + raw["max_attempts_per_profile"], + "max_attempts_per_profile", + 1, + MAX_ATTEMPTS_PER_PROFILE, + ) + else: + max_attempts_per_profile = 1 + + if "emergency_profile" in raw: + emergency_profile = raw["emergency_profile"] + if not isinstance(emergency_profile, str) or not emergency_profile: + raise ConfigError("emergency_profile must be a non-empty string") + else: + emergency_profile = None + return ExecutionPolicy( fresh_process_per_unit=True, permission=permission, @@ -400,9 +431,79 @@ def _build_execution(raw: Any) -> ExecutionPolicy: summary_bytes=summary_bytes, raw_output_bytes=raw_output_bytes, idle_timeout_seconds=idle_timeout_seconds, + max_attempts_per_profile=max_attempts_per_profile, + emergency_profile=emergency_profile, ) +def _validate_emergency_profile( + execution: ExecutionPolicy, + profiles: Mapping[str, Profile], + roles: Mapping[str, tuple[str, ...]], +) -> None: + name = execution.emergency_profile + if name is None: + return + if execution.max_attempts_per_profile != 3: + raise ConfigError( + "max_attempts_per_profile must equal 3 when emergency_profile is configured" + ) + profile = profiles.get(name) + if profile is None: + raise ConfigError(f"emergency_profile {name!r} does not exist") + if not profile.enabled: + raise ConfigError(f"emergency_profile {name!r} must be enabled") + if profile.adapter != "codex": + raise ConfigError(f"emergency_profile {name!r} must use adapter codex") + for role in ("implementation", "repair", "test"): + if name in roles[role]: + raise ConfigError( + f"emergency_profile {name!r} must not appear in role {role!r}" + ) + + +def _validate_normal_role_adapters( + profiles: Mapping[str, Profile], + roles: Mapping[str, tuple[str, ...]], +) -> None: + """Reject adapters outside ``NORMAL_ROLE_ADAPTERS`` on implementation, repair, and test. + + Applies to every profile in those roles, enabled or not, in every mode. + """ + for role in ("implementation", "repair", "test"): + for name in roles[role]: + adapter = profiles[name].adapter + if adapter not in NORMAL_ROLE_ADAPTERS: + raise ConfigError( + f"profile {name!r} in role {role!r} must use adapter " + "cursor, antigravity, or pi" + ) + + +def _validate_normal_role_exactness( + profiles: Mapping[str, Profile], + roles: Mapping[str, tuple[str, ...]], +) -> None: + """Each normal role needs exactly one enabled profile per normal adapter. + + Disabled profiles do not count and may appear alongside the enabled one. + Review is exempt. + """ + for role in ("implementation", "repair", "test"): + enabled: dict[str, list[str]] = {} + for name in roles[role]: + profile = profiles[name] + if profile.enabled: + enabled.setdefault(profile.adapter, []).append(name) + for adapter in sorted(NORMAL_ROLE_ADAPTERS): + names = enabled.get(adapter, []) + if len(names) != 1: + raise ConfigError( + f"role {role!r} must reference exactly one enabled " + f"{adapter} profile; found {names or 'none'}" + ) + + 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") diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index c2eebd7b..c9a0871f 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 本地硬闸(pre-commit):集成分支直提拒绝 + 分支命名。权威准入在 PR(baseline.md:26)。 +# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。 branch=$(git rev-parse --abbrev-ref HEAD) case "$branch" in main|master) @@ -10,4 +10,7 @@ echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || { echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/,当前:${branch}" >&2 exit 1 } -exit 0 +root=$(git rev-parse --show-toplevel) +# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子, +# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。 +PYTHONDONTWRITEBYTECODE=1 python3 "$root/scripts/agent_exec.py" admit --repo "$root" diff --git a/scripts/task.sh b/scripts/task.sh index 320a7742..a7478bce 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -213,7 +213,7 @@ ${parent_note} ## Agent 实施收据 -按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。仅当 scripts/agent_exec.py run 对该精确单元发出有效 host-fallback-authorized 收据后,当前 host 方可实施该单元。failed-unknown、scope-expansion、无效配置、未对账 Git 状态与安全失败均不授权回退。原始日志保留在源码外收据路径。 +按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。实施、修复与测试的正常候选仅为 Cursor、Antigravity(agy)和 PI;每个候选对安全可恢复失败最多 3 次全新尝试,然后才换下一候选。三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划或独立 Review。显式 fail-stop(failed-unknown、scope-expansion、blocked、cancelled、无效配置、未对账 Git 状态与安全失败)立即停止。不因推理时长、工具次数、规划方式或首次写文件时间判定失败;尝试只在显式终态失败、进程退出、总超时,或 stdin/stdout/stderr 均无活动的空闲超时时失败。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。实施集成必须是成功 transport 上的 scripts/agent_exec.py apply,或 pre-commit admit 的精确内容来源。host 直接管理的豁免仅为 .task/**、docs/superpowers/specs/** 与 docs/superpowers/plans/**。fallback 列只记录该行是否为 Codex 紧急尝试。安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked。不授权 host 直接撰写该单元。原始日志保留在源码外收据路径。 | 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | |---|---|---|---|---|---|---|---|---|---|---| diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index fdb3af3a..e0e5f282 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -5,6 +5,8 @@ Set RUN_REAL_AGENT_EXEC=1 and AGENT_EXEC_REAL_PROFILE to exactly one profile. """ from __future__ import annotations +import hashlib +import hmac import json import os import re @@ -22,6 +24,7 @@ from pathlib import Path from unittest import mock from scripts import agent_exec +from scripts.agent_exec_config import NORMAL_ROLE_ADAPTERS def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: @@ -60,16 +63,74 @@ def _pid_alive(pid: int) -> bool: ) +_NORMAL_ADAPTER_ORDER = ("cursor", "antigravity", "pi") + + +def _holder_name(adapter: str) -> str: + return f"{adapter}_holder" + + +def _route_normal_roles( + profiles: dict[str, dict[str, object]], + orders: list[list[str]], +) -> dict[str, dict[str, object]]: + """Place fixture profiles behind the cursor/antigravity/pi adapters. + + A ``fake`` profile is a ``_write_fake_worker`` executable; it takes the + next normal adapter not already enabled elsewhere. Any adapter a normal + role still lacks gets an enabled holder whose executable is absent, so it + is a safe ``unavailable`` after the configured profiles. + """ + routed = {name: dict(body) for name, body in profiles.items()} + used = { + body["adapter"] + for body in routed.values() + if body["adapter"] in _NORMAL_ADAPTER_ORDER and body.get("enabled", True) + } + for body in routed.values(): + if body["adapter"] != "fake": + continue + free = [adapter for adapter in _NORMAL_ADAPTER_ORDER if adapter not in used] + if not free: + raise ValueError("fixture has more fake profiles than normal adapters") + body["adapter"] = free[0] + used.add(free[0]) + for order in orders: + present = { + routed[name]["adapter"] + for name in order + if routed[name].get("enabled", True) + } + for adapter in _NORMAL_ADAPTER_ORDER: + if adapter in present: + continue + holder = _holder_name(adapter) + routed.setdefault( + holder, {"adapter": adapter, "executable": f"{adapter}-holder-absent"} + ) + order.append(holder) + return routed + + def _write_config( repo: Path, *, profiles: dict[str, dict[str, object]], role_order: list[str], + review_order: list[str] | None = None, + repair_test_order: list[str] | None = None, timeout_seconds: int = 30, idle_timeout_seconds: int | None = None, + max_attempts_per_profile: int | None = None, + emergency_profile: str | None = None, summary_bytes: int = 256, raw_output_bytes: int = 1024, ) -> None: + implementation = list(role_order) + repair_test = list(repair_test_order if repair_test_order is not None else role_order) + repair = list(repair_test) + profiles = _route_normal_roles(profiles, [implementation, repair, repair_test]) + review_names = review_order if review_order is not None else implementation[:1] profile_lines: list[str] = [] custom_lines: list[str] = [] for name, body in profiles.items(): @@ -86,13 +147,25 @@ def _write_config( custom_lines.append(" model_argv: []") custom_lines.append(" prompt_transport: stdin") custom_lines.append(" output_format: text") - order = ", ".join(role_order) - first = role_order[0] + implementation_line = ", ".join(implementation) + repair_line = ", ".join(repair) + test_line = ", ".join(repair_test) + review_line = ", ".join(review_names) idle_line = ( f" idle_timeout_seconds: {idle_timeout_seconds}\n" if idle_timeout_seconds is not None else "" ) + attempts_line = ( + f" max_attempts_per_profile: {max_attempts_per_profile}\n" + if max_attempts_per_profile is not None + else "" + ) + emergency_line = ( + f" emergency_profile: {emergency_profile}\n" + if emergency_profile is not None + else "" + ) block = textwrap.dedent( f"""\ schema: agent-execution/v1 @@ -103,14 +176,14 @@ def _write_config( fallback_to_host: all-unavailable max_parallel: 1 timeout_seconds: {timeout_seconds} -{idle_line} summary_bytes: {summary_bytes} +{idle_line}{attempts_line}{emergency_line} summary_bytes: {summary_bytes} raw_output_bytes: {raw_output_bytes} roles: - implementation: [{order}] - repair: [{first}] - test: [{first}] - review: [{first}] + implementation: [{implementation_line}] + repair: [{repair_line}] + test: [{test_line}] + review: [{review_line}] profiles: """ @@ -148,13 +221,79 @@ def _write_unit( return path +_FAKE_ENVELOPE_PY = textwrap.dedent( + """\ + import json, sys + adapter, path = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as handle: + text = handle.read() + if text: + if adapter == "cursor": + event = {"type": "result", "subtype": "success", "is_error": False, "result": text} + elif adapter == "antigravity": + event = {"event": "result", "result": {"response": text}} + else: + event = { + "type": "message_end", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": text}], + "stopReason": "stop", + }, + } + sys.stdout.write("\\n" + json.dumps(event) + "\\n") + """ +) + + +def _fake_adapter_prelude(scratch: Path) -> str: + """Run a ``$1``=workspace/``$3``=result body behind cursor, agy, or PI argv. + + Text the body writes to ``$3`` becomes that adapter's final envelope on + exit, so terminal decoding still goes through the real adapter path. + """ + return textwrap.dedent( + f"""\ + _fake_adapter="" + for _fake_arg in "$@"; do + case "$_fake_arg" in + --force) _fake_adapter=cursor ;; + --dangerously-skip-permissions) _fake_adapter=antigravity ;; + --approve) _fake_adapter=pi ;; + esac + done + if [ -n "$_fake_adapter" ]; then + _fake_result=$(mktemp {shlex.quote(str(scratch))}/fake-terminal.XXXXXX) || exit 70 + _fake_emit() {{ + _fake_code=$? + python3 -c {shlex.quote(_FAKE_ENVELOPE_PY)} "$_fake_adapter" "$_fake_result" + rm -f "$_fake_result" + exit "$_fake_code" + }} + trap _fake_emit EXIT + set -- "$(pwd -P)" "" "$_fake_result" + fi + """ + ) + + def _write_fake_worker( bindir: Path, name: str, *, body: str, - help_text: str = "custom worker\n", + help_text: str | None = None, + native: bool = False, ) -> Path: + """Write a fake CLI. + + By default the body uses the ``$1``/``$3`` convention behind any normal + adapter. ``native=True`` leaves argv and stdout exactly as the adapter + delivers them. + """ + if help_text is None: + help_text = "custom worker\n" if native else _normal_help() + prelude = "" if native else _fake_adapter_prelude(bindir.parent) path = bindir / name path.write_text( "#!/bin/sh\n" @@ -166,6 +305,7 @@ def _write_fake_worker( f" cat <<'HELP'\n{help_text}HELP\n" " exit 0\n" "fi\n" + + prelude + body, encoding="utf-8", ) @@ -173,6 +313,113 @@ def _write_fake_worker( return path +def _normal_help() -> str: + from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS + + markers = { + marker + for adapter in _NORMAL_ADAPTER_ORDER + for marker in _REQUIRED_HELP_MARKERS[adapter] + } + return "\n".join(sorted(markers)) + "\n" + + +def _codex_help() -> str: + from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS + + return "\n".join(_REQUIRED_HELP_MARKERS["codex"]) + "\n" + + +def _pi_help() -> str: + from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS + + return "\n".join(_REQUIRED_HELP_MARKERS["pi"]) + "\n" + + +def _antigravity_help() -> str: + from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS + + return "\n".join(_REQUIRED_HELP_MARKERS["antigravity"]) + "\n" + + +def _terminal_json(status: str = "completed") -> str: + return json.dumps( + { + "terminal_status": status, + "summary": "done", + "changed_files": ["src/marker.txt"], + "evidence": ["wrote marker"], + "scope_expansion": None, + }, + separators=(",", ":"), + ) + + +def _agy_stream_events(final_text: str, *, is_error: bool = False) -> list[str]: + """Representative Antigravity ``--output-format stream-json`` lines.""" + conv = "b97cb31f-a6bd-40bf-aca8-aaeaa2e1999a" + result: dict[str, object] = { + "conversation_id": conv, + "response": final_text, + "num_turns": 2, + "duration_ms": 10, + } + if is_error: + result["is_error"] = True + result["error"] = "model error" + events: list[dict[str, object]] = [ + {"event": "init", "conversation_id": conv, "init": {"cwd": "/w", "tools": ["run_command"]}}, + { + "event": "step_update", + "step_update": {"conversation_id": conv, "step_index": 0, "state": "DONE", "step_type": "user_input"}, + }, + { + "event": "step_update", + "step_update": {"conversation_id": conv, "step_index": 1, "state": "DONE", "step_type": "agent_response"}, + }, + {"event": "result", "conversation_id": conv, "result": result}, + ] + return [json.dumps(event, separators=(",", ":")) for event in events] + + +def _pi_json_events( + final_text: str, + *, + prompt_text: str = "unit", + stop_reason: str = "stop", + agent_end_padding: int = 0, +) -> list[str]: + """Representative PI ``--mode json`` lines (see pi docs/json.md).""" + user = {"role": "user", "content": prompt_text, "timestamp": 1} + assistant = { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "plan"}, + {"type": "text", "text": final_text}, + ], + "stopReason": stop_reason, + } + padding = {"role": "toolResult", "content": [{"type": "text", "text": "x" * agent_end_padding}]} + events: list[dict[str, object]] = [ + {"type": "session", "version": 3, "id": "s", "timestamp": "2026-10-15T00:00:00.000Z", "cwd": "/w"}, + {"type": "agent_start"}, + {"type": "turn_start"}, + {"type": "message_start", "message": user}, + {"type": "message_end", "message": user}, + {"type": "message_start", "message": {"role": "assistant", "content": [], "stopReason": "pending"}}, + { + "type": "message_update", + "usage": {"input": 1, "output": 1}, + "assistantMessageEvent": {"type": "text_delta", "contentIndex": 1, "delta": final_text}, + }, + {"type": "message_end", "message": assistant}, + {"type": "turn_end", "message": assistant, "toolResults": []}, + {"type": "agent_end", "messages": [user, padding, assistant], "willRetry": False}, + {"type": "agent_settled"}, + ] + return [json.dumps(event, separators=(",", ":")) for event in events] + + class AgentExecControllerTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-ctrl-") @@ -269,19 +516,6 @@ class AgentExecControllerTests(unittest.TestCase): def test_run_uses_fresh_process_and_worktree_per_attempt(self) -> None: meta = self.root / "meta" meta.mkdir() - _write_fake_worker( - self.bindir, - "worker-a", - body=textwrap.dedent( - f"""\ - workspace="$1" - echo "$$" > "{meta}/a.pid" - echo "$workspace" > "{meta}/a.wt" - # fail clean after no writes - exit 7 - """ - ), - ) _write_fake_worker( self.bindir, "worker-b", @@ -289,8 +523,8 @@ class AgentExecControllerTests(unittest.TestCase): f"""\ workspace="$1" result="$3" - echo "$$" > "{meta}/b.pid" - echo "$workspace" > "{meta}/b.wt" + echo "$$" >> "{meta}/pids" + echo "$workspace" >> "{meta}/wts" mkdir -p "$workspace/src" printf 'ok\\n' > "$workspace/src/marker.txt" printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":["wrote marker"],"scope_expansion":null}}' > "$result" @@ -299,38 +533,42 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={ - "a": {"adapter": "custom", "executable": "worker-a"}, - "b": {"adapter": "custom", "executable": "worker-b"}, - }, - role_order=["a", "b"], + profiles={"b": {"adapter": "fake", "executable": "worker-b"}}, + role_order=["b"], ) - self.base_snap = _canonical_snapshot(self.repo) + loaded = agent_exec.load_config(self.repo) + self.assertEqual(loaded.profiles["b"].adapter, "cursor") + for role in ("implementation", "repair", "test"): + self.assertEqual(loaded.roles[role][0], "b") + adapters = [loaded.profiles[name].adapter for name in loaded.roles[role]] + self.assertEqual(sorted(adapters), sorted(NORMAL_ROLE_ADAPTERS)) unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "fresh.json" - proc = self._run_cli( - "run", - "--repo", - str(self.repo), - "--role", - "implementation", - "--unit", - str(unit), - "--receipt", - str(receipt), - ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) - data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["schema"], "agent-exec-receipt/v1") - self.assertEqual(data["terminal_status"], "success") - a_pid = (meta / "a.pid").read_text(encoding="utf-8").strip() - b_pid = (meta / "b.pid").read_text(encoding="utf-8").strip() - a_wt = (meta / "a.wt").read_text(encoding="utf-8").strip() - b_wt = (meta / "b.wt").read_text(encoding="utf-8").strip() - self.assertNotEqual(a_pid, b_pid) - self.assertNotEqual(a_wt, b_wt) - self.assertTrue(a_wt.startswith(f"/tmp/agent-exec-{os.getuid()}/")) - self.assertTrue(b_wt.startswith(f"/tmp/agent-exec-{os.getuid()}/")) + for label in ("first", "second"): + receipt = self.receipts / f"fresh-{label}.json" + proc = self._run_cli( + "run", + "--repo", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + str(receipt), + ) + self.assertEqual( + proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout + ) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(data["schema"], "agent-exec-receipt/v1") + self.assertEqual(data["terminal_status"], "success") + pids = (meta / "pids").read_text(encoding="utf-8").split() + wts = (meta / "wts").read_text(encoding="utf-8").split() + self.assertEqual(len(pids), 2) + self.assertNotEqual(pids[0], pids[1]) + self.assertNotEqual(wts[0], wts[1]) + for wt in wts: + self.assertTrue(wt.startswith(f"/tmp/agent-exec-{os.getuid()}/")) self._assert_canonical_untouched() def test_missing_profiles_fall_through_in_declared_order(self) -> None: @@ -353,8 +591,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "missing": {"adapter": "custom", "executable": "does-not-exist-xyz"}, - "second": {"adapter": "custom", "executable": "second"}, + "missing": {"adapter": "fake", "executable": "does-not-exist-xyz"}, + "second": {"adapter": "fake", "executable": "second"}, }, role_order=["missing", "second"], ) @@ -379,11 +617,11 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual(log.read_text(encoding="utf-8").strip(), "second") self._assert_canonical_untouched() - def test_all_unavailable_authorizes_host_fallback_once(self) -> None: + def test_all_unavailable_exhaustion_does_not_authorize_host(self) -> None: self._configure( profiles={ - "m1": {"adapter": "custom", "executable": "gone-one"}, - "m2": {"adapter": "custom", "executable": "gone-two"}, + "m1": {"adapter": "fake", "executable": "gone-one"}, + "m2": {"adapter": "fake", "executable": "gone-two"}, }, role_order=["m1", "m2"], ) @@ -402,13 +640,18 @@ class AgentExecControllerTests(unittest.TestCase): str(receipt), ) self.assertEqual( - proc.returncode, agent_exec.EXIT_HOST_FALLBACK, proc.stderr + proc.stdout + proc.returncode, + agent_exec.EXIT_UNAVAILABLE_EXHAUSTION, + proc.stderr + proc.stdout, ) data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["terminal_status"], "host-fallback-authorized") - self.assertEqual(len(data["attempts"]), 2) + self.assertEqual(data["terminal_status"], "unavailable") + self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") + self.assertEqual( + [a["profile"] for a in data["attempts"]], ["m1", "m2", _holder_name("pi")] + ) self.assertTrue(all(a["terminal_status"] == "unavailable" for a in data["attempts"])) - self.assertEqual(data.get("host_fallback_authorizations"), 1) + self.assertEqual(data.get("host_fallback_authorizations"), 0) # Controller must not implement the unit itself. history_root = self.repo / ".git" / "agent-exec" after = [ @@ -420,10 +663,47 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual(data["history"]["status"], "recorded") self.assertTrue((history_root / "execution-history.jsonl").is_file()) self.assertFalse((self.repo / "src" / "marker.txt").exists()) - self.assertIn("allowed_paths", data["host_scope"]) + self.assertIsNone(data.get("host_scope")) self._assert_canonical_untouched() - def test_failed_clean_can_continue_after_exact_restore(self) -> None: + def test_legacy_exhaustion_without_emergency_does_not_authorize_host(self) -> None: + self._configure( + profiles={ + "m1": {"adapter": "fake", "executable": "gone-one"}, + "m2": {"adapter": "fake", "executable": "gone-two"}, + }, + role_order=["m1", "m2"], + ) + unit = _write_unit(self.root / "unit.json") + receipt = self.receipts / "no-host.json" + proc = self._run_cli( + "run", + "--repo", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + str(receipt), + ) + self.assertEqual( + proc.returncode, + agent_exec.EXIT_UNAVAILABLE_EXHAUSTION, + proc.stderr + proc.stdout, + ) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(data["terminal_status"], "unavailable") + self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") + self.assertEqual(data.get("host_fallback_authorizations"), 0) + self.assertIsNone(data.get("host_scope")) + self.assertFalse((self.repo / "src" / "marker.txt").exists()) + self._assert_canonical_untouched() + + def test_declared_offline_launched_failure_is_failed_unknown_and_stops(self) -> None: + """network=false/external_cost=false are declarations, not isolation proof.""" + launched = self.root / "declared-offline-next.log" + launched.write_text("", encoding="utf-8") _write_fake_worker( self.bindir, "dirty", @@ -440,25 +720,82 @@ class AgentExecControllerTests(unittest.TestCase): self.bindir, "ok", body=textwrap.dedent( - """\ - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'final\\n' > "$workspace/src/marker.txt" - printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" + f"""\ + echo next >> "{launched}" exit 0 """ ), ) self._configure( profiles={ - "dirty": {"adapter": "custom", "executable": "dirty"}, - "ok": {"adapter": "custom", "executable": "ok"}, + "dirty": {"adapter": "fake", "executable": "dirty"}, + "ok": {"adapter": "fake", "executable": "ok"}, }, role_order=["dirty", "ok"], ) unit = _write_unit(self.root / "unit.json") - receipt = self.receipts / "clean.json" + packet = json.loads(unit.read_text(encoding="utf-8")) + self.assertFalse(packet["network"]) + self.assertFalse(packet["external_cost"]) + receipt = self.receipts / "declared-offline.json" + proc = self._run_cli( + "run", + "--repo", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + str(receipt), + ) + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(data["terminal_status"], "failed-unknown") + self.assertEqual(len(data["attempts"]), 1) + attempt = data["attempts"][0] + self.assertEqual(attempt["terminal_status"], "failed-unknown") + self.assertFalse(attempt["cleanup"]["exact_restore"]) + self.assertTrue(attempt["workspace_preserved"]) + self.assertFalse(attempt["cleanup"]["declared_network"]) + self.assertFalse(attempt["cleanup"]["declared_external_cost"]) + self.assertTrue( + (Path(attempt["worktree"]) / "src" / "marker.txt").is_file() + ) + self.assertEqual(launched.read_text(encoding="utf-8"), "") + self.assertEqual(data.get("host_fallback_authorizations", 0), 0) + self._assert_canonical_untouched() + + def test_safe_unavailable_retries_profile_before_next_success(self) -> None: + launches = self.root / "profile-launches.log" + launches.write_text("", encoding="utf-8") + _write_fake_worker( + self.bindir, + "thenok", + body=textwrap.dedent( + f"""\ + printf 'thenok\\n' >> "{launches}" + workspace="$1" + result="$3" + mkdir -p "$workspace/src" + printf 'final\\n' > "$workspace/src/marker.txt" + printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}}' > "$result" + exit 0 + """ + ), + ) + self._configure( + profiles={ + "retryme": {"adapter": "fake", "executable": "retryme-missing"}, + "thenok": {"adapter": "fake", "executable": "thenok"}, + }, + role_order=["retryme", "thenok"], + max_attempts_per_profile=3, + ) + loaded = agent_exec.load_config(self.repo) + self.assertEqual(loaded.execution.max_attempts_per_profile, 3) + unit = _write_unit(self.root / "unit.json") + receipt = self.receipts / "retry-ordinal.json" proc = self._run_cli( "run", "--repo", @@ -471,10 +808,150 @@ class AgentExecControllerTests(unittest.TestCase): str(receipt), ) self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + observed = [ + line for line in launches.read_text(encoding="utf-8").splitlines() if line + ] + self.assertEqual(observed, ["thenok"]) data = json.loads(receipt.read_text(encoding="utf-8")) - self.assertEqual(data["attempts"][0]["terminal_status"], "failed-clean") - self.assertTrue(data["attempts"][0]["cleanup"]["exact_restore"]) - self.assertEqual(data["terminal_status"], "success") + self.assertEqual( + [ + ( + attempt["profile"], + attempt.get("attempt_ordinal"), + attempt["terminal_status"], + ) + for attempt in data["attempts"] + ], + [ + ("retryme", 1, "unavailable"), + ("retryme", 2, "unavailable"), + ("retryme", 3, "unavailable"), + ("thenok", 1, "success"), + ], + ) + self.assertTrue( + all(attempt["cleanup"]["exact_restore"] for attempt in data["attempts"][:3]) + ) + self._assert_canonical_untouched() + + def test_emergency_profile_runs_once_after_regular_safe_exhaustion(self) -> None: + launches = self.root / "emergency-launches.log" + launches.write_text("", encoding="utf-8") + # Installed but missing required capability flags: each attempt is a + # safe pre-launch `unavailable`, which is what may route onward. + normal = ( + ("cursor", "cursor-regular", "incompatible\n"), + ("antigravity", "agy-regular", "incompatible\n"), + ("pi", "pi-regular", "incompatible\n"), + ) + for adapter, executable, help_text in normal: + _write_fake_worker( + self.bindir, + executable, + help_text=help_text, + native=True, + body=textwrap.dedent( + f"""\ + printf '{adapter}\\n' >> "{launches}" + exit 9 + """ + ), + ) + _write_fake_worker( + self.bindir, + "codex", + help_text=_codex_help(), + native=True, + body=textwrap.dedent( + f"""\ + if [ "$1" = "exec" ] && [ "$2" = "--help" ]; then + exec "$0" --help + fi + printf 'codex\\n' >> "{launches}" + result="" + prev="" + for arg in "$@"; do + if [ "$prev" = "--output-last-message" ]; then + result="$arg" + fi + prev="$arg" + done + if [ -z "$result" ]; then + echo "missing --output-last-message" >&2 + exit 2 + fi + mkdir -p src + printf 'final\\n' > src/marker.txt + printf '%s\\n' '{{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}}' > "$result" + exit 0 + """ + ), + ) + order = [adapter for adapter, _, _ in normal] + profiles: dict[str, dict[str, object]] = { + adapter: {"adapter": adapter, "executable": executable, "model": "auto"} + for adapter, executable, _ in normal + } + profiles["codex"] = {"adapter": "codex", "executable": "codex", "model": "auto"} + self._configure( + profiles=profiles, + role_order=order, + repair_test_order=order, + max_attempts_per_profile=3, + emergency_profile="codex", + ) + loaded = agent_exec.load_config(self.repo) + self.assertEqual(loaded.execution.max_attempts_per_profile, 3) + self.assertEqual(loaded.execution.emergency_profile, "codex") + self.assertIn("codex", loaded.profiles) + for role in ("implementation", "repair", "test"): + self.assertEqual(loaded.roles[role], tuple(order)) + for adapter in order: + self.assertEqual(loaded.profiles[adapter].adapter, adapter) + self.assertEqual(loaded.profiles["codex"].adapter, "codex") + for role_names in loaded.roles.values(): + self.assertNotIn("codex", role_names) + unit = _write_unit(self.root / "unit.json") + receipt = self.receipts / "emergency-fallback.json" + proc = self._run_cli( + "run", + "--repo", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + str(receipt), + ) + data = json.loads(receipt.read_text(encoding="utf-8")) + recorded = [ + ( + attempt["profile"], + attempt.get("attempt_ordinal"), + attempt["terminal_status"], + attempt["emergency_fallback"], + ) + for attempt in data["attempts"] + ] + self.assertEqual( + recorded, + [ + (adapter, ordinal, "unavailable", False) + for adapter in order + for ordinal in (1, 2, 3) + ] + + [("codex", 1, "success", True)], + ) + self.assertTrue(recorded[-1][3]) + self.assertFalse(any(flag for _, _, _, flag in recorded[:-1])) + self.assertEqual(data.get("host_fallback_authorizations"), 0) + observed = [ + line for line in launches.read_text(encoding="utf-8").splitlines() if line + ] + self.assertEqual(observed, ["codex"]) + self.assertEqual(data["attempts"][-1]["summary"], "done") + self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) self._assert_canonical_untouched() def test_failed_unknown_blocks_fallback(self) -> None: @@ -505,8 +982,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "unknown": {"adapter": "custom", "executable": "unknown"}, - "next": {"adapter": "custom", "executable": "next"}, + "unknown": {"adapter": "fake", "executable": "unknown"}, + "next": {"adapter": "fake", "executable": "next"}, }, role_order=["unknown", "next"], ) @@ -559,8 +1036,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "scope": {"adapter": "custom", "executable": "scope"}, - "next": {"adapter": "custom", "executable": "next"}, + "scope": {"adapter": "fake", "executable": "scope"}, + "next": {"adapter": "fake", "executable": "next"}, }, role_order=["scope", "next"], ) @@ -638,9 +1115,9 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "committer": {"adapter": "custom", "executable": worker}, + "committer": {"adapter": "fake", "executable": worker}, "next": { - "adapter": "custom", + "adapter": "fake", "executable": f"next-{label}", }, }, @@ -689,7 +1166,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"binwriter": {"adapter": "custom", "executable": "binwriter"}}, + profiles={"binwriter": {"adapter": "fake", "executable": "binwriter"}}, role_order=["binwriter"], ) unit = _write_unit( @@ -730,7 +1207,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"sleeper": {"adapter": "custom", "executable": "sleeper"}}, + profiles={"sleeper": {"adapter": "fake", "executable": "sleeper"}}, role_order=["sleeper"], timeout_seconds=2, ) @@ -790,8 +1267,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "silent": {"adapter": "custom", "executable": "silent"}, - "idlenext": {"adapter": "custom", "executable": "idlenext"}, + "silent": {"adapter": "fake", "executable": "silent"}, + "idlenext": {"adapter": "fake", "executable": "idlenext"}, }, role_order=["silent", "idlenext"], timeout_seconds=40, @@ -863,7 +1340,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"paced": {"adapter": "custom", "executable": "paced"}}, + profiles={"paced": {"adapter": "fake", "executable": "paced"}}, role_order=["paced"], timeout_seconds=40, idle_timeout_seconds=2, @@ -953,7 +1430,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"noisy": {"adapter": "custom", "executable": "noisy"}}, + profiles={"noisy": {"adapter": "fake", "executable": "noisy"}}, role_order=["noisy"], summary_bytes=64, raw_output_bytes=128, @@ -1008,7 +1485,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"leaky": {"adapter": "custom", "executable": "leaky"}}, + profiles={"leaky": {"adapter": "fake", "executable": "leaky"}}, role_order=["leaky"], ) unit = _write_unit(self.root / "unit.json") @@ -1050,7 +1527,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"ok": {"adapter": "custom", "executable": "ok"}}, + profiles={"ok": {"adapter": "fake", "executable": "ok"}}, role_order=["ok"], ) unit = _write_unit(self.root / "unit.json") @@ -1121,8 +1598,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "canonmut": {"adapter": "custom", "executable": "canonmut"}, - "canonnext": {"adapter": "custom", "executable": "canonnext"}, + "canonmut": {"adapter": "fake", "executable": "canonmut"}, + "canonnext": {"adapter": "fake", "executable": "canonnext"}, }, role_order=["canonmut", "canonnext"], ) @@ -1168,7 +1645,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"corruptgit": {"adapter": "custom", "executable": "corruptgit"}}, + profiles={"corruptgit": {"adapter": "fake", "executable": "corruptgit"}}, role_order=["corruptgit"], ) unit = _write_unit(self.root / "unit-corrupt.json") @@ -1227,8 +1704,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "netfail": {"adapter": "custom", "executable": "netfail"}, - "netnext": {"adapter": "custom", "executable": "netnext"}, + "netfail": {"adapter": "fake", "executable": "netfail"}, + "netnext": {"adapter": "fake", "executable": "netnext"}, }, role_order=["netfail", "netnext"], ) @@ -1290,8 +1767,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "adminmut": {"adapter": "custom", "executable": "adminmut"}, - "adminnext": {"adapter": "custom", "executable": "adminnext"}, + "adminmut": {"adapter": "fake", "executable": "adminmut"}, + "adminnext": {"adapter": "fake", "executable": "adminnext"}, }, role_order=["adminmut", "adminnext"], ) @@ -1356,7 +1833,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"sibworker": {"adapter": "custom", "executable": "sibworker"}}, + profiles={"sibworker": {"adapter": "fake", "executable": "sibworker"}}, role_order=["sibworker"], ) unit = _write_unit(self.root / "unit-sibling.json") @@ -1481,8 +1958,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "symworker": {"adapter": "custom", "executable": "symworker"}, - "symnext": {"adapter": "custom", "executable": "symnext"}, + "symworker": {"adapter": "fake", "executable": "symworker"}, + "symnext": {"adapter": "fake", "executable": "symnext"}, }, role_order=["symworker", "symnext"], ) @@ -1535,7 +2012,7 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"boundary": {"adapter": "custom", "executable": "boundary"}}, + profiles={"boundary": {"adapter": "fake", "executable": "boundary"}}, role_order=["boundary"], raw_output_bytes=4096, ) @@ -1566,10 +2043,12 @@ class AgentExecControllerTests(unittest.TestCase): def test_large_stdin_with_early_stdout_does_not_deadlock(self) -> None: """Stdin must be select-driven so early stdout beyond pipe capacity cannot hang.""" # Stay under adapter MAX_UNIT_PACKET_BYTES (262144) while exceeding pipe capacity. + # Only a review-role custom adapter delivers the packet on stdin. pad = "P" * (128 * 1024) _write_fake_worker( self.bindir, "pipefill", + native=True, body=textwrap.dedent( """\ workspace="$1" @@ -1585,10 +2064,14 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={"pipefill": {"adapter": "custom", "executable": "pipefill"}}, - role_order=["pipefill"], + role_order=[], + review_order=["pipefill"], timeout_seconds=15, raw_output_bytes=1024, ) + loaded = agent_exec.load_config(self.repo) + self.assertEqual(loaded.custom_adapters["pipefill"].prompt_transport, "stdin") + self.assertNotIn("pipefill", loaded.roles["implementation"]) unit = _write_unit( self.root / "unit-pipefill.json", timeout_seconds=15, @@ -1606,7 +2089,7 @@ class AgentExecControllerTests(unittest.TestCase): "--repo", str(self.repo), "--role", - "implementation", + "review", "--unit", str(unit), "--receipt", @@ -1661,8 +2144,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "txok": {"adapter": "custom", "executable": "txok"}, - "txnext": {"adapter": "custom", "executable": "txnext"}, + "txok": {"adapter": "fake", "executable": "txok"}, + "txnext": {"adapter": "fake", "executable": "txnext"}, }, role_order=["txok", "txnext"], ) @@ -1727,6 +2210,484 @@ class AgentExecControllerTests(unittest.TestCase): ) self.assertEqual(identity, f"{expected}|{expected}") + def test_rename_into_allowed_destination_cannot_hide_source(self) -> None: + """Rename parsing must keep an out-of-scope source and its destination.""" + launched = self.root / "rename-next.log" + launched.write_text("", encoding="utf-8") + _write_fake_worker( + self.bindir, + "renamer", + body=textwrap.dedent( + """\ + workspace="$1" + result="$3" + git -C "$workspace" mv README.md src/marker.txt + printf '%s\\n' '{"terminal_status":"completed","summary":"moved","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" + exit 0 + """ + ), + ) + _write_fake_worker( + self.bindir, + "renamenext", + body=textwrap.dedent( + f"""\ + echo next >> "{launched}" + exit 0 + """ + ), + ) + self._configure( + profiles={ + "renamer": {"adapter": "fake", "executable": "renamer"}, + "renamenext": {"adapter": "fake", "executable": "renamenext"}, + }, + role_order=["renamer", "renamenext"], + ) + unit = _write_unit(self.root / "unit-rename.json") + receipt = self.receipts / "rename.json" + proc = self._run_cli( + "run", + "--repo", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + str(receipt), + ) + self.assertNotEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertNotEqual(data["terminal_status"], "success") + self.assertEqual(len(data["attempts"]), 1) + attempt = data["attempts"][0] + paths = attempt.get("inventory_paths") or [] + self.assertIn("README.md", paths) + self.assertIn("src/marker.txt", paths) + self.assertIn("README.md", attempt.get("changed_paths") or []) + self.assertIn("src/marker.txt", attempt.get("changed_paths") or []) + self.assertFalse(agent_exec._path_allowed("README.md", ("src/marker.txt",))) + self.assertEqual(launched.read_text(encoding="utf-8"), "") + self.assertEqual(data.get("host_fallback_authorizations", 0), 0) + self._assert_canonical_untouched() + + def test_launched_failure_without_diff_is_failed_unknown_and_stops(self) -> None: + """No diff and declared-offline packets still do not prove external cleanliness.""" + cases = { + "clean-exit": "exit 0\n", + "nonzero-exit": "exit 7\n", + } + for label, body in cases.items(): + with self.subTest(case=label): + launched = self.root / f"restore-next-{label}.log" + launched.write_text("", encoding="utf-8") + _write_fake_worker(self.bindir, f"restorer-{label}", body=body) + _write_fake_worker( + self.bindir, + f"restorenext-{label}", + body=textwrap.dedent( + f"""\ + echo next >> "{launched}" + exit 0 + """ + ), + ) + self._configure( + profiles={ + "restorer": { + "adapter": "fake", + "executable": f"restorer-{label}", + }, + "restorenext": { + "adapter": "fake", + "executable": f"restorenext-{label}", + }, + }, + role_order=["restorer", "restorenext"], + ) + unit = _write_unit(self.root / f"unit-restore-{label}.json") + receipt = self.receipts / f"restore-{label}.json" + code = agent_exec.cmd_run( + self.repo, + role="implementation", + unit_path=unit, + receipt_path=receipt, + ) + self.assertEqual(code, agent_exec.EXIT_BLOCKED) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(data["terminal_status"], "failed-unknown") + self.assertEqual(len(data["attempts"]), 1) + attempt = data["attempts"][0] + self.assertEqual(attempt["terminal_status"], "failed-unknown") + self.assertFalse((attempt.get("cleanup") or {}).get("exact_restore")) + self.assertEqual(launched.read_text(encoding="utf-8"), "") + self.assertEqual(data.get("host_fallback_authorizations", 0), 0) + self._assert_canonical_untouched() + + def test_clean_exit_without_diff_and_network_is_failed_unknown(self) -> None: + """Authorized network with a clean no-diff exit is not failed-clean.""" + launched = self.root / "net-clean-next.log" + launched.write_text("", encoding="utf-8") + _write_fake_worker(self.bindir, "netclean", body="exit 0\n") + _write_fake_worker( + self.bindir, + "netcleannext", + body=textwrap.dedent( + f"""\ + echo next >> "{launched}" + exit 0 + """ + ), + ) + self._configure( + profiles={ + "netclean": {"adapter": "fake", "executable": "netclean"}, + "netcleannext": {"adapter": "fake", "executable": "netcleannext"}, + }, + role_order=["netclean", "netcleannext"], + ) + unit = _write_unit(self.root / "unit-net-clean.json") + packet = json.loads(unit.read_text(encoding="utf-8")) + packet["network"] = True + packet["external_cost"] = False + unit.write_text(json.dumps(packet, indent=2) + "\n", encoding="utf-8") + receipt = self.receipts / "net-clean.json" + proc = self._run_cli( + "run", + "--repo", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + str(receipt), + ) + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(data["terminal_status"], "failed-unknown") + self.assertEqual(len(data["attempts"]), 1) + self.assertEqual(data["attempts"][0]["terminal_status"], "failed-unknown") + self.assertEqual(launched.read_text(encoding="utf-8"), "") + self.assertEqual(data.get("host_fallback_authorizations", 0), 0) + self._assert_canonical_untouched() + + def test_worker_fail_stop_terminal_beats_success_and_no_change(self) -> None: + """blocked, cancelled, and failed-unknown stop before success or no-change.""" + cases = ( + ("blocked", agent_exec.EXIT_BLOCKED, True), + ("cancelled", agent_exec.EXIT_CANCELLED, True), + ("failed-unknown", agent_exec.EXIT_BLOCKED, True), + ("cancelled", agent_exec.EXIT_CANCELLED, False), + ) + for status, exit_code, write_file in cases: + label = f"{status}-{'files' if write_file else 'nodiff'}" + with self.subTest(case=label): + launched = self.root / f"failstop-{label}.log" + launched.write_text("", encoding="utf-8") + write = "" + if write_file: + write = ( + 'mkdir -p "$workspace/src"\n' + "printf 'x\\n' > \"$workspace/src/marker.txt\"\n" + ) + _write_fake_worker( + self.bindir, + f"failstop-{label}", + body=textwrap.dedent( + f"""\ + workspace="$1" + result="$3" + {write}printf '%s\\n' '{{"terminal_status":"{status}","summary":"stop","changed_files":[],"evidence":[],"scope_expansion":null}}' > "$result" + exit 0 + """ + ), + ) + _write_fake_worker( + self.bindir, + f"failstop-next-{label}", + body=textwrap.dedent( + f"""\ + echo next >> "{launched}" + exit 0 + """ + ), + ) + self._configure( + profiles={ + "failstop": { + "adapter": "fake", + "executable": f"failstop-{label}", + }, + "failnext": { + "adapter": "fake", + "executable": f"failstop-next-{label}", + }, + }, + role_order=["failstop", "failnext"], + ) + unit = _write_unit(self.root / f"unit-failstop-{label}.json") + receipt = self.receipts / f"failstop-{label}.json" + proc = self._run_cli( + "run", + "--repo", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + str(receipt), + ) + self.assertEqual(proc.returncode, exit_code, proc.stderr + proc.stdout) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(data["terminal_status"], status) + self.assertEqual(len(data["attempts"]), 1) + attempt = data["attempts"][0] + self.assertEqual(attempt["terminal_status"], status) + if write_file: + self.assertIn("src/marker.txt", attempt.get("changed_paths") or []) + self.assertEqual(launched.read_text(encoding="utf-8"), "") + self.assertEqual(data.get("host_fallback_authorizations", 0), 0) + self._assert_canonical_untouched() + + def test_builtin_event_envelopes_gate_success(self) -> None: + """Real Cursor/agy/PI output envelopes decide the terminal; prose fails closed.""" + prose = "All done, the marker is written." + cases = ( + ("cursor", _cursor_help(), _cursor_stream_events("ok\n" + _terminal_json()), "success"), + ("cursor", _cursor_help(), _cursor_stream_events(prose), "failed-unknown"), + ("cursor", _cursor_help(), _cursor_stream_events(_terminal_json("blocked")), "blocked"), + ( + "cursor", + _cursor_help(), + _cursor_stream_events(_terminal_json(), is_error=True), + "failed-unknown", + ), + ("antigravity", _antigravity_help(), _agy_stream_events("ok " + _terminal_json()), "success"), + ("antigravity", _antigravity_help(), _agy_stream_events(prose), "failed-unknown"), + ( + "pi", + _pi_help(), + _pi_json_events( + "ok\n" + _terminal_json() + "\n", + agent_end_padding=2 * agent_exec.TERMINAL_LINE_MAX_BYTES, + ), + "success", + ), + ("pi", _pi_help(), _pi_json_events(prose, prompt_text=_terminal_json()), "failed-unknown"), + ("pi", _pi_help(), [], "failed-unknown"), + ) + expected_exit = { + "success": agent_exec.EXIT_SUCCESS, + "failed-unknown": agent_exec.EXIT_BLOCKED, + "blocked": agent_exec.EXIT_BLOCKED, + } + for index, (adapter, help_text, events, expected) in enumerate(cases): + label = f"{adapter}-{index}-{expected}" + with self.subTest(case=label): + events_path = self.root / f"events-{label}.jsonl" + events_path.write_text( + "".join(line + "\n" for line in events), encoding="utf-8" + ) + executable = f"envelope-{label}" + _write_fake_worker( + self.bindir, + executable, + help_text=help_text, + native=True, + body=textwrap.dedent( + f"""\ + mkdir -p src + printf 'ok\\n' > src/marker.txt + cat "{events_path}" + exit 0 + """ + ), + ) + self._configure( + profiles={ + adapter: {"adapter": adapter, "executable": executable} + }, + role_order=[adapter], + ) + unit = _write_unit(self.root / f"unit-{label}.json") + receipt = self.receipts / f"envelope-{label}.json" + code = agent_exec.cmd_run( + self.repo, + role="implementation", + unit_path=unit, + receipt_path=receipt, + ) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(data["terminal_status"], expected, data.get("reason")) + self.assertEqual(code, expected_exit[expected]) + self.assertEqual(len(data["attempts"]), 1) + attempt = data["attempts"][0] + self.assertEqual(attempt["exit_code"], 0) + self.assertEqual(attempt["changed_paths"], ["src/marker.txt"]) + if expected == "success": + self.assertTrue(attempt["transport_ref"]) + else: + self.assertIsNone(attempt.get("transport_ref")) + self.assertTrue(attempt["workspace_preserved"]) + if expected == "failed-unknown": + self.assertIn("no valid completed terminal record", attempt["reason"]) + self._assert_canonical_untouched() + + +class TerminalRecordDecodingTests(unittest.TestCase): + """Adapter-specific terminal decoding over representative real envelopes.""" + + def _decode( + self, + adapter: str, + lines: list[str | None], + *, + stdout_tail: str | None = None, + result_text: str | None = None, + ) -> dict[str, object] | None: + from scripts.agent_exec_adapters import decode_terminal_record + + tail = stdout_tail + if tail is None: + tail = "".join(line + "\n" for line in lines if line is not None) + return decode_terminal_record( + adapter, event_lines=lines, stdout_tail=tail, result_text=result_text + ) + + def test_cursor_stream_json_result_is_decoded(self) -> None: + lines = _cursor_stream_events("Done.\n" + _terminal_json() + "\n \n") + record = self._decode("cursor", lines) + assert record is not None + self.assertEqual(record["terminal_status"], "completed") + self.assertEqual(record["changed_files"], ["src/marker.txt"]) + + def test_terminal_record_must_be_sole_final_content(self) -> None: + invalid = json.dumps({"terminal_status": "done", "summary": "x"}) + texts = { + "trailing-prose": "Done.\n" + _terminal_json() + "\nThanks.", + "trailing-invalid-object": _terminal_json() + "\n" + invalid, + "trailing-non-terminal-object": _terminal_json() + ' {"note":"x"}', + "truncated-terminal-object": _terminal_json() + '\n{"terminal_status":', + "two-records": _terminal_json() + "\n" + _terminal_json(), + "conflicting-records": _terminal_json() + "\n" + _terminal_json("blocked"), + } + for name, text in texts.items(): + for adapter, lines in ( + ("cursor", _cursor_stream_events(text)), + ("antigravity", _agy_stream_events(text)), + ("pi", _pi_json_events(text)), + ): + with self.subTest(case=name, adapter=adapter): + self.assertIsNone(self._decode(adapter, lines)) + with self.subTest(case=name, adapter="custom"): + self.assertIsNone( + self._decode("custom", [], stdout_tail="", result_text=text) + ) + + def test_cursor_user_echo_and_error_result_are_not_records(self) -> None: + lines = _cursor_stream_events("prose only") + echo = json.loads(lines[1]) + echo["message"]["content"][0]["text"] = _terminal_json() + lines[1] = json.dumps(echo) + self.assertIsNone(self._decode("cursor", lines)) + self.assertIsNone( + self._decode("cursor", _cursor_stream_events(_terminal_json(), is_error=True)) + ) + self.assertIsNone(self._decode("cursor", _cursor_stream_events(_terminal_json())[:-1])) + + def test_cursor_blocked_record_is_decoded_as_fail_stop(self) -> None: + record = self._decode("cursor", _cursor_stream_events(_terminal_json("blocked"))) + assert record is not None + self.assertEqual(record["terminal_status"], "blocked") + + def test_agy_stream_json_and_json_results_are_decoded(self) -> None: + record = self._decode("antigravity", _agy_stream_events("ok " + _terminal_json())) + assert record is not None + self.assertEqual(record["terminal_status"], "completed") + single = json.dumps( + {"conversation_id": "c", "response": "ok\n" + _terminal_json(), "num_turns": 1}, + indent=2, + ) + record = self._decode("antigravity", [], stdout_tail=single) + assert record is not None + self.assertEqual(record["terminal_status"], "completed") + self.assertIsNone( + self._decode("antigravity", _agy_stream_events(_terminal_json(), is_error=True)) + ) + self.assertIsNone(self._decode("antigravity", _agy_stream_events(_terminal_json())[:-1])) + + def test_pi_json_final_assistant_message_is_decoded(self) -> None: + lines = _pi_json_events("ok\n" + _terminal_json()) + record = self._decode("pi", lines) + assert record is not None + self.assertEqual(record["terminal_status"], "completed") + self.assertIsNone( + self._decode("pi", _pi_json_events("prose", prompt_text=_terminal_json())) + ) + self.assertIsNone( + self._decode("pi", _pi_json_events(_terminal_json(), stop_reason="error")) + ) + + def test_prose_missing_or_invalid_terminal_fails_closed(self) -> None: + for adapter, lines in ( + ("cursor", _cursor_stream_events("finished, no record")), + ("antigravity", _agy_stream_events("finished, no record")), + ("pi", _pi_json_events("finished, no record")), + ("cursor", []), + ("antigravity", []), + ("pi", []), + ): + with self.subTest(adapter=adapter, lines=len(lines)): + self.assertIsNone(self._decode(adapter, lines)) + invalid = json.dumps({"terminal_status": "done", "summary": "x"}) + self.assertIsNone(self._decode("cursor", _cursor_stream_events(invalid))) + missing_keys = json.dumps({"terminal_status": "completed", "summary": "x"}) + self.assertIsNone(self._decode("cursor", _cursor_stream_events(missing_keys))) + + def test_raw_stdout_record_is_not_an_envelope_for_builtins(self) -> None: + bare = [_terminal_json()] + for adapter in ("cursor", "antigravity", "pi"): + with self.subTest(adapter=adapter): + self.assertIsNone(self._decode(adapter, bare)) + record = self._decode("custom", [], stdout_tail="", result_text=_terminal_json()) + assert record is not None + self.assertEqual(record["terminal_status"], "completed") + + def test_prompt_states_schema_without_being_a_record(self) -> None: + from scripts import agent_exec_adapters as adapters + + prompt = adapters._build_bounded_unit_prompt('{"unit_id":"u"}') + self.assertIn( + json.dumps(adapters.TERMINAL_RECORD_SCHEMA, separators=(",", ":")), prompt + ) + for status in adapters.TERMINAL_STATUSES: + self.assertIn(status, prompt) + self.assertIsNone(adapters.extract_terminal_record(prompt)) + + def test_stdout_tail_keeps_final_envelope_behind_oversized_event(self) -> None: + big = 2 * agent_exec.TERMINAL_LINE_MAX_BYTES + stream = "".join( + line + "\n" + for line in _pi_json_events("ok " + _terminal_json(), agent_end_padding=big) + ).encode("utf-8") + tail = agent_exec._StdoutTail(agent_exec.OUTPUT_TAIL_BYTES) + for start in range(0, len(stream), 4093): + tail.extend(stream[start : start + 4093]) + self.assertNotIn(b"message_end", tail.text().encode("utf-8")) + record = self._decode("pi", tail.terminal_lines(), stdout_tail=tail.text()) + assert record is not None + self.assertEqual(record["terminal_status"], "completed") + + oversized = _cursor_stream_events("x" * big + _terminal_json()) + tail = agent_exec._StdoutTail(agent_exec.OUTPUT_TAIL_BYTES) + tail.extend("".join(line + "\n" for line in oversized).encode("utf-8")) + lines = tail.terminal_lines() + self.assertIsNone(lines[-1]) + self.assertIsNone(self._decode("cursor", lines, stdout_tail=tail.text())) + class AgentExecHistoryTests(unittest.TestCase): def setUp(self) -> None: @@ -1779,7 +2740,7 @@ class AgentExecHistoryTests(unittest.TestCase): self.repo, profiles={ "writer": { - "adapter": "custom", + "adapter": "fake", "executable": "history-writer", } }, @@ -1805,7 +2766,7 @@ class AgentExecHistoryTests(unittest.TestCase): self.repo, profiles={ "missing": { - "adapter": "custom", + "adapter": "fake", "executable": "missing-history-cli", } }, @@ -1819,7 +2780,7 @@ class AgentExecHistoryTests(unittest.TestCase): unit_path=fallback_unit, receipt_path=fallback_receipt, ) - self.assertEqual(fallback_code, agent_exec.EXIT_HOST_FALLBACK) + self.assertEqual(fallback_code, agent_exec.EXIT_UNAVAILABLE_EXHAUSTION) text = self.history_path.read_text(encoding="utf-8") self.assertNotIn(secret, text) @@ -1836,15 +2797,15 @@ class AgentExecHistoryTests(unittest.TestCase): self.assertEqual(success["attempts"][0]["fresh_session"], True) self.assertEqual(success["attempts"][0]["session_id"], "not-observable") self.assertEqual(success["attempts"][0]["model_requested"], "auto") - self.assertEqual(fallback["terminal_status"], "host-fallback-authorized") - self.assertEqual(fallback["host_fallback_authorizations"], 1) + self.assertEqual(fallback["terminal_status"], "unavailable") + self.assertEqual(fallback["host_fallback_authorizations"], 0) def test_history_failure_does_not_change_run_outcome(self) -> None: _write_config( self.repo, profiles={ "missing": { - "adapter": "custom", + "adapter": "fake", "executable": "missing-history-cli", } }, @@ -1864,11 +2825,12 @@ class AgentExecHistoryTests(unittest.TestCase): unit_path=unit, receipt_path=receipt_path, ) - self.assertEqual(code, agent_exec.EXIT_HOST_FALLBACK) + self.assertEqual(code, agent_exec.EXIT_UNAVAILABLE_EXHAUSTION) receipt_text = receipt_path.read_text(encoding="utf-8") self.assertNotIn(secret, receipt_text) receipt = json.loads(receipt_text) - self.assertEqual(receipt["terminal_status"], "host-fallback-authorized") + self.assertEqual(receipt["terminal_status"], "unavailable") + self.assertEqual(receipt.get("host_fallback_authorizations"), 0) self.assertEqual(receipt["history"]["status"], "failed") def test_invalid_config_also_appends_one_history_record(self) -> None: @@ -1894,6 +2856,125 @@ class AgentExecHistoryTests(unittest.TestCase): self.assertEqual(records[0]["terminal_status"], "invalid-config") self.assertEqual(records[0]["attempts"], []) + def _run_marker_success(self, name: str) -> tuple[Path, dict[str, object]]: + _write_fake_worker( + self.bindir, + name, + body=textwrap.dedent( + """\ + workspace="$1" + result="$3" + mkdir -p "$workspace/src" + printf 'ok\\n' > "$workspace/src/marker.txt" + printf '%s\\n' '{"terminal_status":"completed","summary":"done","changed_files":["src/marker.txt"],"evidence":[],"scope_expansion":null}' > "$result" + exit 0 + """ + ), + ) + _write_config( + self.repo, + profiles={name: {"adapter": "fake", "executable": name}}, + role_order=[name], + ) + unit = _write_unit(self.root / f"{name}.json", unit_id=f"unit-{name}") + receipt_path = self.receipts / f"{name}.json" + code = agent_exec.cmd_run( + self.repo, + role="implementation", + unit_path=unit, + receipt_path=receipt_path, + ) + receipt_text = ( + receipt_path.read_text(encoding="utf-8") if receipt_path.is_file() else "" + ) + self.assertEqual(code, agent_exec.EXIT_SUCCESS, receipt_text) + return receipt_path, json.loads(receipt_text) + + def test_success_history_is_mode_correct_and_signed(self) -> None: + receipt_path, receipt = self._run_marker_success("signedhistory") + key_path = self.history_path.parent / "attestation.key" + self.assertTrue(key_path.is_file()) + self.assertFalse(key_path.is_symlink()) + self.assertFalse(key_path.parent.is_symlink()) + self.assertEqual(stat.S_IMODE(key_path.parent.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(key_path.stat().st_mode), 0o600) + key = key_path.read_bytes() + self.assertEqual(len(key), 32) + self.assertNotIn(key, receipt_path.read_bytes()) + self.assertNotIn(key, self.history_path.read_bytes()) + + records = [ + json.loads(line) + for line in self.history_path.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual(len(records), 1) + record = records[0] + transport = record["transport"] + ref = transport["ref"] + commit = _git(self.repo, "rev-parse", ref).stdout.strip() + self.assertEqual(record["terminal_status"], "success") + self.assertEqual(record["run_id"], receipt["run_id"]) + self.assertEqual(transport["parent"], receipt["transport"]["parent"]) + self.assertEqual(transport["commit"], commit) + self.assertEqual(receipt["transport"]["commit"], commit) + self.assertEqual(record["changed_paths"], receipt["changed_paths"]) + body = {field: value for field, value in record.items() if field != "attestation"} + payload = json.dumps( + body, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + self.assertEqual( + hmac.new(key, payload, hashlib.sha256).hexdigest(), + record["attestation"], + ) + identity = receipt["history"] + self.assertEqual(identity["status"], "recorded") + self.assertEqual(identity["run_id"], receipt["run_id"]) + self.assertEqual(identity["record_id"], record["record_id"]) + self.assertEqual(identity["local_integrity_mac"], record["attestation"]) + self.assertNotIn("attestation", identity) + self.assertNotIn(key.hex(), json.dumps(identity)) + + def test_attestation_key_symlink_is_not_followed(self) -> None: + history_dir = self.history_path.parent + history_dir.mkdir(mode=0o700) + outside = self.root / "outside-key" + outside.write_bytes(b"A" * 32) + key_path = history_dir / "attestation.key" + key_path.symlink_to(outside) + receipt_path, receipt = self._run_marker_success("symlinkkey") + self.assertTrue(key_path.is_symlink()) + self.assertEqual(outside.read_bytes(), b"A" * 32) + self.assertEqual(receipt["history"]["status"], "failed") + self.assertNotIn(b"A" * 32, receipt_path.read_bytes()) + + def test_attestation_key_first_run_race_shares_one_key(self) -> None: + found: list[bytes] = [] + errors: list[BaseException] = [] + barrier = threading.Barrier(8) + + def load() -> None: + try: + barrier.wait(timeout=5) + found.append(agent_exec._ensure_local_integrity_key(self.repo)) + except BaseException as exc: # noqa: BLE001 - race failures are the assertion + errors.append(exc) + + threads = [threading.Thread(target=load) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(len(found), 8) + self.assertEqual(len({item for item in found}), 1) + self.assertEqual(len(found[0]), 32) + key_path = self.history_path.parent / "attestation.key" + self.assertFalse(key_path.is_symlink()) + self.assertEqual(key_path.read_bytes(), found[0]) + self.assertEqual(stat.S_IMODE(key_path.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(key_path.parent.stat().st_mode), 0o700) + # Opt-in real CLI probes (U5). Default unittest must not contact providers. U5_PROFILES = ( @@ -1914,11 +2995,16 @@ U5_EXECUTABLE = { "kimi": "kimi", "opencode": "opencode", } +# Normal roles need exactly one enabled cursor, antigravity, and PI profile and +# reject every other adapter. The probe runs its one selected profile alone +# from review; normal roles keep only uninvoked holders. +_PROBE_NORMAL_HOLDERS = [_holder_name(adapter) for adapter in _NORMAL_ADAPTER_ORDER] U5_BRANCH = "feature/u5-real-probe" U5_MARKER_REL = "src/u5-marker.txt" U5_MARKER_BODY = "U5-REAL-PROBE-MARKER\n" +# Envelope adapters JSON-escape the final text, so quotes may be backslashed. _MODEL_RECEIPT_RE = re.compile( - r'"model_receipt_status"\s*:\s*"(verified|not-observable|mismatch|unavailable)"' + r'\\?"model_receipt_status\\?"\s*:\s*\\?"(verified|not-observable|mismatch|unavailable)\\?"' ) @@ -2025,18 +3111,46 @@ def _marker_run_body(*, body: str, model_receipt: str | None) -> str: } if model_receipt is not None: payload["model_receipt_status"] = model_receipt - encoded = json.dumps(payload, separators=(",", ":")) + final_text = "Wrote the marker.\n" + json.dumps(payload, separators=(",", ":")) + events = _cursor_stream_events(final_text) writer = ( "from pathlib import Path; " f"p=Path({json.dumps(U5_MARKER_REL)}); " "p.parent.mkdir(parents=True); " f"p.write_text({json.dumps(body)}, encoding='utf-8')" ) - return ( - f"python3 -c {shlex.quote(writer)}\n" - f"printf '%s\\n' {shlex.quote(encoded)}\n" - "exit 0\n" - ) + printed = "".join(f"printf '%s\\n' {shlex.quote(line)}\n" for line in events) + return f"python3 -c {shlex.quote(writer)}\n" + printed + "exit 0\n" + + +def _cursor_stream_events(final_text: str, *, is_error: bool = False) -> list[str]: + """Representative Cursor ``--output-format stream-json`` lines.""" + session = "00000000-0000-4000-8000-000000000000" + events: list[dict[str, object]] = [ + {"type": "system", "subtype": "init", "session_id": session}, + { + "type": "user", + "message": {"role": "user", "content": [{"type": "text", "text": "unit"}]}, + "session_id": session, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": final_text}], + }, + "session_id": session, + }, + { + "type": "result", + "subtype": "error" if is_error else "success", + "duration_ms": 10, + "is_error": is_error, + "result": final_text, + "session_id": session, + }, + ] + return [json.dumps(event, separators=(",", ":")) for event in events] def _write_probe_unit(path: Path, timeout_seconds: int) -> None: @@ -2079,6 +3193,18 @@ def _cleanup_probe_runtime(repo: Path, receipt: dict[str, object] | None) -> Non shutil.rmtree(run_dir, ignore_errors=True) +def _marker_probe_config( + profile: str, +) -> tuple[dict[str, dict[str, object]], list[str], list[str] | None, str]: + """Run exactly the selected profile from review; normal roles get holders.""" + selected = { + "adapter": profile, + "executable": U5_EXECUTABLE[profile], + "model": "auto", + } + return {profile: selected}, [], [profile], "review" + + def execute_marker_probe( profile: str, *, @@ -2131,16 +3257,12 @@ def execute_marker_probe( raise RuntimeError( f"probe fixture must use non-default branch {U5_BRANCH}, got {branch}" ) + profiles, role_order, review_order, probe_role = _marker_probe_config(profile) _write_config( repo, - profiles={ - profile: { - "adapter": profile, - "executable": U5_EXECUTABLE[profile], - "model": "auto", - } - }, - role_order=[profile], + profiles=profiles, + role_order=role_order, + review_order=review_order, timeout_seconds=timeout_seconds, summary_bytes=4096, raw_output_bytes=65536, @@ -2166,7 +3288,7 @@ def execute_marker_probe( "--repo", str(repo), "--role", - "implementation", + probe_role, "--unit", str(unit), "--receipt", @@ -2210,6 +3332,32 @@ def execute_marker_probe( repo_dir.cleanup() +def _probe_config_mismatch( + profile: str, + profiles: object, + roles: object, +) -> str | None: + """Reject a fixture that does not isolate the selected adapter. + + The selected profile is the whole review role. Implementation, repair, + and test hold only the uninvoked cursor/antigravity/PI holders, so strict + role validation passes and the selected CLI can reach PASS. + """ + if not isinstance(profiles, dict) or not isinstance(roles, dict): + return "probe configured a profile other than the selection" + if set(profiles) != {profile, *_PROBE_NORMAL_HOLDERS}: + return "probe configured a profile other than the selection" + for role in ("implementation", "repair", "test"): + if roles.get(role) != _PROBE_NORMAL_HOLDERS: + return f"{role} role must hold only the uninvoked normal holders" + if roles.get("review") != [profile]: + return "selected profile must run alone from the review role" + selected = profiles.get(profile) + if not isinstance(selected, dict) or selected.get("adapter") != profile: + return "probe configured a profile other than the selection" + return None + + def _classify_marker_probe( *, profile: str, @@ -2256,18 +3404,15 @@ def _classify_marker_probe( summary = receipt.get("config_summary") profiles = summary.get("profiles") if isinstance(summary, dict) else None roles = summary.get("roles") if isinstance(summary, dict) else None - if not isinstance(profiles, dict) or set(profiles) != {profile}: + mismatch = _probe_config_mismatch(profile, profiles, roles) + if mismatch is not None: base["capability"] = "failed" - base["reason"] = "probe configured a profile other than the selection" + base["reason"] = mismatch return base + assert isinstance(profiles, dict) selected = profiles[profile] if isinstance(selected, dict): base["configured_model"] = selected.get("model") - implementation = roles.get("implementation") if isinstance(roles, dict) else None - if implementation != [profile]: - base["capability"] = "failed" - base["reason"] = "implementation role is not exactly the selected profile" - return base recorded_base = receipt.get("base") if isinstance(recorded_base, dict): base["branch"] = recorded_base.get("branch") @@ -2487,8 +3632,8 @@ class RealAgentCliProbeTests(unittest.TestCase): self.assertEqual(outcome["capability"], "unavailable", outcome) self.assertNotEqual(outcome["capability"], "PASS") self.assertEqual(outcome["attempt_status"], "unavailable") - self.assertEqual(outcome["run_terminal"], "host-fallback-authorized") - self.assertEqual(outcome["host_fallback_authorizations"], 1) + self.assertEqual(outcome["run_terminal"], "unavailable") + self.assertEqual(outcome["host_fallback_authorizations"], 0) self.assertFalse(outcome["marker_in_canonical"]) self.assertTrue(outcome["canonical_preserved"]) cleanup = outcome["cleanup"] diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py new file mode 100644 index 00000000..62505baa --- /dev/null +++ b/tests/test_agent_exec_admission.py @@ -0,0 +1,739 @@ +"""Provenance admission: apply a success transport, or admit exact staged paths.""" +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import shutil +import subprocess +import tempfile +import unittest +import uuid +from pathlib import Path + +from scripts import agent_exec + + +SOURCE = Path(__file__).resolve().parents[1] + + +def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=check, + capture_output=True, + text=True, + ) + + +class AgentExecAdmissionTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-admit-") + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.repo = self.root / "repo" + self.repo.mkdir() + _git(self.repo, "init", "-b", "main") + _git(self.repo, "config", "user.email", "agent-exec@example.com") + _git(self.repo, "config", "user.name", "Agent Exec") + (self.repo / "README.md").write_text("fixture\n", encoding="utf-8") + _git(self.repo, "add", "README.md") + _git(self.repo, "commit", "-m", "init") + _git(self.repo, "checkout", "-b", "feature/admission-test") + self.head = _git(self.repo, "rev-parse", "HEAD").stdout.strip() + + def _cli(self, *args: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["PYTHONPATH"] = str(SOURCE) + return subprocess.run( + ["python3", "-m", "scripts.agent_exec", *args], + cwd=str(self.root), + capture_output=True, + text=True, + env=env, + ) + + def _state(self) -> dict[str, str]: + return { + "head": _git(self.repo, "rev-parse", "HEAD").stdout.strip(), + "branch": _git(self.repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip(), + "status": _git( + self.repo, "status", "--porcelain", "--untracked-files=all" + ).stdout, + "index": _git(self.repo, "ls-files", "--stage").stdout, + "readme": (self.repo / "README.md").read_text(encoding="utf-8"), + } + + def _transport_commit( + self, + files: dict[str, str], + *, + parent: str | None = None, + ref: str | None = None, + ) -> tuple[str, str]: + parent = parent or _git(self.repo, "rev-parse", "HEAD").stdout.strip() + ref = ref or f"refs/agent-exec/{uuid.uuid4().hex}/attempt-1" + index = self.root / f"index-{uuid.uuid4().hex}" + env = os.environ.copy() + env["GIT_INDEX_FILE"] = str(index) + subprocess.run( + ["git", "-C", str(self.repo), "read-tree", parent], + check=True, + env=env, + capture_output=True, + text=True, + ) + for rel, content in files.items(): + blob = subprocess.run( + ["git", "-C", str(self.repo), "hash-object", "-w", "--stdin"], + input=content, + text=True, + check=True, + capture_output=True, + ).stdout.strip() + subprocess.run( + [ + "git", + "-C", + str(self.repo), + "update-index", + "--add", + "--cacheinfo", + f"100644,{blob},{rel}", + ], + check=True, + env=env, + capture_output=True, + text=True, + ) + tree = subprocess.run( + ["git", "-C", str(self.repo), "write-tree"], + check=True, + env=env, + capture_output=True, + text=True, + ).stdout.strip() + commit = subprocess.run( + [ + "git", + "-C", + str(self.repo), + "-c", + "user.name=agent-exec-transport", + "-c", + "user.email=agent-exec-transport@localhost.invalid", + "commit-tree", + tree, + "-p", + parent, + "-m", + "agent-exec transport", + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + _git(self.repo, "update-ref", ref, commit) + index.unlink(missing_ok=True) + return commit, ref + + def _receipt( + self, + *, + parent: str, + ref: str | None, + paths: list[str], + status: str = "success", + repo: str | None = None, + reason: str | None = None, + transport_parent: str | None = None, + omit_transport: bool = False, + run_id: str | None = None, + commit: str | None = None, + ) -> Path: + data: dict[str, object] = { + "schema": agent_exec.RECEIPT_SCHEMA, + "repo": repo if repo is not None else str(self.repo.resolve()), + "terminal_status": status, + "reason": reason, + "base": { + "head": parent, + "tree": _git(self.repo, "rev-parse", f"{parent}^{{tree}}").stdout.strip(), + "branch": "feature/admission-test", + }, + "changed_paths": paths, + } + if run_id is not None: + data["run_id"] = run_id + if not omit_transport: + transport: dict[str, object] = { + "ref": ref, + "parent": parent if transport_parent is None else transport_parent, + "attempt_id": "attempt-1", + } + if commit is not None: + transport["commit"] = commit + data["transport"] = transport + path = self.root / f"receipt-{uuid.uuid4().hex}.json" + path.write_text(json.dumps(data), encoding="utf-8") + return path + + def _common_dir(self) -> Path: + return Path( + _git( + self.repo, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ).stdout.strip() + ) + + def _history_path(self) -> Path: + return self._common_dir() / "agent-exec" / "execution-history.jsonl" + + def _attestation_key(self) -> bytes: + dest = self._common_dir() / "agent-exec" + dest.mkdir(mode=0o700, exist_ok=True) + os.chmod(dest, 0o700) + path = dest / "attestation.key" + if path.is_symlink(): + raise RuntimeError("test attestation key must not be a symlink") + if path.exists(): + key = path.read_bytes() + if len(key) != 32: + raise RuntimeError("test attestation key must be 32 bytes") + return key + key = os.urandom(32) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + os.write(fd, key) + os.fchmod(fd, 0o600) + finally: + os.close(fd) + return key + + @staticmethod + def _sign_history_record(record: dict[str, object], key: bytes) -> dict[str, object]: + body = {field: value for field, value in record.items() if field != "attestation"} + payload = json.dumps( + body, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + signed = dict(body) + signed["attestation"] = hmac.new(key, payload, hashlib.sha256).hexdigest() + return signed + + def _history( + self, + *, + parent: str, + ref: str, + paths: list[str], + status: str = "success", + repo: str | None = None, + commit: str | None = None, + run_id: str | None = None, + sign: bool | None = None, + ) -> dict[str, object]: + if commit is None: + commit = _git(self.repo, "rev-parse", f"{ref}^{{commit}}").stdout.strip() + identity = run_id or uuid.uuid4().hex + record: dict[str, object] = { + "schema": agent_exec.HISTORY_SCHEMA, + "terminal_status": status, + "record_id": identity, + "run_id": identity, + "repo": repo if repo is not None else str(self.repo.resolve()), + "changed_paths": paths, + "transport": {"ref": ref, "parent": parent, "commit": commit}, + } + if sign is None: + sign = status == "success" + if sign: + record = self._sign_history_record(record, self._attestation_key()) + dest = self._history_path().parent + dest.mkdir(mode=0o700, exist_ok=True) + os.chmod(dest, 0o700) + line = json.dumps( + record, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + with self._history_path().open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + return record + + def _reset_branch(self) -> None: + _git(self.repo, "reset", "--hard", self.head) + _git(self.repo, "clean", "-fd") + + def _stage(self, rel: str, content: str) -> None: + path = self.repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + _git(self.repo, "add", "--", rel) + + def test_apply_fast_forwards_clean_matching_transport(self) -> None: + commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"}) + run_id = uuid.uuid4().hex + signed = self._history( + parent=self.head, + ref=ref, + paths=["src/marker.txt"], + commit=commit, + run_id=run_id, + ) + receipt = self._receipt( + parent=self.head, + ref=ref, + paths=["src/marker.txt"], + run_id=run_id, + commit=commit, + ) + self.assertEqual(signed["transport"]["commit"], commit) + self.assertIn("attestation", signed) + proc = self._cli( + "apply", "--repo", str(self.repo), "--receipt", str(receipt) + ) + self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + self.assertIn("apply: fast-forwarded", proc.stdout) + self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout.strip(), commit) + self.assertEqual( + _git(self.repo, "rev-parse", "feature/admission-test").stdout.strip(), + commit, + ) + self.assertEqual((self.repo / "src/marker.txt").read_text(encoding="utf-8"), "shipped\n") + self.assertEqual(_git(self.repo, "status", "--porcelain").stdout, "") + parents = _git(self.repo, "rev-list", "--parents", "-n", "1", "HEAD").stdout.split() + self.assertEqual(parents, [commit, self.head]) + + def test_apply_rejects_without_changing_state(self) -> None: + _commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"}) + valid = dict(parent=self.head, ref=ref, paths=["src/marker.txt"]) + + def reject(label: str, receipt: Path) -> None: + before = self._state() + proc = self._cli( + "apply", "--repo", str(self.repo), "--receipt", str(receipt) + ) + self.assertEqual( + proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout + ) + self.assertIn(label, proc.stderr) + self.assertNotIn("apply: fast-forwarded", proc.stdout) + self.assertEqual(self._state(), before) + + with self.subTest(label="stale HEAD"): + (self.repo / "advance.txt").write_text("advance\n", encoding="utf-8") + _git(self.repo, "add", "advance.txt") + _git(self.repo, "commit", "-m", "advance") + reject("stale HEAD", self._receipt(**valid)) + _git(self.repo, "reset", "--hard", self.head) + + with self.subTest(label="dirty worktree"): + (self.repo / "README.md").write_text("dirty\n", encoding="utf-8") + reject("dirty worktree", self._receipt(**valid)) + _git(self.repo, "checkout", "--", "README.md") + + with self.subTest(label="non-success receipt"): + reject( + "non-success receipt", + self._receipt(**valid, status="failed-unknown"), + ) + + with self.subTest(label="missing transport ref"): + reject( + "missing transport", + self._receipt( + parent=self.head, + ref="refs/agent-exec/missing/attempt", + paths=["src/marker.txt"], + ), + ) + + with self.subTest(label="omitted transport"): + reject( + "missing transport", + self._receipt( + parent=self.head, + ref=None, + paths=["src/marker.txt"], + omit_transport=True, + ), + ) + + with self.subTest(label="mismatched transport"): + side = _git( + self.repo, + "commit-tree", + _git(self.repo, "rev-parse", "HEAD^{tree}").stdout.strip(), + "-p", + self.head, + "-m", + "side", + ).stdout.strip() + _mismatch_commit, mismatch_ref = self._transport_commit( + {"src/marker.txt": "other\n"}, parent=side + ) + reject( + "mismatched transport", + self._receipt( + parent=self.head, ref=mismatch_ref, paths=["src/marker.txt"] + ), + ) + + with self.subTest(label="receipt repo mismatch"): + reject( + "receipt repo mismatch", + self._receipt( + **valid, repo=str((self.root / "other-repo").resolve()) + ), + ) + + def test_apply_rejects_fabricated_unsigned_or_tampered_history(self) -> None: + commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"}) + run_id = uuid.uuid4().hex + paths = ["src/marker.txt"] + + def reject(label: str) -> None: + self._reset_branch() + before = self._state() + receipt = self._receipt( + parent=self.head, + ref=ref, + paths=paths, + run_id=run_id, + commit=commit, + ) + proc = self._cli( + "apply", "--repo", str(self.repo), "--receipt", str(receipt) + ) + combined = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined) + self.assertIn("apply: no local integrity record", proc.stderr) + self.assertNotIn("apply: fast-forwarded", proc.stdout) + self.assertEqual(self._state(), before, label) + + with self.subTest(label="fabricated receipt"): + reject("fabricated receipt") + + with self.subTest(label="unsigned history"): + self._history( + parent=self.head, + ref=ref, + paths=paths, + commit=commit, + run_id=run_id, + sign=False, + ) + reject("unsigned history") + + with self.subTest(label="tampered history"): + self._reset_branch() + signed = self._history( + parent=self.head, + ref=ref, + paths=paths, + commit=commit, + run_id=run_id, + ) + history_path = self._history_path() + lines = history_path.read_text(encoding="utf-8").splitlines() + tampered = json.loads(lines[-1]) + self.assertEqual(tampered["attestation"], signed["attestation"]) + tampered["record_id"] = "tampered-record" + lines[-1] = json.dumps( + tampered, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + history_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + reject("tampered history") + + def test_apply_redacts_secret_shaped_receipt_text(self) -> None: + _commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"}) + secret = "sk-admissionsecret" + receipt = self._receipt( + parent=self.head, + ref=ref, + paths=["src/marker.txt"], + status="failed-unknown", + reason=secret, + ) + before = self._state() + proc = self._cli("apply", "--repo", str(self.repo), "--receipt", str(receipt)) + combined = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined) + self.assertIn("non-success receipt", proc.stderr) + self.assertNotIn(secret, combined) + self.assertIn("***", combined) + self.assertEqual(self._state(), before) + + def test_admit_rejects_staged_paths_without_provenance(self) -> None: + self._stage("src/extra.txt", "unproven\n") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) + self.assertIn("admit: no local integrity record", proc.stderr) + self.assertEqual(self._state(), before) + + def test_admit_accepts_exact_transport_paths(self) -> None: + _commit, ref = self._transport_commit( + {"src/marker.txt": "shipped\n", "src/other.txt": "also\n"} + ) + self._history( + parent=self.head, + ref=ref, + paths=["src/marker.txt", "src/other.txt"], + ) + self._stage("src/marker.txt", "shipped\n") + self._stage("src/other.txt", "also\n") + self._stage(".task/feature-admission.md", "host card\n") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + self.assertIn("admit: ok", proc.stdout) + self.assertEqual(self._state(), before) + + self._git_reset_index() + self._stage("src/marker.txt", "shipped\n") + partial = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(partial.returncode, agent_exec.EXIT_BLOCKED, partial.stderr) + self.assertIn("admit: no local integrity record", partial.stderr) + + def test_admit_rejects_tampered_record_and_moved_ref(self) -> None: + commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"}) + signed = self._history(parent=self.head, ref=ref, paths=["src/marker.txt"], commit=commit) + history_path = self._history_path() + lines = history_path.read_text(encoding="utf-8").splitlines() + tampered = json.loads(lines[-1]) + self.assertEqual(tampered["attestation"], signed["attestation"]) + tampered["record_id"] = "tampered-record" + lines[-1] = json.dumps( + tampered, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + history_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + self._stage("src/marker.txt", "shipped\n") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + combined = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined) + self.assertIn("admit: no local integrity record", proc.stderr) + self.assertNotIn("admit: ok", proc.stdout) + self.assertEqual(self._state(), before) + + self._reset_branch() + tree = _git(self.repo, "rev-parse", f"{commit}^{{tree}}").stdout.strip() + moved = _git( + self.repo, + "commit-tree", + tree, + "-p", + self.head, + "-m", + "moved ref", + ).stdout.strip() + self.assertNotEqual(moved, commit) + restored = self._sign_history_record( + { + "schema": agent_exec.HISTORY_SCHEMA, + "terminal_status": "success", + "record_id": signed["record_id"], + "run_id": signed["run_id"], + "repo": str(self.repo.resolve()), + "changed_paths": ["src/marker.txt"], + "transport": {"commit": commit, "parent": self.head, "ref": ref}, + }, + self._attestation_key(), + ) + history_path.write_text( + json.dumps( + restored, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + + "\n", + encoding="utf-8", + ) + _git(self.repo, "update-ref", ref, moved) + self._stage("src/marker.txt", "shipped\n") + moved_before = self._state() + moved_proc = self._cli("admit", "--repo", str(self.repo)) + moved_text = moved_proc.stdout + moved_proc.stderr + self.assertEqual(moved_proc.returncode, agent_exec.EXIT_BLOCKED, moved_text) + self.assertIn("admit: no local integrity record", moved_proc.stderr) + self.assertNotIn("admit: ok", moved_proc.stdout) + self.assertEqual(self._state(), moved_before) + self.assertEqual(_git(self.repo, "rev-parse", ref).stdout.strip(), moved) + + def test_admit_rejects_different_content_at_transport_path(self) -> None: + _commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"}) + self._history(parent=self.head, ref=ref, paths=["src/marker.txt"]) + self._stage("src/marker.txt", "rewritten\n") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) + self.assertIn("admit: no local integrity record", proc.stderr) + self.assertEqual(self._state(), before) + + def test_rename_and_copy_records_keep_non_exempt_source(self) -> None: + """Copy/rename records must not drop a non-exempt source behind an exempt dest.""" + paths = agent_exec._name_status_paths( + "R100\tsrc/secret.txt\t.task/from-rename.md\n" + "C100\tlib/outside.txt\tdocs/superpowers/plans/from-copy.md\n" + ) + self.assertEqual( + agent_exec._non_exempt(paths), + {"src/secret.txt", "lib/outside.txt"}, + ) + + def test_admit_rejects_rename_of_non_exempt_source_into_exempt_path(self) -> None: + (self.repo / "src").mkdir() + (self.repo / "src" / "secret.txt").write_text("secret\n", encoding="utf-8") + _git(self.repo, "add", "src/secret.txt") + _git(self.repo, "commit", "-m", "add secret") + (self.repo / ".task").mkdir() + _git(self.repo, "mv", "src/secret.txt", ".task/hidden.md") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + combined = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined) + self.assertIn("src/secret.txt", proc.stderr) + self.assertNotIn("admit: ok", proc.stdout) + self.assertEqual(self._state(), before) + + def test_admit_accepts_host_managed_exemptions(self) -> None: + self._stage(".task/feature-admission.md", "card\n") + self._stage("docs/superpowers/specs/admission.md", "spec\n") + self._stage("docs/superpowers/plans/admission.md", "plan\n") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + self.assertIn("admit: ok", proc.stdout) + self.assertEqual(self._state(), before) + + self._stage("docs/superpowers/specs-extra/not-exempt.md", "nope\n") + rejected = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(rejected.returncode, agent_exec.EXIT_BLOCKED, rejected.stderr) + self.assertIn("admit: no local integrity record", rejected.stderr) + + def test_whitespace_paths_are_classified_exactly(self) -> None: + non_exempt = ( + " .task/card.md", + "\t.task/card.md", + " docs/superpowers/specs/a.md", + "docs/superpowers/specs /a.md", + "docs/superpowers/plans /a.md", + ".task /card.md", + ) + for path in non_exempt: + with self.subTest(path=path): + self.assertFalse(agent_exec._host_managed(path)) + self.assertEqual(agent_exec._non_exempt([path]), {path}) + for path in ( + ".task/card.md ", + "docs/superpowers/specs/a.md ", + "docs/superpowers/plans/ a.md", + ): + with self.subTest(path=path): + self.assertTrue(agent_exec._host_managed(path)) + self.assertEqual( + agent_exec._non_exempt(["src/a.txt ", " src/b.txt"]), + {"src/a.txt ", " src/b.txt"}, + ) + self.assertEqual( + agent_exec._name_status_paths("A\t .task/card.md\nA\tsrc/a.txt \n"), + [" .task/card.md", "src/a.txt "], + ) + + def test_admit_rejects_whitespace_lookalikes_of_exempt_paths(self) -> None: + for path in ( + " .task/card.md", + "docs/superpowers/specs /a.md", + "docs/superpowers/plans /a.md", + ): + with self.subTest(path=path): + self._reset_branch() + self._stage(path, "lookalike\n") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + combined = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined) + self.assertIn(f"admit: no local integrity record for {path}", proc.stderr) + self.assertNotIn("admit: ok", proc.stdout) + self.assertEqual(self._state(), before) + + def test_admit_covers_whitespace_path_only_with_exact_transport(self) -> None: + exact = "src/marker.txt " + _commit, ref = self._transport_commit({exact: "shipped\n"}) + self._history(parent=self.head, ref=ref, paths=[exact]) + self._stage(exact, "shipped\n") + before = self._state() + proc = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + self.assertIn("admit: ok", proc.stdout) + self.assertEqual(self._state(), before) + + self._reset_branch() + self._history_path().unlink() + _commit, trimmed_ref = self._transport_commit({"src/marker.txt": "shipped\n"}) + self._history(parent=self.head, ref=trimmed_ref, paths=["src/marker.txt"]) + self._stage(exact, "shipped\n") + rejected = self._cli("admit", "--repo", str(self.repo)) + self.assertEqual(rejected.returncode, agent_exec.EXIT_BLOCKED, rejected.stderr) + self.assertIn("admit: no local integrity record", rejected.stderr) + + def test_precommit_rejects_unproven_paths_after_branch_checks(self) -> None: + self._install_project_hook() + self._stage("src/extra.txt", "unproven\n") + proc = self._hook() + combined = proc.stdout + proc.stderr + self.assertNotEqual(proc.returncode, 0, combined) + self.assertIn("admit: no local integrity record", combined) + + _git(self.repo, "checkout", "-B", "main", self.head) + self._stage("src/extra.txt", "unproven\n") + blocked = self._hook() + text = blocked.stdout + blocked.stderr + self.assertNotEqual(blocked.returncode, 0, text) + self.assertIn("集成分支", text) + self.assertNotIn("admit:", text) + + def test_precommit_accepts_host_managed_exemptions(self) -> None: + self._install_project_hook() + self._stage("docs/superpowers/plans/admission.md", "plan\n") + proc = self._hook() + self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout) + self.assertIn("admit: ok", proc.stdout) + + def test_make_agent_exec_apply_requires_receipt(self) -> None: + env = os.environ.copy() + env.pop("RECEIPT", None) + proc = subprocess.run( + ["make", "agent-exec-apply"], + cwd=str(SOURCE), + capture_output=True, + text=True, + env=env, + ) + combined = proc.stdout + proc.stderr + self.assertNotEqual(proc.returncode, 0, combined) + self.assertIn("RECEIPT", combined) + self.assertNotIn("apply: fast-forwarded", combined) + + def _git_reset_index(self) -> None: + _git(self.repo, "reset", "--hard", self.head) + _git(self.repo, "clean", "-fd") + + def _install_project_hook(self) -> None: + dest = self.repo / "scripts" + hooks = dest / "hooks" + hooks.mkdir(parents=True) + for name in ("agent_exec.py", "agent_exec_config.py", "agent_exec_adapters.py"): + shutil.copy(SOURCE / "scripts" / name, dest / name) + hook = hooks / "pre-commit" + shutil.copy(SOURCE / "scripts" / "hooks" / "pre-commit", hook) + hook.chmod(0o755) + + def _hook(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(self.repo / "scripts" / "hooks" / "pre-commit")], + cwd=str(self.repo), + capture_output=True, + text=True, + ) diff --git a/tests/test_agent_exec_config.py b/tests/test_agent_exec_config.py index 1599c130..e6f2c88a 100644 --- a/tests/test_agent_exec_config.py +++ b/tests/test_agent_exec_config.py @@ -31,10 +31,10 @@ MINIMAL_BLOCK = textwrap.dedent( raw_output_bytes: 10485760 roles: - implementation: [cursor, claude] - repair: [cursor] - test: [claude] - review: [claude] + implementation: [cursor, antigravity, pi] + repair: [cursor, antigravity, pi] + test: [pi, antigravity, cursor] + review: [pi] profiles: cursor: @@ -43,9 +43,15 @@ MINIMAL_BLOCK = textwrap.dedent( model: auto enabled: true - claude: - adapter: claude - executable: claude + antigravity: + adapter: antigravity + executable: agy + model: auto + enabled: true + + pi: + adapter: pi + executable: pi model: auto enabled: true """ @@ -76,7 +82,7 @@ class AgentExecConfigTests(unittest.TestCase): 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.roles["implementation"], ("cursor", "antigravity", "pi")) self.assertEqual(cfg.profiles["cursor"].executable, "cursor-agent") self.assertEqual(cfg.custom_adapters, {}) self.assertEqual(validate_config(self.repo), []) @@ -112,7 +118,7 @@ class AgentExecConfigTests(unittest.TestCase): 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 + # profiles listed pi before cursor; role order must stay cursor -> pi block = textwrap.dedent( """\ schema: agent-execution/v1 @@ -127,15 +133,20 @@ class AgentExecConfigTests(unittest.TestCase): raw_output_bytes: 4096 roles: - implementation: [cursor, claude] - repair: [cursor] - test: [claude] - review: [claude] + implementation: [cursor, antigravity, pi] + repair: [cursor, antigravity, pi] + test: [cursor, antigravity, pi] + review: [pi] profiles: - claude: - adapter: claude - executable: claude + pi: + adapter: pi + executable: pi + model: auto + enabled: true + antigravity: + adapter: antigravity + executable: agy model: auto enabled: true cursor: @@ -147,19 +158,19 @@ class AgentExecConfigTests(unittest.TestCase): ) 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") + self.assertEqual(cfg.roles["implementation"], ("cursor", "antigravity", "pi")) + self.assertEqual(list(cfg.profiles.keys())[0], "pi") 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) + self.assertIsNone(cfg.profiles["pi"].model) def test_rejects_unknown_profile_reference(self) -> None: block = MINIMAL_BLOCK.replace( - "implementation: [cursor, claude]", - "implementation: [cursor, claude, missing]", + "implementation: [cursor, antigravity, pi]", + "implementation: [cursor, antigravity, pi, missing]", ) write_repo(self.repo, block) with self.assertRaises(ConfigError): @@ -175,15 +186,15 @@ class AgentExecConfigTests(unittest.TestCase): 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:", + "enabled: true\n\n pi:", + "enabled: true\n api_token: \"sk-secret-value\"\n\n pi:", ) write_repo(self.repo, secret_key) with self.assertRaises(ConfigError): load_config(self.repo) secret_val = MINIMAL_BLOCK.replace( - "executable: claude", + "executable: pi", "executable: \"Bearer abcdef0123456789abcdef0123456789\"", ) write_repo(self.repo, secret_val) @@ -191,26 +202,9 @@ class AgentExecConfigTests(unittest.TestCase): load_config(self.repo) def test_custom_placeholders_must_fill_whole_argv_items(self) -> None: - good = textwrap.dedent( + good = MINIMAL_BLOCK.replace("review: [pi]", "review: [mycustom]") + 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 @@ -320,20 +314,20 @@ class AgentExecConfigTests(unittest.TestCase): def test_quoted_unicode_string_round_trips(self) -> None: block = MINIMAL_BLOCK.replace( - "executable: claude", + "executable: pi", 'executable: "模型-β-测试"', ) write_repo(self.repo, block) cfg = load_config(self.repo) - self.assertEqual(cfg.profiles["claude"].executable, "模型-β-测试") + self.assertEqual(cfg.profiles["pi"].executable, "模型-β-测试") escaped = MINIMAL_BLOCK.replace( - "executable: claude", + "executable: pi", r'executable: "say \"hi\" 模型"', ) write_repo(self.repo, escaped) cfg = load_config(self.repo) - self.assertEqual(cfg.profiles["claude"].executable, 'say "hi" 模型') + self.assertEqual(cfg.profiles["pi"].executable, 'say "hi" 模型') def test_idle_timeout_defaults_within_total_and_rejects_bounds(self) -> None: self.assertEqual(DEFAULT_IDLE_TIMEOUT_SECONDS, 600) @@ -405,6 +399,553 @@ class AgentExecConfigTests(unittest.TestCase): with self.assertRaises(ConfigError): load_config(self.repo) + def test_exposes_max_attempts_per_profile_and_emergency_profile(self) -> None: + write_repo(self.repo, _emergency_block()) + cfg = load_config(self.repo) + self.assertEqual(cfg.execution.max_attempts_per_profile, 3) + self.assertEqual(cfg.execution.emergency_profile, "codex") + + def test_configured_emergency_profile_must_exist_be_enabled_and_use_adapter_codex( + self, + ) -> None: + write_repo(self.repo, _emergency_block(include_profile=False)) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("does not exist", str(ctx.exception)) + + write_repo(self.repo, _emergency_block(enabled="false")) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("must be enabled", str(ctx.exception)) + + write_repo(self.repo, _emergency_block(adapter="pi")) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("adapter codex", str(ctx.exception)) + + def test_emergency_profile_is_excluded_from_normal_roles(self) -> None: + for role, replacement in ( + ("implementation", "[cursor, pi, codex]"), + ("repair", "[cursor, codex]"), + ("test", "[pi, codex]"), + ): + write_repo(self.repo, _emergency_block(**{role: replacement})) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("must not appear", str(ctx.exception)) + self.assertIn(role, str(ctx.exception)) + + def test_review_may_include_emergency_profile(self) -> None: + write_repo(self.repo, _emergency_block(review="[pi, codex]")) + cfg = load_config(self.repo) + self.assertEqual(cfg.execution.emergency_profile, "codex") + self.assertTrue(cfg.profiles["codex"].enabled) + self.assertEqual(cfg.profiles["codex"].adapter, "codex") + self.assertEqual(cfg.roles["review"], ("pi", "codex")) + self.assertNotIn("codex", cfg.roles["implementation"]) + self.assertNotIn("codex", cfg.roles["repair"]) + self.assertNotIn("codex", cfg.roles["test"]) + + def test_accepts_fallback_to_host_never(self) -> None: + write_repo(self.repo, _strict_routing_block(fallback="never")) + cfg = load_config(self.repo) + self.assertEqual(cfg.execution.fallback_to_host, "never") + self.assertEqual(cfg.execution.max_attempts_per_profile, 3) + self.assertEqual(cfg.execution.emergency_profile, "codex") + + def test_legacy_all_unavailable_remains_parseable(self) -> None: + write_repo(self.repo, _strict_routing_block(fallback="all-unavailable")) + cfg = load_config(self.repo) + self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable") + + def test_emergency_profile_requires_max_attempts_exactly_three(self) -> None: + for raw_max in ("1", "2", "4", None): + with self.subTest(max_attempts=raw_max): + write_repo( + self.repo, + _strict_routing_block(fallback="all-unavailable", max_attempts=raw_max), + ) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("max_attempts_per_profile", str(ctx.exception)) + + def test_normal_roles_reject_forbidden_adapters_and_alternate_codex_profile( + self, + ) -> None: + claude = textwrap.dedent( + """\ + claude: + adapter: claude + executable: claude + model: auto + enabled: true + """ + ) + claude = "\n".join( + f" {line}" if line else line for line in claude.strip("\n").splitlines() + ) + write_repo( + self.repo, + _strict_routing_block( + fallback="all-unavailable", + implementation="[cursor, claude]", + extra_profiles=claude, + ), + ) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("implementation", str(ctx.exception)) + self.assertIn("cursor, antigravity, or pi", str(ctx.exception)) + + planner = textwrap.dedent( + """\ + planner: + adapter: codex + executable: planner + model: auto + enabled: true + """ + ) + planner = "\n".join( + f" {line}" if line else line for line in planner.strip("\n").splitlines() + ) + write_repo( + self.repo, + _strict_routing_block( + fallback="all-unavailable", + implementation="[cursor, planner]", + extra_profiles=planner, + ), + ) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("planner", str(ctx.exception)) + self.assertIn("implementation", str(ctx.exception)) + self.assertNotIn("host-fallback", str(ctx.exception).lower()) + + def test_strict_emergency_mode_rejects_custom_normal_role_adapters(self) -> None: + worker = textwrap.dedent( + """\ + worker: + adapter: custom + executable: worker + model: auto + enabled: true + """ + ) + worker = "\n".join( + f" {line}" if line else line for line in worker.strip("\n").splitlines() + ) + adapters = textwrap.dedent( + """\ + custom_adapters: + worker: + base_argv: ["${workspace}", "${unit_packet}", "${result_path}"] + run_everything_argv: [] + workspace_argv: [] + model_argv: [] + prompt_transport: stdin + output_format: text + """ + ) + for role in ("implementation", "repair", "test"): + with self.subTest(role=role): + roles = { + "implementation": "[cursor]", + "repair": "[cursor]", + "test": "[pi]", + } + roles[role] = "[worker]" + block = _strict_routing_block( + implementation=roles["implementation"], + repair=roles["repair"], + test=roles["test"], + extra_profiles=worker, + ) + write_repo(self.repo, block.rstrip() + "\n\n" + adapters) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + message = str(ctx.exception) + self.assertIn("worker", message) + self.assertIn(role, message) + self.assertIn("cursor, antigravity, or pi", message) + + def test_emergency_normal_roles_reject_missing_required_adapter(self) -> None: + for role, replacement, missing in ( + ("implementation", "[cursor, pi]", "antigravity"), + ("repair", "[cursor, antigravity]", "pi"), + ("test", "[pi, antigravity]", "cursor"), + ): + with self.subTest(role=role): + write_repo(self.repo, _strict_routing_block(**{role: replacement})) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + message = str(ctx.exception) + self.assertIn(role, message) + self.assertIn(missing, message) + self.assertIn("exactly one enabled", message) + + def test_emergency_normal_roles_reject_disabled_only_adapter(self) -> None: + for role in ("implementation", "repair", "test"): + with self.subTest(role=role): + write_repo( + self.repo, + _strict_routing_block( + **{role: "[cursor, agy_off, pi]"}, + extra_profiles=_profile_lines("agy_off", "antigravity", "false"), + ), + ) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + message = str(ctx.exception) + self.assertIn(role, message) + self.assertIn("antigravity", message) + self.assertIn("exactly one enabled", message) + + def test_emergency_normal_roles_reject_multiple_enabled_same_adapter(self) -> None: + for role in ("implementation", "repair", "test"): + with self.subTest(role=role): + write_repo( + self.repo, + _strict_routing_block( + **{role: "[cursor, antigravity, pi, cursor2]"}, + extra_profiles=_profile_lines("cursor2", "cursor", "true"), + ), + ) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + message = str(ctx.exception) + self.assertIn(role, message) + self.assertIn("cursor2", message) + self.assertIn("exactly one enabled", message) + + def test_emergency_normal_roles_allow_extra_disabled_and_keep_order(self) -> None: + write_repo( + self.repo, + _strict_routing_block( + implementation="[cursor_off, pi, cursor, antigravity]", + repair="[antigravity, pi, cursor_off, cursor]", + extra_profiles=_profile_lines("cursor_off", "cursor", "false"), + ), + ) + cfg = load_config(self.repo) + self.assertEqual( + cfg.roles["implementation"], ("cursor_off", "pi", "cursor", "antigravity") + ) + self.assertEqual(cfg.roles["repair"], ("antigravity", "pi", "cursor_off", "cursor")) + self.assertEqual(cfg.roles["test"], ("pi", "antigravity", "cursor")) + self.assertFalse(cfg.profiles["cursor_off"].enabled) + + def test_emergency_exactness_does_not_apply_to_review(self) -> None: + write_repo(self.repo, _strict_routing_block(review="[codex]")) + cfg = load_config(self.repo) + self.assertEqual(cfg.roles["review"], ("codex",)) + + def test_without_emergency_custom_normal_roles_are_rejected(self) -> None: + write_repo(self.repo, _legacy_custom_block()) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + message = str(ctx.exception) + self.assertIn("mycustom", message) + self.assertIn("cursor, antigravity, or pi", message) + + def test_without_emergency_review_may_use_custom_adapter(self) -> None: + block = _legacy_custom_block().replace( + "implementation: [mycustom]\n" + " repair: [mycustom]\n" + " test: [mycustom]\n", + "implementation: [cursor, antigravity, pi]\n" + " repair: [cursor, antigravity, pi]\n" + " test: [pi, antigravity, cursor]\n", + ).replace( + "\ncustom_adapters:", + "\n" + + _profile_lines("cursor", "cursor", "true") + + "\n" + + _profile_lines("antigravity", "antigravity", "true") + + "\n" + + _profile_lines("pi", "pi", "true") + + "\n\ncustom_adapters:", + ) + write_repo(self.repo, block) + cfg = load_config(self.repo) + self.assertIsNone(cfg.execution.emergency_profile) + self.assertEqual(cfg.roles["review"], ("mycustom",)) + self.assertEqual(cfg.profiles["mycustom"].adapter, "custom") + + def test_without_emergency_normal_roles_need_exact_enabled_adapter_set(self) -> None: + no_emergency = {"emergency": None, "max_attempts": None, "review": "[pi]"} + worker = _profile_lines("worker", "custom", "true") + adapters = ( + "custom_adapters:\n" + " worker:\n" + ' base_argv: ["${workspace}", "${unit_packet}", "${result_path}"]\n' + " run_everything_argv: []\n" + " workspace_argv: []\n" + " model_argv: []\n" + " prompt_transport: stdin\n" + " output_format: text\n" + ) + claude = _profile_lines("claude", "claude", "true") + for role in ("implementation", "repair", "test"): + cases = ( + ("missing", "[cursor, pi]", "", "exactly one enabled"), + ( + "disabled-only", + "[cursor, agy_off, pi]", + _profile_lines("agy_off", "antigravity", "false"), + "exactly one enabled", + ), + ( + "duplicate-enabled", + "[cursor, antigravity, pi, cursor2]", + _profile_lines("cursor2", "cursor", "true"), + "exactly one enabled", + ), + ( + "custom-extra", + "[cursor, antigravity, pi, worker]", + worker, + "cursor, antigravity, or pi", + ), + ( + "custom-only", + "[worker]", + worker, + "cursor, antigravity, or pi", + ), + ( + "other-builtin", + "[cursor, antigravity, pi, claude]", + claude, + "cursor, antigravity, or pi", + ), + ) + for label, replacement, extra, expected in cases: + with self.subTest(role=role, case=label): + block = _strict_routing_block( + **no_emergency, **{role: replacement}, extra_profiles=extra + ) + if extra is worker: + block = block.rstrip() + "\n\n" + adapters + write_repo(self.repo, block) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + message = str(ctx.exception) + self.assertIn(role, message) + self.assertIn(expected, message) + + def test_without_emergency_disabled_extras_are_ignored_and_order_kept(self) -> None: + write_repo( + self.repo, + _strict_routing_block( + emergency=None, + max_attempts=None, + review="[pi]", + implementation="[pi, cursor_off, antigravity, cursor]", + test="[antigravity, cursor, pi, cursor_off]", + extra_profiles=_profile_lines("cursor_off", "cursor", "false"), + ), + ) + cfg = load_config(self.repo) + self.assertIsNone(cfg.execution.emergency_profile) + self.assertEqual( + cfg.roles["implementation"], ("pi", "cursor_off", "antigravity", "cursor") + ) + self.assertEqual(cfg.roles["repair"], ("cursor", "antigravity", "pi")) + self.assertEqual(cfg.roles["test"], ("antigravity", "cursor", "pi", "cursor_off")) + + def test_max_attempts_per_profile_never_exceeds_three(self) -> None: + for raw_max in ("1", "2", "3"): + with self.subTest(max_attempts=raw_max): + write_repo( + self.repo, + _strict_routing_block( + emergency=None, max_attempts=raw_max, review="[pi]" + ), + ) + cfg = load_config(self.repo) + self.assertEqual(cfg.execution.max_attempts_per_profile, int(raw_max)) + for raw_max in ("4", "10"): + for emergency in (None, "codex"): + with self.subTest(max_attempts=raw_max, emergency=emergency): + write_repo( + self.repo, + _strict_routing_block(emergency=emergency, max_attempts=raw_max), + ) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("max_attempts_per_profile", str(ctx.exception)) + + def test_checked_in_project_config_never_authorizes_host_fallback(self) -> None: + repo = Path(__file__).resolve().parents[1] + cfg = load_config(repo) + self.assertEqual(cfg.execution.fallback_to_host, "never") + self.assertEqual(cfg.execution.max_attempts_per_profile, 3) + self.assertEqual(cfg.execution.emergency_profile, "codex") + allowed = {"cursor", "antigravity", "pi"} + for role in ("implementation", "repair", "test"): + self.assertNotIn("codex", cfg.roles[role]) + for name in cfg.roles[role]: + self.assertIn(cfg.profiles[name].adapter, allowed) + self.assertIn("codex", cfg.roles["review"]) + self.assertTrue(cfg.profiles["codex"].enabled) + self.assertEqual(cfg.profiles["codex"].adapter, "codex") + + +def _legacy_custom_block() -> str: + """No emergency_profile and adapter custom on every role.""" + return 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 + """ + ) + + +def _strict_routing_block( + *, + fallback: str = "never", + max_attempts: str | None = "3", + emergency: str | None = "codex", + implementation: str = "[cursor, antigravity, pi]", + repair: str = "[cursor, antigravity, pi]", + test: str = "[pi, antigravity, cursor]", + review: str = "[pi, codex]", + extra_profiles: str = "", +) -> str: + lines = [ + "schema: agent-execution/v1", + "", + "execution:", + " fresh_process_per_unit: true", + " permission: run-everything", + f" fallback_to_host: {fallback}", + " max_parallel: 1", + " timeout_seconds: 60", + " summary_bytes: 1024", + " raw_output_bytes: 4096", + ] + if max_attempts is not None: + lines.append(f" max_attempts_per_profile: {max_attempts}") + if emergency is not None: + lines.append(f" emergency_profile: {emergency}") + lines.extend( + [ + "", + "roles:", + f" implementation: {implementation}", + f" repair: {repair}", + f" test: {test}", + f" review: {review}", + "", + "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", + "", + " codex:", + " adapter: codex", + " executable: codex", + " model: auto", + " enabled: true", + ] + ) + if extra_profiles: + lines.append(extra_profiles.rstrip("\n")) + return "\n".join(lines) + "\n" + + +def _profile_lines(name: str, adapter: str, enabled: str) -> str: + return ( + f" {name}:\n" + f" adapter: {adapter}\n" + f" executable: {name}\n" + " model: auto\n" + f" enabled: {enabled}" + ) + + +def _emergency_block( + *, + include_profile: bool = True, + adapter: str = "codex", + enabled: str = "true", + implementation: str = "[cursor, antigravity, pi]", + repair: str = "[cursor, antigravity, pi]", + test: str = "[pi, antigravity, cursor]", + review: str = "[pi]", +) -> str: + block = MINIMAL_BLOCK.replace( + " raw_output_bytes: 10485760\n", + " raw_output_bytes: 10485760\n" + " max_attempts_per_profile: 3\n" + " emergency_profile: codex\n", + ) + block = block.replace( + "implementation: [cursor, antigravity, pi]", f"implementation: {implementation}" + ) + block = block.replace("repair: [cursor, antigravity, pi]", f"repair: {repair}") + block = block.replace("test: [pi, antigravity, cursor]", f"test: {test}") + block = block.replace("review: [pi]", f"review: {review}") + if not include_profile: + return block + return block.replace( + " enabled: true\n", + " enabled: true\n" + "\n" + " codex:\n" + f" adapter: {adapter}\n" + " executable: codex\n" + " model: auto\n" + f" enabled: {enabled}\n", + 1, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_task.py b/tests/test_task.py index b118a458..aca3b4e4 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -728,7 +728,23 @@ class TaskLifecycleTests(unittest.TestCase): "| 待定义 | — | 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) + self.assertIn("Cursor、Antigravity(agy)和 PI", agent_receipts) + self.assertIn("最多 3 次全新尝试", agent_receipts) + self.assertIn("一次全新的 Codex CLI 紧急尝试", agent_receipts) + self.assertIn("规划、方案设计、实施计划或独立 Review", agent_receipts) + self.assertIn("显式 fail-stop", agent_receipts) + self.assertIn("不因推理时长、工具次数、规划方式或首次写文件时间判定失败", agent_receipts) + self.assertIn("stdin/stdout/stderr 均无活动的空闲超时", agent_receipts) + self.assertIn("自主检查、计划、测试并迭代", agent_receipts) + self.assertIn("scripts/agent_exec.py apply", agent_receipts) + self.assertIn("pre-commit admit", agent_receipts) + self.assertIn(".task/**", agent_receipts) + self.assertIn("docs/superpowers/specs/**", agent_receipts) + self.assertIn("docs/superpowers/plans/**", agent_receipts) + self.assertIn("fallback 列只记录该行是否为 Codex 紧急尝试", agent_receipts) + self.assertIn("不授权 host 直接撰写该单元", agent_receipts) + self.assertIn("安全耗尽不发出 host-fallback-authorized", agent_receipts) + self.assertIn("终态为 unavailable 或 blocked", agent_receipts) for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"): self.assertIn(field + ":", card) self.assertIn("## 交付调度\n", card)