From 343a56e2b66b5bd6f18beb149f9fde3433bc124e Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 14:48:10 +0000 Subject: [PATCH 01/23] Record the task card for feature-agent-cli-enforcement Why: establish the authorized task workspace and its initial baseline. Evidence: preserved precheck receipt in .task/feature-agent-cli-enforcement.md; R0=04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee. Impact: task metadata only; the feature workspace starts at 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee. Refs: .task/feature-agent-cli-enforcement.md --- .task/feature-agent-cli-enforcement.md | 149 +++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 .task/feature-agent-cli-enforcement.md diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md new file mode 100644 index 00000000..6eeb68b8 --- /dev/null +++ b/.task/feature-agent-cli-enforcement.md @@ -0,0 +1,149 @@ +# feature/agent-cli-enforcement 任务卡 + +任务主记录:;状态:进行中;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 + +- 授权与交付终点:<本次允许的动作与终点;未授权事项写明> +- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进) +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md) +- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入) +- 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收 +- 当前阶段:需求拆解;当前增量:I1(待定义) +- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) + +## 目标与非目标 + +<预期结果、受影响入口/模块/契约;明确不做什么。> + +## Agent 实施收据 + +按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。仅当 scripts/agent_exec.py run 对该精确单元发出有效 host-fallback-authorized 收据后,当前 host 方可实施该单元。failed-unknown、scope-expansion、无效配置、未对账 Git 状态与安全失败均不授权回退。原始日志保留在源码外收据路径。 + +| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---|---| +| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — | + +## 关联方向与规划 + +- 适用状态:待核对(无适用规划时说明原因) +- 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文) +- 对应里程碑:待核对 + +## 需求分析 + +| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | +|---|---|---|---|---|---|---| +| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 | + +## 方案与计划 + +- 方案引用:待填写(含对象版本) +- 实施计划引用:待填写(含增量与需求 ID) +- 测试计划引用:待填写(含需求 ID 与验收条件) + +## 阶段记录 + +| 阶段 | 对象版本 | 状态 | 决策/执行者 | 时间 | 依据 | +|---|---|---|---|---|---| +| 需求拆解 | 待定 | 待执行 | — | — | — | +| 需求评估 | 待定 | 待执行 | — | — | — | +| 需求表确认 | 待定 | 待执行 | — | — | — | +| 方案设计 | 待定 | 待执行 | — | — | — | +| 实施计划 | 待定 | 待执行 | — | — | — | +| 实施与自检 | 待定 | 待执行 | — | — | — | +| 正式测试与验收 | 待定 | 待执行 | — | — | — | +| 代码 Review | 待定 | 待执行 | — | — | — | +| 合入 | 待定 | 待执行 | — | — | — | +| 合入核验与收口 | 待定 | 待执行 | — | — | — | + +## 探索试验与迭代 + +| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 授权/决策依据 | 结果 | 变更影响 | 需重审补测 | +|---|---|---|---|---|---|---|---| +| 待定义 | — | — | — | 待核对 | NOT_RUN | 待评估 | 待评估 | + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | +|---|---|---|---| +| 启动 | 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee | 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee | 2026-10-15T14:48:10Z | + +## 证据(Evidence) + + +| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) | +|---|---|---|---|---|---| + +## 交付调度 + +- 调度状态:未入队;调度记录:无 +- entry_id:无;attempt_id:无;窗口执行者:无 +- 固定候选:无(base/head、组件与相关配置引用实际尝试) +- 调度依据:policy/merge-queue.md;新模式启用前按既有交付门禁 +- 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合 + +## 结果与验证(完成后填写) + +- 结果: +- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表> +- 影响与剩余项: + +## 规划进展与下一步 + +- 本次推进/剩余:待核对(依据实际成果更新适用方案/规划) +- 首选下一任务:待核对(无适用规划、已完成或受阻时如实说明,不虚构任务) +- 推荐依据:待填写 +- 前置条件/待确认:待核对 +- 预期产物/验收条件:待定义 +- 重复任务核对:待核对(已有/在途任务、PR 与规划记录) +- 启动授权:未授权(建议不自动启动新任务,仍须明确授权) + +## 合入与清理 + +- Review 记录:NOT_RUN(审阅智能体/平台身份、base/head、结论、问题闭合及重放依据) +- 冲突记录: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" +} +``` -- 2.54.0 From dd3140b3912af8db3b2df2750ff41412da2d8d63 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 15:22:51 +0000 Subject: [PATCH 02/23] Add execution retry and emergency profile settings Why: Routing needs an explicit per-profile attempt limit and a named Codex emergency profile before controller enforcement can be implemented. Evidence: The new parser test failed on unknown keys before implementation; all 15 config test executions now pass and git diff --check is clean. Impact: Adds backward-compatible optional configuration fields only; routing behavior is unchanged in this commit. Refs: feature/agent-cli-enforcement --- scripts/agent_exec_config.py | 27 ++++++++++++++++++++++++++- tests/test_agent_exec_config.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py index 84aa59b0..e213fe14 100644 --- a/scripts/agent_exec_config.py +++ b/scripts/agent_exec_config.py @@ -54,9 +54,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 +88,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) @@ -391,6 +397,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, + 10, + ) + 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,6 +423,8 @@ 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, ) diff --git a/tests/test_agent_exec_config.py b/tests/test_agent_exec_config.py index 1599c130..6261c3b2 100644 --- a/tests/test_agent_exec_config.py +++ b/tests/test_agent_exec_config.py @@ -405,6 +405,35 @@ class AgentExecConfigTests(unittest.TestCase): with self.assertRaises(ConfigError): load_config(self.repo) + def test_exposes_max_attempts_per_profile_and_emergency_profile(self) -> None: + block = MINIMAL_BLOCK.replace( + " raw_output_bytes: 10485760\n", + " raw_output_bytes: 10485760\n" + " max_attempts_per_profile: 3\n" + " emergency_profile: codex\n", + ).replace( + " claude:\n" + " adapter: claude\n" + " executable: claude\n" + " model: auto\n" + " enabled: true\n", + " claude:\n" + " adapter: claude\n" + " executable: claude\n" + " model: auto\n" + " enabled: true\n" + "\n" + " codex:\n" + " adapter: codex\n" + " executable: codex\n" + " model: auto\n" + " enabled: true\n", + ) + write_repo(self.repo, block) + cfg = load_config(self.repo) + self.assertEqual(cfg.execution.max_attempts_per_profile, 3) + self.assertEqual(cfg.execution.emergency_profile, "codex") + if __name__ == "__main__": unittest.main() -- 2.54.0 From cefd95b30775def933a4731413f1c0b8b87561f2 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 16:08:20 +0000 Subject: [PATCH 03/23] Specify per-profile retry ordering Why: The routing controller must retry each safe failed profile three times before advancing. Evidence: The focused test fails with observed order retryme,thenok instead of three retryme attempts, proving current behavior lacks retries. Impact: Adds a RED controller contract test only; production behavior is unchanged. Refs: feature/agent-cli-enforcement --- tests/test_agent_exec.py | 89 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index fdb3af3a..b49d9c64 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -67,6 +67,7 @@ def _write_config( role_order: list[str], timeout_seconds: int = 30, idle_timeout_seconds: int | None = None, + max_attempts_per_profile: int | None = None, summary_bytes: int = 256, raw_output_bytes: int = 1024, ) -> None: @@ -93,6 +94,11 @@ def _write_config( 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 "" + ) block = textwrap.dedent( f"""\ schema: agent-execution/v1 @@ -103,7 +109,7 @@ 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} summary_bytes: {summary_bytes} raw_output_bytes: {raw_output_bytes} roles: @@ -477,6 +483,87 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual(data["terminal_status"], "success") self._assert_canonical_untouched() + def test_failed_clean_retries_profile_before_next_success(self) -> None: + launches = self.root / "profile-launches.log" + launches.write_text("", encoding="utf-8") + _write_fake_worker( + self.bindir, + "retryme", + body=textwrap.dedent( + f"""\ + printf 'retryme\\n' >> "{launches}" + workspace="$1" + mkdir -p "$workspace/src" + printf 'tmp\\n' > "$workspace/src/marker.txt" + exit 9 + """ + ), + ) + _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": "custom", "executable": "retryme"}, + "thenok": {"adapter": "custom", "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", + str(self.repo), + "--role", + "implementation", + "--unit", + str(unit), + "--receipt", + 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, ["retryme", "retryme", "retryme", "thenok"]) + data = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual( + [ + ( + attempt["profile"], + attempt.get("attempt_ordinal"), + attempt["terminal_status"], + ) + for attempt in data["attempts"] + ], + [ + ("retryme", 1, "failed-clean"), + ("retryme", 2, "failed-clean"), + ("retryme", 3, "failed-clean"), + ("thenok", 1, "success"), + ], + ) + self.assertTrue( + all(attempt["cleanup"]["exact_restore"] for attempt in data["attempts"][:3]) + ) + self._assert_canonical_untouched() + def test_failed_unknown_blocks_fallback(self) -> None: launched = self.root / "launched.log" launched.write_text("", encoding="utf-8") -- 2.54.0 From 09ca1c6ebe9102e1ad1ad94e4614b9013aeff65a Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 16:23:06 +0000 Subject: [PATCH 04/23] Retry recoverable execution profiles Why: Each non-Codex execution profile must receive three safe attempts before routing advances. Evidence: The focused RED test observed one retryme launch before implementation; the controller and history suites now pass 31 executions. Impact: Retries only unavailable and failed-clean outcomes; existing fail-stop statuses remain immediate. Refs: feature/agent-cli-enforcement --- scripts/agent_exec.py | 104 +++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 48 deletions(-) diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py index be71f6b3..cc14bf17 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -149,6 +149,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: @@ -292,66 +294,68 @@ 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) + 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) - 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 + 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 in {"unavailable", "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 + break 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 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 len(attempts) == len(profiles) * max_attempts ) if exhausted_ok and cfg.execution.fallback_to_host == "all-unavailable": terminal = "host-fallback-authorized" @@ -1742,6 +1746,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, @@ -1894,6 +1900,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", -- 2.54.0 From df0cc2d0ac30a8f47b78cf5fd5085c05a4ee3eea Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 16:28:17 +0000 Subject: [PATCH 05/23] Specify the Codex emergency routing contract Why: Codex CLI must run only after every regular profile exhausts its three safe attempts. Evidence: The focused test records three regular failed-clean attempts and fails because no emergency Codex attempt is launched. Impact: Adds a RED controller contract test and test helper support only. Refs: feature/agent-cli-enforcement --- tests/test_agent_exec.py | 96 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index b49d9c64..b6747a28 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -68,6 +68,7 @@ def _write_config( 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: @@ -99,6 +100,11 @@ def _write_config( 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 @@ -109,7 +115,7 @@ def _write_config( fallback_to_host: all-unavailable max_parallel: 1 timeout_seconds: {timeout_seconds} -{idle_line}{attempts_line} summary_bytes: {summary_bytes} +{idle_line}{attempts_line}{emergency_line} summary_bytes: {summary_bytes} raw_output_bytes: {raw_output_bytes} roles: @@ -564,6 +570,94 @@ class AgentExecControllerTests(unittest.TestCase): ) self._assert_canonical_untouched() + def test_emergency_profile_runs_once_after_regular_failed_clean(self) -> None: + launches = self.root / "emergency-launches.log" + launches.write_text("", encoding="utf-8") + _write_fake_worker( + self.bindir, + "regular", + body=textwrap.dedent( + f"""\ + printf 'regular\\n' >> "{launches}" + workspace="$1" + mkdir -p "$workspace/src" + printf 'tmp\\n' > "$workspace/src/marker.txt" + exit 9 + """ + ), + ) + _write_fake_worker( + self.bindir, + "codex", + body=textwrap.dedent( + f"""\ + printf 'codex\\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={ + "regular": {"adapter": "custom", "executable": "regular"}, + "codex": {"adapter": "custom", "executable": "codex"}, + }, + role_order=["regular"], + 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_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, + [ + ("regular", 1, "failed-clean", False), + ("regular", 2, "failed-clean", False), + ("regular", 3, "failed-clean", False), + ("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, ["regular", "regular", "regular", "codex"]) + self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + self._assert_canonical_untouched() + def test_failed_unknown_blocks_fallback(self) -> None: launched = self.root / "launched.log" launched.write_text("", encoding="utf-8") -- 2.54.0 From 71fd4486f58a4fa18b457f2adff596ad4a8951c0 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 16:36:50 +0000 Subject: [PATCH 06/23] Route exhausted work through emergency Codex Why: Codex CLI is permitted only after every normal execution profile exhausts three safe attempts, with no host implementation fallback. Evidence: The emergency RED test lacked the fourth Codex attempt before implementation; 32 controller/history executions now pass. Impact: Adds one emergency attempt for implementation, repair, and test roles while preserving fail-stop statuses and review isolation. Refs: feature/agent-cli-enforcement --- scripts/agent_exec.py | 132 +++++++++++++++++++++++++++++++----------- 1 file changed, 98 insertions(+), 34 deletions(-) diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py index cc14bf17..7f317f7f 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -47,6 +47,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" @@ -195,6 +198,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, *, @@ -316,48 +359,69 @@ def cmd_run( attempt.attempt_ordinal = attempt_ordinal attempt.emergency_fallback = False 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 in {"unavailable", "failed-clean"}: - terminal = "unavailable" - reason = attempt.reason + 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 - 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 - break - break + stop = action == "stop" break exhausted_ok = ( not stop and attempts - and all(a.terminal_status in {"unavailable", "failed-clean"} for a in attempts) + 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": + 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 + stop = True + + if ( + exhausted_ok + and not stop + and not emergency_name + and cfg.execution.fallback_to_host == "all-unavailable" + ): terminal = "host-fallback-authorized" host_fallback_authorizations = 1 host_scope = { -- 2.54.0 From d54306b65e5b8e6e540fda2d22f36ac975279264 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 16:45:14 +0000 Subject: [PATCH 07/23] Harden Codex emergency profile configuration Why: Normal execution roles must exclude Codex while a single enabled Codex adapter remains available for emergency routing and review. Evidence: Parser invalid-case tests were RED before validation; all 17 config tests and agent_exec validate now pass. Impact: Enables three attempts per normal profile, stable Cursor routing, and a validated emergency Codex profile. Refs: feature/agent-cli-enforcement --- AGENT-EXECUTION.md | 25 +++++++---- scripts/agent_exec_config.py | 24 +++++++++++ tests/test_agent_exec_config.py | 74 +++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/AGENT-EXECUTION.md b/AGENT-EXECUTION.md index 1a8f2023..93a3ade0 100644 --- a/AGENT-EXECUTION.md +++ b/AGENT-EXECUTION.md @@ -7,11 +7,14 @@ 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 profile may be attempted up to +`max_attempts_per_profile`. 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. A configured +emergency profile replaces host write fallback. 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 @@ -25,17 +28,19 @@ execution: 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 +55,10 @@ profiles: executable: pi model: auto enabled: true + + codex: + adapter: codex + executable: codex + model: auto + enabled: true ``` diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py index e213fe14..12470725 100644 --- a/scripts/agent_exec_config.py +++ b/scripts/agent_exec_config.py @@ -343,6 +343,8 @@ 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) + return ExecutionConfig( schema=schema, execution=execution, @@ -428,6 +430,28 @@ def _build_execution(raw: Any) -> ExecutionPolicy: ) +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 + 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 _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/tests/test_agent_exec_config.py b/tests/test_agent_exec_config.py index 6261c3b2..4a0d5405 100644 --- a/tests/test_agent_exec_config.py +++ b/tests/test_agent_exec_config.py @@ -434,6 +434,80 @@ class AgentExecConfigTests(unittest.TestCase): 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, claude, codex]"), + ("repair", "[cursor, codex]"), + ("test", "[claude, 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="[claude, 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"], ("claude", "codex")) + self.assertNotIn("codex", cfg.roles["implementation"]) + self.assertNotIn("codex", cfg.roles["repair"]) + self.assertNotIn("codex", cfg.roles["test"]) + + +def _emergency_block( + *, + include_profile: bool = True, + adapter: str = "codex", + enabled: str = "true", + implementation: str = "[cursor, claude]", + repair: str = "[cursor]", + test: str = "[claude]", + review: str = "[claude]", +) -> str: + block = MINIMAL_BLOCK.replace( + " raw_output_bytes: 10485760\n", + " raw_output_bytes: 10485760\n emergency_profile: codex\n", + ) + block = block.replace("implementation: [cursor, claude]", f"implementation: {implementation}") + block = block.replace("repair: [cursor]", f"repair: {repair}") + block = block.replace("test: [claude]", f"test: {test}") + block = block.replace("review: [claude]", 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() -- 2.54.0 From 2a994ca768bf9a9c0e18fc1c76ff60fbd36f5703 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 16:54:23 +0000 Subject: [PATCH 08/23] Exercise emergency routing through the Codex adapter Why: Parser hardening requires the emergency fixture to use the real Codex adapter contract instead of a custom adapter. Evidence: Agent-exec and config suites pass 58 executions with one opt-in probe skipped; validate and diff-check pass. Impact: Test fixture only; production validation remains strict. Refs: feature/agent-cli-enforcement --- tests/test_agent_exec.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index b6747a28..1e4d6319 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -185,6 +185,12 @@ def _write_fake_worker( return path +def _codex_help() -> str: + from scripts.agent_exec_adapters import _REQUIRED_HELP_MARKERS + + return "\n".join(_REQUIRED_HELP_MARKERS["codex"]) + "\n" + + class AgentExecControllerTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-ctrl-") @@ -589,13 +595,27 @@ class AgentExecControllerTests(unittest.TestCase): _write_fake_worker( self.bindir, "codex", + help_text=_codex_help(), body=textwrap.dedent( f"""\ + if [ "$1" = "exec" ] && [ "$2" = "--help" ]; then + exec "$0" --help + fi printf 'codex\\n' >> "{launches}" - workspace="$1" - result="$3" - mkdir -p "$workspace/src" - printf 'final\\n' > "$workspace/src/marker.txt" + 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 """ @@ -604,7 +624,7 @@ class AgentExecControllerTests(unittest.TestCase): self._configure( profiles={ "regular": {"adapter": "custom", "executable": "regular"}, - "codex": {"adapter": "custom", "executable": "codex"}, + "codex": {"adapter": "codex", "executable": "codex", "model": "auto"}, }, role_order=["regular"], max_attempts_per_profile=3, @@ -614,6 +634,7 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual(loaded.execution.max_attempts_per_profile, 3) self.assertEqual(loaded.execution.emergency_profile, "codex") self.assertIn("codex", loaded.profiles) + 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") @@ -655,6 +676,7 @@ class AgentExecControllerTests(unittest.TestCase): line for line in launches.read_text(encoding="utf-8").splitlines() if line ] self.assertEqual(observed, ["regular", "regular", "regular", "codex"]) + self.assertEqual(data["attempts"][-1]["summary"], "done") self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) self._assert_canonical_untouched() -- 2.54.0 From 8b30353c4d0c978d38b6cbd581abe079e65aad60 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 17:12:33 +0000 Subject: [PATCH 09/23] Enforce execution-agent provenance at integration Why: Implementation changes must be mechanically tied to a successful CLI transport instead of relying on agents to follow prose. Evidence: Admission tests went RED without apply/admit; 10 admission, 41 agent-exec, and 62 task tests now pass with exact staged-content matching. Impact: Adds validated transport fast-forwarding and rejects unproven implementation commits while exempting task cards and approved plan/spec paths. Refs: feature/agent-cli-enforcement --- Makefile | 11 +- scripts/agent_exec.py | 332 ++++++++++++++++++++- scripts/hooks/pre-commit | 3 +- tests/test_agent_exec_admission.py | 446 +++++++++++++++++++++++++++++ 4 files changed, 789 insertions(+), 3 deletions(-) create mode 100644 tests/test_agent_exec_admission.py 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/scripts/agent_exec.py b/scripts/agent_exec.py index 7f317f7f..4f98e4c2 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -1,4 +1,4 @@ -"""Recoverable agent-exec controller: validate, isolate, supervise, receipt, transport.""" +"""Recoverable agent-exec controller: validate, run, apply transports, admit provenance.""" from __future__ import annotations import argparse @@ -169,6 +169,19 @@ 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="Admit staged paths backed by a success transport or host exemption", + ) + p_admit.add_argument("--repo", required=True) + args = parser.parse_args(argv) if args.command == "validate": return cmd_validate(Path(args.repo)) @@ -179,6 +192,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 @@ -2117,5 +2134,318 @@ 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``.""" + norm = path.replace("\\", "/").strip() + 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 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: + paths.append(parts[2]) + elif len(parts) >= 2: + paths.append(parts[1]) + 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 + + +def _history_file(repo: Path) -> Path | None: + common = _git( + repo, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + check=False, + ) + if common.returncode != 0 or not common.stdout.strip(): + raise RuntimeError(common.stderr.strip() or "unable to resolve git common dir") + common_dir = Path(common.stdout.strip()) + if not common_dir.is_absolute(): + common_dir = (repo / common_dir).resolve() + history_dir = common_dir / "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], +) -> bool: + 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") + if ref is None or not isinstance(parent, str): + return False + if _resolve_commit(repo, parent) != head: + return False + commit = _resolve_commit(repo, ref) + if commit is None or _commit_parents(repo, 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, commit) + if _non_exempt(changed) != required or _non_exempt(diff_paths) != required: + return False + return _index_matches_commit(repo, commit, required) + + +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) + + 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.""" + 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 provenance", 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) + for record in _load_history_records(repo): + if _transport_covers(repo, record, head=head, required=required): + print("admit: ok") + return EXIT_SUCCESS + except Exception as exc: + return _reject(f"admit: no provenance: {exc}", secrets) + listed = ", ".join(sorted(required)) + return _reject(f"admit: no provenance for {listed}", secrets) + + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index c2eebd7b..08778e53 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -10,4 +10,5 @@ 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) +PYTHONDONTWRITEBYTECODE=1 python3 "$root/scripts/agent_exec.py" admit --repo "$root" diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py new file mode 100644 index 00000000..f0c50eab --- /dev/null +++ b/tests/test_agent_exec_admission.py @@ -0,0 +1,446 @@ +"""Provenance admission: apply a success transport, or admit exact staged paths.""" +from __future__ import annotations + +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, + ) -> 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 not omit_transport: + data["transport"] = { + "ref": ref, + "parent": parent if transport_parent is None else transport_parent, + "attempt_id": "attempt-1", + } + path = self.root / f"receipt-{uuid.uuid4().hex}.json" + path.write_text(json.dumps(data), encoding="utf-8") + return path + + def _history( + self, + *, + parent: str, + ref: str, + paths: list[str], + status: str = "success", + repo: str | None = None, + ) -> None: + record = { + "schema": agent_exec.HISTORY_SCHEMA, + "terminal_status": status, + "repo": repo if repo is not None else str(self.repo.resolve()), + "changed_paths": paths, + "transport": {"ref": ref, "parent": parent}, + } + common = Path( + _git( + self.repo, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ).stdout.strip() + ) + dest = common / "agent-exec" + dest.mkdir(mode=0o700, exist_ok=True) + with (dest / "execution-history.jsonl").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True) + "\n") + + 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"}) + receipt = self._receipt( + parent=self.head, ref=ref, paths=["src/marker.txt"] + ) + 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_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 provenance", 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 provenance", partial.stderr) + + 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 provenance", proc.stderr) + 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 provenance", 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 provenance", 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, + ) -- 2.54.0 From 11973f52448bc02ec10e89c955eeccf5cb7896ea Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 17:24:50 +0000 Subject: [PATCH 10/23] Require CLI provenance without constraining agent reasoning Why: Agents must use configured CLIs for implementation and testing, while remaining free to inspect, plan, test, and iterate within bounded scope. Evidence: Task-card contract tests and the full 430-test governance discovery pass with 19 existing skips; diff-check passes. Impact: Defines three safe attempts per normal CLI, one emergency Codex attempt, fail-stop terminals, and mandatory apply/admit integration. Refs: feature/agent-cli-enforcement --- AGENTS.md | 26 ++++++++++++------ policy/adoption.md | 2 +- policy/delivery.md | 45 +++++++++++++++++++++++++------- policy/governance.md | 24 +++++++++++------ policy/tool-adaptation-matrix.md | 2 +- scripts/task.sh | 2 +- tests/test_task.py | 17 +++++++++++- 7 files changed, 88 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c76ed91c..67589f43 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,20 @@ 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 次全新尝试,然后才换下一候选。 + 三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划 + 或独立 Review,不进入这三个正常角色。 +- 显式 fail-stop 立即停止,不重试、不换候选、不启动紧急尝试:`failed-unknown`、`scope-expansion`、 + `blocked`、`cancelled`、无效配置、未对账 Git 状态与安全失败。总超时,以及 stdin/stdout/stderr + 都没有活动的空闲超时,归入 `failed-unknown`。 +- 一次尝试只在显式终态失败、进程退出、总超时或上述空闲超时时结束。CLI 可在目标、范围与安全边界内 + 自主检查、计划、测试并迭代。 +- 实施变更只经成功 transport 由 `scripts/agent_exec.py apply` 集成,或由 pre-commit `admit` 按精确内容 + 证明来源。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/policy/adoption.md b/policy/adoption.md index 88377f87..84535288 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 不直接实施);仅真实决策点需人工;任务卡提供记录,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..2731a3e1 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,23 @@ 产生仓库变更的实施工作拆成可独立实施的单元。任务卡默认记 `实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。执行路由以根目录 [AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 为唯一权威;按角色声明的候选顺序在新进程与 -隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程。 +隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程,也不得 resume 同一次尝试。 + +实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败 +最多 3 次全新尝试:`unavailable`,或工作区已精确恢复的 `failed-clean`。三次仍属这类失败后才进入 +下一候选。三个正常候选都安全耗尽后,才允许一次全新的 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 可在该单元的目标、范围与安全边界内自主检查、计划、测试并迭代。 主模型(host)负责计划、拆解、有界单元包、强制范围、检查与审阅实际 diff、运行权威验证、 规范提交、管理 PR/CI/合入、写入证据与任务管理元数据,以及安全清理。权威验证、集成、合入与 @@ -82,11 +100,18 @@ `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。 +host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/specs/**` 和 +`docs/superpowers/plans/**`。除此之外,host 不直接撰写实施单元。 + +未配置紧急候选时,控制器仍可能在安全耗尽后写出 `host-fallback-authorized`。现行 +[AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 已配置紧急候选,这个状态不再授权 host 撰写实施单元。 +已归档任务卡里的旧表述保持原样,不改写成现行规则。 + +规划、只读调查、证据捕获、任务卡与 superpowers 规格/计划,以及 apply 或 admit 通过之后的集成、 +验证、提交和 PR/CI/合入,属于 host 管理工作,不递归创建另一实施单元。 本条不重新引入逐阶段人工门禁;goal-first 的决策点、严格范围、先可用再迭代、独立 Review、 证据、平台准入与高后果双钥匙规则继续有效。 diff --git a/policy/governance.md b/policy/governance.md index 329b82f5..c7852ae1 100644 --- a/policy/governance.md +++ b/policy/governance.md @@ -8,17 +8,25 @@ 智能体审阅者及对象版本;权威验证与合入仍由 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。显式 fail-stop 立即停止。一次尝试只在显式终态失败、进程退出、总超时, +或 stdin/stdout/stderr 都没有活动的空闲超时时结束;推理时长、工具次数、规划方式和首次写文件时间 +都不是结束条件。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。路由细则见 +[迭代交付](delivery.md)。 + +实施变更进入仓库,只通过成功 transport 的 `scripts/agent_exec.py apply`,或 pre-commit `admit` +对暂存内容的精确来源核对。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` +与 `docs/superpowers/plans/**`。host 不直接撰写实施单元。`failed-unknown`、`scope-expansion`、 +无效配置、未对账 Git 状态与安全失败都不授权继续路由或代写。 ## 启动与恢复 diff --git a/policy/tool-adaptation-matrix.md b/policy/tool-adaptation-matrix.md index fe18d621..7528b5d0 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 不直接实施 | 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/task.sh b/scripts/task.sh index 320a7742..ec92a255 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 直接撰写该单元。原始日志保留在源码外收据路径。 | 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | |---|---|---|---|---|---|---|---|---|---|---| diff --git a/tests/test_task.py b/tests/test_task.py index b118a458..4af9dd41 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -728,7 +728,22 @@ 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.assertNotIn("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) for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"): self.assertIn(field + ":", card) self.assertIn("## 交付调度\n", card) -- 2.54.0 From 529d8aa5c7fc5e19d6dab882ddd1903ec184afda Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 17:32:01 +0000 Subject: [PATCH 11/23] Record the sync checkpoint Why: bind the task validation checkpoint to the integration baseline. Evidence: SELF recursive component checks; sync_base=bc5a1194984433c4098f1961f5280b3d9ee20995; before_record=f0bcbe018dd07bd03371dae856f76bc1c2907fb1. Impact: task metadata only. Refs: .task/feature-agent-cli-enforcement.md --- .task/feature-agent-cli-enforcement.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md index 6eeb68b8..a39da7f6 100644 --- a/.task/feature-agent-cli-enforcement.md +++ b/.task/feature-agent-cli-enforcement.md @@ -147,3 +147,4 @@ "receipt_sha256": "417a8e59a4d750ec1e81b61f92b2d71f123c5de628d0fb4e3d4d275453de32ed" } ``` +| 同步(记录前对象) | bc5a1194984433c4098f1961f5280b3d9ee20995 | f0bcbe018dd07bd03371dae856f76bc1c2907fb1 | 2026-10-15T17:32:01Z | -- 2.54.0 From 16fb93a300499da1df0b10bcad38084654646b7b Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 17:55:07 +0000 Subject: [PATCH 12/23] Fail stop unsafe worker outcomes and preserve rename sources Why: Safe routing must not retry unproven cleanup, ignore worker fail-stop terminals, or lose out-of-scope rename sources. Evidence: New tests were RED for each Review finding; 44 controller and admission tests now pass with diff-check clean. Impact: Tightens safety classification and path inventory only; routing order and provenance contract remain unchanged. Refs: independent Codex review of feature/agent-cli-enforcement --- scripts/agent_exec.py | 119 ++++++++++---- tests/test_agent_exec.py | 245 +++++++++++++++++++++++++++++ tests/test_agent_exec_admission.py | 26 +++ 3 files changed, 362 insertions(+), 28 deletions(-) diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py index 4f98e4c2..333521e0 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -925,6 +925,16 @@ def _run_attempt( ) clean_exit = proc_meta["exit_code"] == 0 + # 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"}, + ) + # Never report success from worker prose alone — require exit + admin + in-scope diff + transport. if clean_exit and changed and not out_of_scope: # Recheck path bounds immediately before host-owned transport. @@ -979,7 +989,27 @@ def _run_attempt( ) if clean_exit and not changed: + # No diff does not prove an authorized external effect was reconciled. + if _external_side_effects_unproven(unit, worker_result): + return _preserved( + "failed-unknown", + ( + "worker exited 0 without in-scope changes; " + "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, + }, + ) restored = _restore_worktree(repo, worktree, base["head"]) + if not restored: + return _preserved( + "failed-unknown", + "exact restore failed", + cleanup_extra={"reason": "exact restore failed"}, + ) return AttemptRecord( attempt_id=attempt_id, profile=profile.name, @@ -993,7 +1023,7 @@ def _run_attempt( inventory_paths=[], raw_log=str(raw_log), summary=summary, - cleanup={"exact_restore": restored}, + cleanup={"exact_restore": True}, observed_version=probe.observed_version, ) @@ -1044,6 +1074,12 @@ def _run_attempt( ) restored = _restore_worktree(repo, worktree, base["head"]) + if not restored: + return _preserved( + "failed-unknown", + "exact restore failed", + cleanup_extra={"reason": "exact restore failed"}, + ) return AttemptRecord( attempt_id=attempt_id, profile=profile.name, @@ -1058,7 +1094,7 @@ def _run_attempt( raw_log=str(raw_log), summary=summary, cleanup={ - "exact_restore": restored, + "exact_restore": True, "external_objects_reconciled": True, "network": False, "external_cost": False, @@ -1410,6 +1446,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. @@ -1419,23 +1491,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( @@ -1642,11 +1711,12 @@ def _worktree_pointer_snapshot( 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. + """Network or external_cost units cannot be failed-clean without reconciliation. - 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. + A clean exit with no file diff is included. failed-clean still requires exact + restore plus affirmative evidence that no authorized external object remains + unknown. Worker prose is not that evidence; only an explicit trustworthy + reconciliation record would clear the unit, and none is accepted here. """ del worker_result # Worker prose alone never proves external reconciliation. return bool(unit.network or unit.external_cost) @@ -2209,15 +2279,8 @@ def _commit_parents(repo: Path, commit: str) -> list[str] | None: def _name_status_paths(text: str) -> list[str]: paths: list[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: - paths.append(parts[2]) - elif len(parts) >= 2: - paths.append(parts[1]) + for _status, rec_paths in _parse_name_status(text): + paths.extend(rec_paths) return paths diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index 1e4d6319..1dc166da 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -1930,6 +1930,251 @@ 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": "custom", "executable": "renamer"}, + "renamenext": {"adapter": "custom", "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_unproven_restore_is_failed_unknown_and_stops(self) -> None: + """failed-clean is allowed only after _restore_worktree succeeds.""" + 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": "custom", + "executable": f"restorer-{label}", + }, + "restorenext": { + "adapter": "custom", + "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" + with mock.patch.object( + agent_exec, "_restore_worktree", return_value=False + ): + 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": "custom", "executable": "netclean"}, + "netcleannext": {"adapter": "custom", "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": "custom", + "executable": f"failstop-{label}", + }, + "failnext": { + "adapter": "custom", + "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() + class AgentExecHistoryTests(unittest.TestCase): def setUp(self) -> None: diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py index f0c50eab..bf29adaf 100644 --- a/tests/test_agent_exec_admission.py +++ b/tests/test_agent_exec_admission.py @@ -370,6 +370,32 @@ class AgentExecAdmissionTests(unittest.TestCase): self.assertIn("admit: no provenance", 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") -- 2.54.0 From 28f000af8bff0652ee46e2cc854a2c5a4cf71623 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 18:12:52 +0000 Subject: [PATCH 13/23] Sign execution history for transport admission Why: Success-shaped receipts and caller-writable history must not authorize apply/admit without controller-originated provenance. Evidence: Fabricated, unsigned, tampered-history, and moved-ref tests were RED before HMAC binding; 52 controller/history/admission tests now pass. Impact: Stores a local 0600 attestation key, signs successful history including transport commit SHA, and verifies it before apply/admit. Refs: independent Codex review of feature/agent-cli-enforcement --- scripts/agent_exec.py | 340 ++++++++++++++++++++++++++--- tests/test_agent_exec.py | 120 ++++++++++ tests/test_agent_exec_admission.py | 235 ++++++++++++++++++-- 3 files changed, 645 insertions(+), 50 deletions(-) diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py index 333521e0..44f1a4df 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -2,18 +2,23 @@ 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 @@ -1971,11 +1976,18 @@ def _finish_run( ) history_path: Path | None = None try: - history_path = _append_execution_history(repo, receipt, secret_values) - receipt["history"] = { + history_path, attestation = _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 attestation: + recorded["attestation"] = attestation + receipt["history"] = recorded except Exception as exc: receipt["history"] = { "path": str(history_path) if history_path is not None else None, @@ -1995,32 +2007,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") + attestation: str | None = None + if receipt.get("terminal_status") == "success": + _bind_success_transport_commit(repo, receipt) + key = _ensure_attestation_key(repo) + record = _execution_history_record(repo, receipt) + line, attestation = _signed_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: @@ -2033,7 +2045,7 @@ def _append_execution_history( fcntl.flock(fd, fcntl.LOCK_UN) finally: os.close(fd) - return history_path + return history_path, attestation def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, Any]: @@ -2073,6 +2085,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, @@ -2338,7 +2353,12 @@ def _valid_transport_ref(ref: object) -> str | None: return ref -def _history_file(repo: Path) -> Path | None: +_ATTESTATION_KEY_NAME = "attestation.key" +_ATTESTATION_KEY_BYTES = 32 + + +def _git_common_dir(repo: Path) -> Path: + repo = repo.resolve() common = _git( repo, "rev-parse", @@ -2347,11 +2367,200 @@ def _history_file(repo: Path) -> Path | None: check=False, ) if common.returncode != 0 or not common.stdout.strip(): - raise RuntimeError(common.stderr.strip() or "unable to resolve git common dir") + 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() - history_dir = common_dir / "agent-exec" + return common_dir + + +def _canonical_json(data: dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _history_attestation(key: bytes, record: dict[str, Any]) -> str: + body = {field: value for field, value in record.items() if field != "attestation"} + return hmac.new(key, _canonical_json(body).encode("utf-8"), hashlib.sha256).hexdigest() + + +def _history_attestation_ok(key: bytes, record: dict[str, Any]) -> bool: + presented = record.get("attestation") + if not isinstance(presented, str) or len(presented) != 64: + return False + return hmac.compare_digest(_history_attestation(key, record), presented) + + +def _signed_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 = _history_attestation(key, body) + body["attestation"] = 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( + _ATTESTATION_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("attestation key must be a regular file") + if stat.S_IMODE(current.st_mode) != 0o600: + raise RuntimeError("attestation key mode must be 0600") + if current.st_size != _ATTESTATION_KEY_BYTES: + raise RuntimeError("attestation key must be 32 bytes") + os.lseek(fd, 0, os.SEEK_SET) + key = os.read(fd, _ATTESTATION_KEY_BYTES) + if len(key) != _ATTESTATION_KEY_BYTES: + raise RuntimeError("attestation key must be 32 bytes") + return key + finally: + os.close(fd) + + +def _key_is_symlink(dir_fd: int) -> bool: + try: + info = os.stat(_ATTESTATION_KEY_NAME, dir_fd=dir_fd, follow_symlinks=False) + except FileNotFoundError: + return False + return stat.S_ISLNK(info.st_mode) + + +def _read_attestation_key(repo: Path) -> bytes | None: + """Read the local attestation 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("attestation 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, "attestation key must not be a symlink") from exc + + +def _ensure_attestation_key(repo: Path) -> bytes: + """Create or read the 32-byte key under the Git common dir. + + This binds controller history to later apply/admit checks. It does not + protect against the same OS user deliberately reading the key file. + """ + 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("attestation key must not be a symlink") + flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC + try: + fd = os.open(_ATTESTATION_KEY_NAME, flags, 0o600, dir_fd=dir_fd) + except OSError as exc: + raise _symlink_oserror(exc, "attestation 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("attestation key must be a regular file") + if current.st_size == 0: + key = os.urandom(_ATTESTATION_KEY_BYTES) + written = os.write(fd, key) + if written != _ATTESTATION_KEY_BYTES: + raise RuntimeError("attestation key write was short") + os.fsync(fd) + return key + if current.st_size != _ATTESTATION_KEY_BYTES: + raise RuntimeError("attestation key must be 32 bytes") + os.lseek(fd, 0, os.SEEK_SET) + key = os.read(fd, _ATTESTATION_KEY_BYTES) + if len(key) != _ATTESTATION_KEY_BYTES: + raise RuntimeError("attestation key must be 32 bytes") + if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600: + raise RuntimeError("attestation 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 @@ -2381,7 +2590,10 @@ def _transport_covers( *, head: str, required: set[str], + key: bytes | None, ) -> bool: + if key is None or not _history_attestation_ok(key, record): + return False if record.get("schema") != HISTORY_SCHEMA: return False if record.get("terminal_status") != "success": @@ -2393,20 +2605,70 @@ def _transport_covers( return False ref = _valid_transport_ref(transport.get("ref")) parent = transport.get("parent") - if ref is None or not isinstance(parent, str): + signed_commit = transport.get("commit") + if ref is None or not isinstance(parent, str) or not isinstance(signed_commit, str): + return False + if _resolve_commit(repo, ref) != signed_commit: return False if _resolve_commit(repo, parent) != head: return False - commit = _resolve_commit(repo, ref) - if commit is None or _commit_parents(repo, commit) != [head]: + if _commit_parents(repo, signed_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, commit) + diff_paths = _diff_paths(repo, head, signed_commit) if _non_exempt(changed) != required or _non_exempt(diff_paths) != required: return False - return _index_matches_commit(repo, commit, required) + return _index_matches_commit(repo, signed_commit, required) + + +def _apply_signed_history_ok( + repo: Path, + receipt: dict[str, Any], + *, + commit: str, + base_head: str, +) -> bool: + key = _read_attestation_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 _history_attestation_ok(key, record): + return True + return False def cmd_apply(repo: Path, receipt_path: Path) -> int: @@ -2472,6 +2734,15 @@ def cmd_apply(repo: Path, receipt_path: Path) -> int: ): return _reject("apply: mismatched transport", secrets) + try: + authorized = _apply_signed_history_ok( + repo, receipt, commit=commit, base_head=resolved_base + ) + except Exception as exc: + return _reject(f"apply: no signed history: {exc}", secrets) + if not authorized: + return _reject("apply: no signed history", secrets) + merge = _git(repo, "merge", "--ff-only", "--quiet", commit, check=False) if merge.returncode != 0: return _reject( @@ -2500,8 +2771,11 @@ def cmd_admit(repo: Path) -> int: 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_attestation_key(repo) for record in _load_history_records(repo): - if _transport_covers(repo, record, head=head, required=required): + if _transport_covers( + repo, record, head=head, required=required, key=key + ): print("admit: ok") return EXIT_SUCCESS except Exception as exc: diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index 1dc166da..f079e019 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 @@ -2342,6 +2344,124 @@ 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": "custom", "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["attestation"], record["attestation"]) + 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_attestation_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 = ( diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py index bf29adaf..c97bb854 100644 --- a/tests/test_agent_exec_admission.py +++ b/tests/test_agent_exec_admission.py @@ -1,6 +1,8 @@ """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 @@ -148,6 +150,8 @@ class AgentExecAdmissionTests(unittest.TestCase): 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, @@ -161,16 +165,65 @@ class AgentExecAdmissionTests(unittest.TestCase): }, "changed_paths": paths, } + if run_id is not None: + data["run_id"] = run_id if not omit_transport: - data["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, *, @@ -179,26 +232,39 @@ class AgentExecAdmissionTests(unittest.TestCase): paths: list[str], status: str = "success", repo: str | None = None, - ) -> None: - record = { + 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}, + "transport": {"ref": ref, "parent": parent, "commit": commit}, } - common = Path( - _git( - self.repo, - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - ).stdout.strip() - ) - dest = common / "agent-exec" + 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) - with (dest / "execution-history.jsonl").open("a", encoding="utf-8") as handle: - handle.write(json.dumps(record, sort_keys=True) + "\n") + 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 @@ -208,9 +274,23 @@ class AgentExecAdmissionTests(unittest.TestCase): def test_apply_fast_forwards_clean_matching_transport(self) -> None: commit, ref = self._transport_commit({"src/marker.txt": "shipped\n"}) - receipt = self._receipt( - parent=self.head, ref=ref, paths=["src/marker.txt"] + 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) ) @@ -309,6 +389,64 @@ class AgentExecAdmissionTests(unittest.TestCase): ), ) + 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 signed history", 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" @@ -360,6 +498,69 @@ class AgentExecAdmissionTests(unittest.TestCase): self.assertEqual(partial.returncode, agent_exec.EXIT_BLOCKED, partial.stderr) self.assertIn("admit: no provenance", 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 provenance", 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 provenance", 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"]) -- 2.54.0 From 818e17f4216fa3253d16c886e37c10a04408334c Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 18:44:55 +0000 Subject: [PATCH 14/23] Remove host fallback and enforce strict normal routes Why: The approved contract requires exactly three safe attempts for Cursor, agy, and PI, then one Codex emergency attempt, never host implementation authority. Evidence: Routing/config tests were RED for never, max attempts, forbidden adapters, and legacy host fallback; 133 related tests now pass with one opt-in probe skipped. Impact: Normal emergency-mode roles reject all non-Cursor/agy/PI adapters, project fallback is never, and Codex remains available through emergency/review paths. Refs: independent Codex review of feature/agent-cli-enforcement --- AGENT-EXECUTION.md | 17 +- AGENTS.md | 4 +- policy/adoption.md | 2 +- policy/delivery.md | 8 +- policy/governance.md | 3 +- policy/tool-adaptation-matrix.md | 2 +- scripts/agent_exec.py | 22 +- scripts/agent_exec_config.py | 37 +++- scripts/task.sh | 2 +- tests/test_agent_exec.py | 167 ++++++++++++--- tests/test_agent_exec_config.py | 339 +++++++++++++++++++++++++++---- tests/test_task.py | 3 +- 12 files changed, 494 insertions(+), 112 deletions(-) diff --git a/AGENT-EXECUTION.md b/AGENT-EXECUTION.md index 93a3ade0..662f22b1 100644 --- a/AGENT-EXECUTION.md +++ b/AGENT-EXECUTION.md @@ -8,11 +8,16 @@ 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. Normal implementation, repair, and test routes stay Cursor, Antigravity -(`agy`), and PI. Each profile may be attempted up to -`max_attempts_per_profile`. 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. A configured -emergency profile replaces host write fallback. Cursor uses +(`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. @@ -22,7 +27,7 @@ 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 diff --git a/AGENTS.md b/AGENTS.md index 67589f43..73e0a8f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,9 @@ policy/delivery.md。 - 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准。每个候选对 安全可恢复失败(`unavailable` 或已精确恢复的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。 三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划 - 或独立 Review,不进入这三个正常角色。 + 或独立 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`。 diff --git a/policy/adoption.md b/policy/adoption.md index 84535288..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;实施/修复/测试正常候选仅为 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时只用于规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成须成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施);仅真实决策点需人工;任务卡提供记录,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 2731a3e1..925d46f3 100644 --- a/policy/delivery.md +++ b/policy/delivery.md @@ -106,9 +106,11 @@ Codex 平时只用于规划、方案设计、实施计划,或独立 Review。 host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/specs/**` 和 `docs/superpowers/plans/**`。除此之外,host 不直接撰写实施单元。 -未配置紧急候选时,控制器仍可能在安全耗尽后写出 `host-fallback-authorized`。现行 -[AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 已配置紧急候选,这个状态不再授权 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 管理工作,不递归创建另一实施单元。 diff --git a/policy/governance.md b/policy/governance.md index c7852ae1..604ee96b 100644 --- a/policy/governance.md +++ b/policy/governance.md @@ -18,7 +18,8 @@ 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败 最多 3 次全新尝试,全部安全耗尽后才允许一次 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、 -实施计划或独立 Review。显式 fail-stop 立即停止。一次尝试只在显式终态失败、进程退出、总超时, +实施计划或独立 Review。`fallback_to_host` 为 `never`。安全耗尽不发出 host-fallback-authorized, +终态为 unavailable 或 blocked,不授权 host 撰写实施单元。显式 fail-stop 立即停止。一次尝试只在显式终态失败、进程退出、总超时, 或 stdin/stdout/stderr 都没有活动的空闲超时时结束;推理时长、工具次数、规划方式和首次写文件时间 都不是结束条件。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。路由细则见 [迭代交付](delivery.md)。 diff --git a/policy/tool-adaptation-matrix.md b/policy/tool-adaptation-matrix.md index 7528b5d0..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);模板默认连续执行,仅真实决策点需人工,没有全流程授权强制;实施/修复/测试仅 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时仅规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成经成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施 | 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/平台准入 | +| 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 44f1a4df..2a571b40 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -436,27 +436,9 @@ def cmd_run( if action == "continue": terminal = "blocked" reason = attempt.reason - stop = True - - if ( - exhausted_ok - and not stop - and not emergency_name - 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" + # 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, diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py index 12470725..c8e37e7b 100644 --- a/scripts/agent_exec_config.py +++ b/scripts/agent_exec_config.py @@ -23,7 +23,12 @@ 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"}) +# Exact implementation/repair/test adapters when emergency_profile is set +# (the active project mode). Legacy configs without an emergency profile may +# also name adapter custom for controlled fixtures; they never receive host +# fallback. +NORMAL_ROLE_ADAPTERS = frozenset({"cursor", "antigravity", "pi"}) PROMPT_TRANSPORTS = frozenset({"stdin", "positional", "flag"}) OUTPUT_FORMATS = frozenset({"json", "stream-json", "jsonl", "text"}) @@ -344,6 +349,7 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig: raise ConfigError(f"unknown adapter: {profile.adapter!r}") _validate_emergency_profile(execution, profiles, roles) + _validate_normal_role_adapters(execution, profiles, roles) return ExecutionConfig( schema=schema, @@ -438,6 +444,10 @@ def _validate_emergency_profile( 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") @@ -452,6 +462,31 @@ def _validate_emergency_profile( ) +def _validate_normal_role_adapters( + execution: ExecutionPolicy, + profiles: Mapping[str, Profile], + roles: Mapping[str, tuple[str, ...]], +) -> None: + """Reject non-project adapters on implementation, repair, and test. + + Active project mode (``emergency_profile`` configured) allows only + ``NORMAL_ROLE_ADAPTERS``. Legacy configs without an emergency profile may + still use ``custom`` so controller fixtures keep working; that allowance + does not grant host fallback. + """ + allowed = NORMAL_ROLE_ADAPTERS + if execution.emergency_profile is None: + allowed = allowed | {"custom"} + for role in ("implementation", "repair", "test"): + for name in roles[role]: + adapter = profiles[name].adapter + if adapter not in allowed: + raise ConfigError( + f"profile {name!r} in role {role!r} must use adapter " + "cursor, antigravity, or pi" + ) + + 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/task.sh b/scripts/task.sh index ec92a255..a7478bce 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -213,7 +213,7 @@ ${parent_note} ## Agent 实施收据 -按根目录 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 直接撰写该单元。原始日志保留在源码外收据路径。 +按根目录 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 f079e019..e1aba7e0 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -24,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]: @@ -67,6 +68,7 @@ def _write_config( *, profiles: dict[str, dict[str, object]], role_order: list[str], + review_order: list[str] | None = None, timeout_seconds: int = 30, idle_timeout_seconds: int | None = None, max_attempts_per_profile: int | None = None, @@ -92,6 +94,7 @@ def _write_config( custom_lines.append(" output_format: text") order = ", ".join(role_order) first = role_order[0] + review = ", ".join(review_order) if review_order is not None else first idle_line = ( f" idle_timeout_seconds: {idle_timeout_seconds}\n" if idle_timeout_seconds is not None @@ -124,7 +127,7 @@ def _write_config( implementation: [{order}] repair: [{first}] test: [{first}] - review: [{first}] + review: [{review}] profiles: """ @@ -193,6 +196,12 @@ def _codex_help() -> str: 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" + + class AgentExecControllerTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-ctrl-") @@ -399,7 +408,7 @@ 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"}, @@ -422,13 +431,16 @@ 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(data["terminal_status"], "unavailable") + self.assertNotEqual(data["terminal_status"], "host-fallback-authorized") self.assertEqual(len(data["attempts"]), 2) 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 = [ @@ -440,7 +452,41 @@ 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_legacy_exhaustion_without_emergency_does_not_authorize_host(self) -> None: + self._configure( + profiles={ + "m1": {"adapter": "custom", "executable": "gone-one"}, + "m2": {"adapter": "custom", "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_failed_clean_can_continue_after_exact_restore(self) -> None: @@ -584,12 +630,13 @@ class AgentExecControllerTests(unittest.TestCase): _write_fake_worker( self.bindir, "regular", + help_text=_pi_help(), body=textwrap.dedent( f"""\ printf 'regular\\n' >> "{launches}" - workspace="$1" - mkdir -p "$workspace/src" - printf 'tmp\\n' > "$workspace/src/marker.txt" + mkdir -p src + printf 'tmp\\n' > src/marker.txt + printf '%s\\n' '{{"terminal_status":"failed","summary":"regular failed clean","changed_files":["src/marker.txt"]}}' exit 9 """ ), @@ -625,7 +672,7 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "regular": {"adapter": "custom", "executable": "regular"}, + "regular": {"adapter": "pi", "executable": "regular", "model": "auto"}, "codex": {"adapter": "codex", "executable": "codex", "model": "auto"}, }, role_order=["regular"], @@ -636,6 +683,7 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual(loaded.execution.max_attempts_per_profile, 3) self.assertEqual(loaded.execution.emergency_profile, "codex") self.assertIn("codex", loaded.profiles) + self.assertEqual(loaded.profiles["regular"].adapter, "pi") self.assertEqual(loaded.profiles["codex"].adapter, "codex") for role_names in loaded.roles.values(): self.assertNotIn("codex", role_names) @@ -2269,7 +2317,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) @@ -2286,8 +2334,8 @@ 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( @@ -2314,11 +2362,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: @@ -2482,6 +2531,9 @@ U5_EXECUTABLE = { "kimi": "kimi", "opencode": "opencode", } +# Normal-role validation rejects every other adapter on implementation/repair/test. +# The probe still has to launch those adapters, so they sit only on review. +_PROBE_NORMAL_HOLDER = "piholder" U5_BRANCH = "feature/u5-real-probe" U5_MARKER_REL = "src/u5-marker.txt" U5_MARKER_BODY = "U5-REAL-PROBE-MARKER\n" @@ -2647,6 +2699,32 @@ 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]: + """Place one profile where strict normal-role validation allows it to run.""" + selected = { + "adapter": profile, + "executable": U5_EXECUTABLE[profile], + "model": "auto", + } + if profile in NORMAL_ROLE_ADAPTERS: + return {profile: selected}, [profile], None, "implementation" + return ( + { + _PROBE_NORMAL_HOLDER: { + "adapter": "pi", + "executable": "pi-role-holder", + "model": "auto", + }, + profile: selected, + }, + [_PROBE_NORMAL_HOLDER], + [profile], + "review", + ) + + def execute_marker_probe( profile: str, *, @@ -2699,16 +2777,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, @@ -2734,7 +2808,7 @@ def execute_marker_probe( "--repo", str(repo), "--role", - "implementation", + probe_role, "--unit", str(unit), "--receipt", @@ -2778,6 +2852,36 @@ 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. + + Normal adapters stay on implementation. Codex and other non-normal adapters + stay on review, beside an uninvoked pi holder, so strict role validation + still 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" + implementation = roles.get("implementation") + if profile in NORMAL_ROLE_ADAPTERS: + if set(profiles) != {profile}: + return "probe configured a profile other than the selection" + if implementation != [profile]: + return "implementation role is not exactly the selected profile" + return None + if set(profiles) != {profile, _PROBE_NORMAL_HOLDER}: + return "probe configured a profile other than the selection" + if implementation != [_PROBE_NORMAL_HOLDER] or roles.get("review") != [profile]: + return "non-normal adapter must run only 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, @@ -2824,18 +2928,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") @@ -3055,8 +3156,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_config.py b/tests/test_agent_exec_config.py index 4a0d5405..9fa34bf8 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] + implementation: [cursor, pi] repair: [cursor] - test: [claude] - review: [claude] + test: [pi] + review: [pi] profiles: cursor: @@ -43,9 +43,9 @@ MINIMAL_BLOCK = textwrap.dedent( model: auto enabled: true - claude: - adapter: claude - executable: claude + pi: + adapter: pi + executable: pi model: auto enabled: true """ @@ -76,7 +76,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", "pi")) self.assertEqual(cfg.profiles["cursor"].executable, "cursor-agent") self.assertEqual(cfg.custom_adapters, {}) self.assertEqual(validate_config(self.repo), []) @@ -112,7 +112,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 +127,15 @@ class AgentExecConfigTests(unittest.TestCase): raw_output_bytes: 4096 roles: - implementation: [cursor, claude] + implementation: [cursor, pi] repair: [cursor] - test: [claude] - review: [claude] + test: [pi] + review: [pi] profiles: - claude: - adapter: claude - executable: claude + pi: + adapter: pi + executable: pi model: auto enabled: true cursor: @@ -147,19 +147,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", "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, pi]", + "implementation: [cursor, pi, missing]", ) write_repo(self.repo, block) with self.assertRaises(ConfigError): @@ -175,15 +175,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) @@ -320,20 +320,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) @@ -412,14 +412,14 @@ class AgentExecConfigTests(unittest.TestCase): " max_attempts_per_profile: 3\n" " emergency_profile: codex\n", ).replace( - " claude:\n" - " adapter: claude\n" - " executable: claude\n" + " pi:\n" + " adapter: pi\n" + " executable: pi\n" " model: auto\n" " enabled: true\n", - " claude:\n" - " adapter: claude\n" - " executable: claude\n" + " pi:\n" + " adapter: pi\n" + " executable: pi\n" " model: auto\n" " enabled: true\n" "\n" @@ -454,9 +454,9 @@ class AgentExecConfigTests(unittest.TestCase): def test_emergency_profile_is_excluded_from_normal_roles(self) -> None: for role, replacement in ( - ("implementation", "[cursor, claude, codex]"), + ("implementation", "[cursor, pi, codex]"), ("repair", "[cursor, codex]"), - ("test", "[claude, codex]"), + ("test", "[pi, codex]"), ): write_repo(self.repo, _emergency_block(**{role: replacement})) with self.assertRaises(ConfigError) as ctx: @@ -465,35 +465,288 @@ class AgentExecConfigTests(unittest.TestCase): self.assertIn(role, str(ctx.exception)) def test_review_may_include_emergency_profile(self) -> None: - write_repo(self.repo, _emergency_block(review="[claude, codex]")) + 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"], ("claude", "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_legacy_without_emergency_custom_remains_parseable(self) -> None: + write_repo(self.repo, _legacy_custom_block()) + cfg = load_config(self.repo) + self.assertIsNone(cfg.execution.emergency_profile) + self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable") + self.assertEqual(cfg.profiles["mycustom"].adapter, "custom") + self.assertEqual(cfg.roles["implementation"], ("mycustom",)) + self.assertEqual(cfg.roles["repair"], ("mycustom",)) + self.assertEqual(cfg.roles["test"], ("mycustom",)) + self.assertIn("mycustom", cfg.custom_adapters) + + 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: custom stays a parseable fixture adapter.""" + 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, pi]", + repair: str = "[cursor]", + test: str = "[pi]", + 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", + "", + " 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 _emergency_block( *, include_profile: bool = True, adapter: str = "codex", enabled: str = "true", - implementation: str = "[cursor, claude]", + implementation: str = "[cursor, pi]", repair: str = "[cursor]", - test: str = "[claude]", - review: str = "[claude]", + test: str = "[pi]", + review: str = "[pi]", ) -> str: block = MINIMAL_BLOCK.replace( " raw_output_bytes: 10485760\n", - " raw_output_bytes: 10485760\n emergency_profile: codex\n", + " raw_output_bytes: 10485760\n" + " max_attempts_per_profile: 3\n" + " emergency_profile: codex\n", ) - block = block.replace("implementation: [cursor, claude]", f"implementation: {implementation}") + block = block.replace("implementation: [cursor, pi]", f"implementation: {implementation}") block = block.replace("repair: [cursor]", f"repair: {repair}") - block = block.replace("test: [claude]", f"test: {test}") - block = block.replace("review: [claude]", f"review: {review}") + block = block.replace("test: [pi]", f"test: {test}") + block = block.replace("review: [pi]", f"review: {review}") if not include_profile: return block return block.replace( diff --git a/tests/test_task.py b/tests/test_task.py index 4af9dd41..aca3b4e4 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -728,7 +728,6 @@ class TaskLifecycleTests(unittest.TestCase): "| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |", agent_receipts, ) - self.assertNotIn("host-fallback-authorized", agent_receipts) self.assertIn("Cursor、Antigravity(agy)和 PI", agent_receipts) self.assertIn("最多 3 次全新尝试", agent_receipts) self.assertIn("一次全新的 Codex CLI 紧急尝试", agent_receipts) @@ -744,6 +743,8 @@ class TaskLifecycleTests(unittest.TestCase): 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) -- 2.54.0 From 59232669d037d0f70bddf18f48c391d08cca0ea8 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 18:47:49 +0000 Subject: [PATCH 15/23] Record agent CLI enforcement progress Why: Preserve the approved scope, actual CLI attempts, independent testing, and first-review remediation status. Evidence: Task card names the exact sync/review objects and distinguishes direct CLI sessions from standard receipts. Impact: Task metadata only; implementation behavior is unchanged. Refs: feature/agent-cli-enforcement --- .task/feature-agent-cli-enforcement.md | 38 +++++++++++++++----------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md index a39da7f6..d9b72025 100644 --- a/.task/feature-agent-cli-enforcement.md +++ b/.task/feature-agent-cli-enforcement.md @@ -1,31 +1,33 @@ # feature/agent-cli-enforcement 任务卡 -任务主记录:;状态:进行中;owner:weichen +任务主记录:;状态:交付中;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 通过后合入) - 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收 -- 当前阶段:需求拆解;当前增量:I1(待定义) +- 当前阶段:首轮独立 Review 的 6 项阻塞已修复,等待精确 HEAD 重测与复审;当前增量:U1 路由状态机、U2 provenance admission、U3 规范与 Cursor 稳定通道 - 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) ## 目标与非目标 -<预期结果、受影响入口/模块/契约;明确不做什么。> +目标:让 CLI 调用由配置、状态机和提交门禁强制,而不是依赖智能体自觉;CLI 在目标/范围/安全边界内可充分思考、检查、测试和迭代。非目标:不限制 CLI 的推理时长、工具次数、规划方式或首次写文件时间;不建设通用平台或修改业务功能。 ## Agent 实施收据 -按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。仅当 scripts/agent_exec.py run 对该精确单元发出有效 host-fallback-authorized 收据后,当前 host 方可实施该单元。failed-unknown、scope-expansion、无效配置、未对账 Git 状态与安全失败均不授权回退。原始日志保留在源码外收据路径。 +按根目录 AGENT-EXECUTION.md 路由;正常实施/修复/测试候选仅 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次;全部安全耗尽后一次 Codex CLI 紧急尝试。host 不代写。成功实施只经 transport apply 或 pre-commit admit 的签名精确来源进入分支。直接 CLI 隔离会话的失败/成功按真实终态登记;没有标准收据时标明 `not-observable`,不补写。 | 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | |---|---|---|---|---|---|---|---|---|---|---| -| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — | +| 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` | ## 关联方向与规划 @@ -37,13 +39,16 @@ | ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | |---|---|---|---|---|---|---| -| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 | +| 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) -- 测试计划引用:待填写(含需求 ID 与验收条件) +- 测试计划引用:agent_exec/config/admission/task 聚焦测试;`make test-governance`;PR HEAD 三项 CI ## 阶段记录 @@ -54,9 +59,9 @@ | 需求表确认 | 待定 | 待执行 | — | — | — | | 方案设计 | 待定 | 待执行 | — | — | — | | 实施计划 | 待定 | 待执行 | — | — | — | -| 实施与自检 | 待定 | 待执行 | — | — | — | -| 正式测试与验收 | 待定 | 待执行 | — | — | — | -| 代码 Review | 待定 | 待执行 | — | — | — | +| 实施与自检 | `818e17f` 及后续 Review 修复 | 通过(SELF) | Cursor/agy/PI sessions + host 集成 | 2026-10-15 | 路由、来源、规范均已实现;实际失败会话如实保留 | +| 正式测试与验收 | `11973f5`(同步前同代码树) | 通过(REPLAY/SELF) | PI MiniMax 独立测试 session | 2026-10-15 | 130 项直接测试通过(1 skip);430 项治理测试通过(19 skips) | +| 代码 Review | `bc5a119...529d8aa` | 首轮 6 项阻塞均已整改,待复审 | 独立 Codex CLI session `01a1409f-4b2d-7e80-b8bd-b55b93fed9d5` | 2026-10-15 | provenance、rename、failed-clean、worker fail-stop、严格配置、host fallback | | 合入 | 待定 | 待执行 | — | — | — | | 合入核验与收口 | 待定 | 待执行 | — | — | — | @@ -71,6 +76,7 @@ | 节点 | sync_base_sha | tested_head_sha | checked_at | |---|---|---|---| | 启动 | 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee | 04acb027f6678d9a7a1fbdd3cdacb105d8fbd3ee | 2026-10-15T14:48:10Z | +| Review 前 | bc5a1194984433c4098f1961f5280b3d9ee20995 | 529d8aa5c7fc5e19d6dab882ddd1903ec184afda | 2026-10-15T17:32:01Z | ## 证据(Evidence) @@ -88,9 +94,9 @@ ## 结果与验证(完成后填写) -- 结果: -- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表> -- 影响与剩余项: +- 结果:配置路由、三次安全重试、一次 Codex 紧急档、稳定 Cursor 通道、HMAC transport 来源、apply/admit 与 pre-commit 门禁均已实现。 +- 验证:同步前同代码树的 130 项直接测试与 430 项治理测试通过;首轮独立 Review 的 6 项阻塞已修复;最终同步后复测、复审和 CI 待执行。 +- 影响与剩余项:只剩固定最终候选、独立复审、PR 精确 HEAD CI、合入及任务卡闭合。 ## 规划进展与下一步 @@ -100,11 +106,11 @@ - 前置条件/待确认:待核对 - 预期产物/验收条件:待定义 - 重复任务核对:待核对(已有/在途任务、PR 与规划记录) -- 启动授权:未授权(建议不自动启动新任务,仍须明确授权) +- 启动授权:用户明确确认实施并要求加强强制机制;范围外新任务仍须授权 ## 合入与清理 -- Review 记录:NOT_RUN(审阅智能体/平台身份、base/head、结论、问题闭合及重放依据) +- Review 记录:首轮独立 Codex CLI Review 完成;base `bc5a1194984433c4098f1961f5280b3d9ee20995`,head `529d8aa5c7fc5e19d6dab882ddd1903ec184afda`;6 项阻塞均已通过独立 CLI 单元整改,待固定新 HEAD 复审。 - 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测) - CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果) - 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验) -- 2.54.0 From 93aba6ba950ccf848800027ae9de13e3f72a059e Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 19:52:47 +0000 Subject: [PATCH 16/23] Enforce complete normal CLI routing Why: Emergency Codex fallback must not run before Cursor, Antigravity, and PI have each been exhausted. Evidence: Cursor transport 5e6c93f765c232783a7dccb05544e26c386fb031; 29 config tests, focused emergency controller test, and 449 governance tests passed with 19 skips. Impact: Emergency-mode implementation, repair, and test roles now require exactly one enabled profile for each normal adapter while preserving configured order. Refs: .task/feature-agent-cli-enforcement.md --- scripts/agent_exec_config.py | 28 +++++++ tests/test_agent_exec.py | 80 +++++++++++++------- tests/test_agent_exec_config.py | 129 ++++++++++++++++++++++++-------- 3 files changed, 180 insertions(+), 57 deletions(-) diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py index c8e37e7b..45d3e7f2 100644 --- a/scripts/agent_exec_config.py +++ b/scripts/agent_exec_config.py @@ -350,6 +350,7 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig: _validate_emergency_profile(execution, profiles, roles) _validate_normal_role_adapters(execution, profiles, roles) + _validate_normal_role_exactness(execution, profiles, roles) return ExecutionConfig( schema=schema, @@ -487,6 +488,33 @@ def _validate_normal_role_adapters( ) +def _validate_normal_role_exactness( + execution: ExecutionPolicy, + profiles: Mapping[str, Profile], + roles: Mapping[str, tuple[str, ...]], +) -> None: + """In active project mode each normal role needs one enabled profile per adapter. + + Disabled profiles do not count and may appear alongside the enabled one. + Review is exempt. + """ + if execution.emergency_profile is None: + return + 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/tests/test_agent_exec.py b/tests/test_agent_exec.py index e1aba7e0..009fced1 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -69,6 +69,7 @@ def _write_config( 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, @@ -95,6 +96,9 @@ def _write_config( order = ", ".join(role_order) first = role_order[0] review = ", ".join(review_order) if review_order is not None else first + repair_test = ( + ", ".join(repair_test_order) if repair_test_order is not None else first + ) idle_line = ( f" idle_timeout_seconds: {idle_timeout_seconds}\n" if idle_timeout_seconds is not None @@ -125,8 +129,8 @@ def _write_config( roles: implementation: [{order}] - repair: [{first}] - test: [{first}] + repair: [{repair_test}] + test: [{repair_test}] review: [{review}] profiles: @@ -202,6 +206,12 @@ def _pi_help() -> str: 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" + + class AgentExecControllerTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-ctrl-") @@ -627,20 +637,26 @@ class AgentExecControllerTests(unittest.TestCase): def test_emergency_profile_runs_once_after_regular_failed_clean(self) -> None: launches = self.root / "emergency-launches.log" launches.write_text("", encoding="utf-8") - _write_fake_worker( - self.bindir, - "regular", - help_text=_pi_help(), - body=textwrap.dedent( - f"""\ - printf 'regular\\n' >> "{launches}" - mkdir -p src - printf 'tmp\\n' > src/marker.txt - printf '%s\\n' '{{"terminal_status":"failed","summary":"regular failed clean","changed_files":["src/marker.txt"]}}' - exit 9 - """ - ), + normal = ( + ("cursor", "cursor-regular", _cursor_help()), + ("antigravity", "agy-regular", _antigravity_help()), + ("pi", "pi-regular", _pi_help()), ) + for adapter, executable, help_text in normal: + _write_fake_worker( + self.bindir, + executable, + help_text=help_text, + body=textwrap.dedent( + f"""\ + printf '{adapter}\\n' >> "{launches}" + mkdir -p src + printf 'tmp\\n' > src/marker.txt + printf '%s\\n' '{{"terminal_status":"failed","summary":"{adapter} failed clean","changed_files":["src/marker.txt"]}}' + exit 9 + """ + ), + ) _write_fake_worker( self.bindir, "codex", @@ -670,12 +686,16 @@ class AgentExecControllerTests(unittest.TestCase): """ ), ) + 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={ - "regular": {"adapter": "pi", "executable": "regular", "model": "auto"}, - "codex": {"adapter": "codex", "executable": "codex", "model": "auto"}, - }, - role_order=["regular"], + profiles=profiles, + role_order=order, + repair_test_order=order, max_attempts_per_profile=3, emergency_profile="codex", ) @@ -683,7 +703,10 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual(loaded.execution.max_attempts_per_profile, 3) self.assertEqual(loaded.execution.emergency_profile, "codex") self.assertIn("codex", loaded.profiles) - self.assertEqual(loaded.profiles["regular"].adapter, "pi") + 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) @@ -713,11 +736,11 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual( recorded, [ - ("regular", 1, "failed-clean", False), - ("regular", 2, "failed-clean", False), - ("regular", 3, "failed-clean", False), - ("codex", 1, "success", True), - ], + (adapter, ordinal, "failed-clean", 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])) @@ -725,7 +748,10 @@ class AgentExecControllerTests(unittest.TestCase): observed = [ line for line in launches.read_text(encoding="utf-8").splitlines() if line ] - self.assertEqual(observed, ["regular", "regular", "regular", "codex"]) + self.assertEqual( + observed, + ["cursor"] * 3 + ["antigravity"] * 3 + ["pi"] * 3 + ["codex"], + ) self.assertEqual(data["attempts"][-1]["summary"], "done") self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) self._assert_canonical_untouched() diff --git a/tests/test_agent_exec_config.py b/tests/test_agent_exec_config.py index 9fa34bf8..572ec9e7 100644 --- a/tests/test_agent_exec_config.py +++ b/tests/test_agent_exec_config.py @@ -406,30 +406,7 @@ class AgentExecConfigTests(unittest.TestCase): load_config(self.repo) def test_exposes_max_attempts_per_profile_and_emergency_profile(self) -> None: - block = MINIMAL_BLOCK.replace( - " raw_output_bytes: 10485760\n", - " raw_output_bytes: 10485760\n" - " max_attempts_per_profile: 3\n" - " emergency_profile: codex\n", - ).replace( - " pi:\n" - " adapter: pi\n" - " executable: pi\n" - " model: auto\n" - " enabled: true\n", - " pi:\n" - " adapter: pi\n" - " executable: pi\n" - " model: auto\n" - " enabled: true\n" - "\n" - " codex:\n" - " adapter: codex\n" - " executable: codex\n" - " model: auto\n" - " enabled: true\n", - ) - write_repo(self.repo, block) + 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") @@ -599,6 +576,77 @@ class AgentExecConfigTests(unittest.TestCase): 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_legacy_without_emergency_custom_remains_parseable(self) -> None: write_repo(self.repo, _legacy_custom_block()) cfg = load_config(self.repo) @@ -671,9 +719,9 @@ def _strict_routing_block( fallback: str = "never", max_attempts: str | None = "3", emergency: str | None = "codex", - implementation: str = "[cursor, pi]", - repair: str = "[cursor]", - test: str = "[pi]", + implementation: str = "[cursor, antigravity, pi]", + repair: str = "[cursor, antigravity, pi]", + test: str = "[pi, antigravity, cursor]", review: str = "[pi, codex]", extra_profiles: str = "", ) -> str: @@ -709,6 +757,12 @@ def _strict_routing_block( " model: auto", " enabled: true", "", + " antigravity:", + " adapter: antigravity", + " executable: agy", + " model: auto", + " enabled: true", + "", " pi:", " adapter: pi", " executable: pi", @@ -727,14 +781,24 @@ def _strict_routing_block( 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, pi]", - repair: str = "[cursor]", - test: str = "[pi]", + implementation: str = "[cursor, antigravity, pi]", + repair: str = "[cursor, antigravity, pi]", + test: str = "[pi, antigravity, cursor]", review: str = "[pi]", ) -> str: block = MINIMAL_BLOCK.replace( @@ -747,6 +811,11 @@ def _emergency_block( block = block.replace("repair: [cursor]", f"repair: {repair}") block = block.replace("test: [pi]", f"test: {test}") block = block.replace("review: [pi]", f"review: {review}") + block = block.replace( + " pi:\n", + _profile_lines("antigravity", "antigravity", "true") + "\n\n pi:\n", + 1, + ) if not include_profile: return block return block.replace( -- 2.54.0 From 6b00f4f8b043bdbca5fa51ef582d464bff72f992 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Thu, 15 Oct 2026 20:02:36 +0000 Subject: [PATCH 17/23] agent-exec transport b11015860dac4bb8a1943889211374b7/0dd2eb729ad2 --- AGENTS.md | 9 +- policy/delivery.md | 15 +- policy/governance.md | 7 +- scripts/agent_exec.py | 426 +++++++++++------------- scripts/agent_exec_adapters.py | 195 +++++++++++ scripts/hooks/pre-commit | 4 +- tests/test_agent_exec.py | 513 +++++++++++++++++++++++------ tests/test_agent_exec_admission.py | 16 +- 8 files changed, 832 insertions(+), 353 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73e0a8f9..00ac747b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,10 @@ policy/delivery.md。 - 外部 Agent 在新进程/隔离工作区执行一个有界单元;host 负责计划、拆解、范围审阅、权威验证、规范提交、 PR/CI/合入、证据与任务元数据及安全清理。Worker 不得提交、推送、开或合 PR、部署、改路由或派遣同伴。 - 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准。每个候选对 - 安全可恢复失败(`unavailable` 或已精确恢复的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。 + 安全可恢复失败(`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 撰写该单元。旧值 @@ -54,7 +57,9 @@ policy/delivery.md。 - 一次尝试只在显式终态失败、进程退出、总超时或上述空闲超时时结束。CLI 可在目标、范围与安全边界内 自主检查、计划、测试并迭代。 - 实施变更只经成功 transport 由 `scripts/agent_exec.py apply` 集成,或由 pre-commit `admit` 按精确内容 - 证明来源。host 直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与 + 做本地完整性核对。本地 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/policy/delivery.md b/policy/delivery.md index 925d46f3..e921b171 100644 --- a/policy/delivery.md +++ b/policy/delivery.md @@ -74,8 +74,10 @@ 隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程,也不得 resume 同一次尝试。 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败 -最多 3 次全新尝试:`unavailable`,或工作区已精确恢复的 `failed-clean`。三次仍属这类失败后才进入 -下一候选。三个正常候选都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试;这次不按每候选 3 次计算。 +最多 3 次全新尝试:`unavailable`,或外部无副作用已被机制证明的 `failed-clean`。三次仍属这类失败后才进入 +下一候选。单元包的 `network=false` 与 `external_cost=false` 只是声明,不证明外部干净;当前没有强制的 +网络/外部费用隔离或可信对账,所以已启动的失败尝试即使私有克隆可精确恢复,也记为 `failed-unknown` +并保留现场。三个正常候选都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试;这次不按每候选 3 次计算。 紧急尝试若仍是安全可恢复失败,运行停在 `blocked`。host 不接过该单元继续撰写。 Codex 平时只用于规划、方案设计、实施计划,或独立 Review。执行配置没有单独的 plan 角色; @@ -89,6 +91,12 @@ Codex 平时只用于规划、方案设计、实施计划,或独立 Review。 一次尝试只在显式终态失败、进程退出、总超时或该空闲超时时结束。推理时长、工具次数、规划方式和 首次写文件时间都不是结束条件。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/合入、写入证据与任务管理元数据,以及安全清理。权威验证、集成、合入与 任务卡证据所有权属于 host;worker 的局部自检不能替代任务卡证据、独立 Review、当前 HEAD 的 CI @@ -103,6 +111,9 @@ Codex 平时只用于规划、方案设计、实施计划,或独立 Review。 实施结果进入当前分支,必须来自一次成功 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 不直接撰写实施单元。 diff --git a/policy/governance.md b/policy/governance.md index 604ee96b..a1f266ec 100644 --- a/policy/governance.md +++ b/policy/governance.md @@ -25,9 +25,12 @@ [迭代交付](delivery.md)。 实施变更进入仓库,只通过成功 transport 的 `scripts/agent_exec.py apply`,或 pre-commit `admit` -对暂存内容的精确来源核对。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` +对暂存内容的本地完整性核对。本地 HMAC 执行历史与该钩子只记作本地完整性/篡改证据(SELF), +不是 GATE、可信身份,也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任来自精确 HEAD 的 +CI 或独立 REPLAY。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与 `docs/superpowers/plans/**`。host 不直接撰写实施单元。`failed-unknown`、`scope-expansion`、 -无效配置、未对账 Git 状态与安全失败都不授权继续路由或代写。 +无效配置、未对账 Git 状态与安全失败都不授权继续路由或代写。`network=false`/`external_cost=false` +声明不证明外部干净,已启动的失败尝试在没有强制隔离或可信对账时记 `failed-unknown`。 ## 启动与恢复 diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py index 2a571b40..80919a38 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -1,4 +1,10 @@ -"""Recoverable agent-exec controller: validate, run, apply transports, admit provenance.""" +"""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 @@ -29,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, @@ -183,7 +193,10 @@ def main(argv: list[str] | None = None) -> int: p_admit = sub.add_parser( "admit", - help="Admit staged paths backed by a success transport or host exemption", + help=( + "Locally check staged paths against a success transport or host " + "exemption (local integrity evidence, not GATE)" + ), ) p_admit.add_argument("--repo", required=True) @@ -791,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, @@ -922,7 +935,17 @@ def _run_attempt( cleanup_extra={"reason": "worker fail-stop terminal"}, ) - # Never report success from worker prose alone — require exit + admin + in-scope diff + transport. + 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: @@ -975,121 +998,23 @@ def _run_attempt( observed_version=probe.observed_version, ) - if clean_exit and not changed: - # No diff does not prove an authorized external effect was reconciled. - if _external_side_effects_unproven(unit, worker_result): - return _preserved( - "failed-unknown", - ( - "worker exited 0 without in-scope changes; " - "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, - }, - ) - restored = _restore_worktree(repo, worktree, base["head"]) - if not restored: - return _preserved( - "failed-unknown", - "exact restore failed", - cleanup_extra={"reason": "exact restore failed"}, - ) - 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": True}, - 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"]) - if not restored: - return _preserved( - "failed-unknown", - "exact restore failed", - cleanup_extra={"reason": "exact restore failed"}, - ) - 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": True, - "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, ...], *, @@ -1113,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. @@ -1386,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, } @@ -1408,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) @@ -1695,20 +1689,6 @@ def _worktree_pointer_snapshot( return snap -def _external_side_effects_unproven( - unit: UnitPacket, worker_result: dict[str, Any] | None -) -> bool: - """Network or external_cost units cannot be failed-clean without reconciliation. - - A clean exit with no file diff is included. failed-clean still requires exact - restore plus affirmative evidence that no authorized external object remains - unknown. Worker prose is not that evidence; only an explicit trustworthy - reconciliation record would clear the unit, and none is accepted here. - """ - 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()) @@ -1818,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. @@ -1840,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( @@ -1958,7 +1916,7 @@ def _finish_run( ) history_path: Path | None = None try: - history_path, attestation = _append_execution_history( + history_path, integrity_mac = _append_execution_history( repo, receipt, secret_values ) recorded: dict[str, Any] = { @@ -1967,8 +1925,8 @@ def _finish_run( "record_id": receipt.get("run_id"), "run_id": receipt.get("run_id"), } - if attestation: - recorded["attestation"] = attestation + if integrity_mac: + recorded["local_integrity_mac"] = integrity_mac receipt["history"] = recorded except Exception as exc: receipt["history"] = { @@ -2001,12 +1959,12 @@ def _append_execution_history( history_path = history_dir / "execution-history.jsonl" if history_path.is_symlink(): raise RuntimeError("agent-exec history file must not be a symlink") - attestation: str | None = None + integrity_mac: str | None = None if receipt.get("terminal_status") == "success": _bind_success_transport_commit(repo, receipt) - key = _ensure_attestation_key(repo) + key = _ensure_local_integrity_key(repo) record = _execution_history_record(repo, receipt) - line, attestation = _signed_history_line(record, key, secret_values) + 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" @@ -2027,7 +1985,7 @@ def _append_execution_history( fcntl.flock(fd, fcntl.LOCK_UN) finally: os.close(fd) - return history_path, attestation + return history_path, integrity_mac def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, Any]: @@ -2335,8 +2293,12 @@ def _valid_transport_ref(ref: object) -> str | None: return ref -_ATTESTATION_KEY_NAME = "attestation.key" -_ATTESTATION_KEY_BYTES = 32 +# 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: @@ -2363,27 +2325,27 @@ def _canonical_json(data: dict[str, Any]) -> str: return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) -def _history_attestation(key: bytes, record: dict[str, Any]) -> str: - body = {field: value for field, value in record.items() if field != "attestation"} +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 _history_attestation_ok(key: bytes, record: dict[str, Any]) -> bool: - presented = record.get("attestation") +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(_history_attestation(key, record), presented) + return hmac.compare_digest(_local_integrity_mac(key, record), presented) -def _signed_history_line( +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 = _history_attestation(key, body) - body["attestation"] = mac + mac = _local_integrity_mac(key, body) + body[_LOCAL_INTEGRITY_FIELD] = mac return _canonical_json(body) + "\n", mac @@ -2456,22 +2418,22 @@ def _locked_agent_exec_dir(repo: Path, *, create: bool) -> Iterator[int | None]: def _read_key_at(dir_fd: int) -> bytes: fd = os.open( - _ATTESTATION_KEY_NAME, + _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("attestation key must be a regular file") + raise RuntimeError("local integrity key must be a regular file") if stat.S_IMODE(current.st_mode) != 0o600: - raise RuntimeError("attestation key mode must be 0600") - if current.st_size != _ATTESTATION_KEY_BYTES: - raise RuntimeError("attestation key must be 32 bytes") + 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, _ATTESTATION_KEY_BYTES) - if len(key) != _ATTESTATION_KEY_BYTES: - raise RuntimeError("attestation key must be 32 bytes") + 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) @@ -2479,63 +2441,63 @@ def _read_key_at(dir_fd: int) -> bytes: def _key_is_symlink(dir_fd: int) -> bool: try: - info = os.stat(_ATTESTATION_KEY_NAME, dir_fd=dir_fd, follow_symlinks=False) + 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_attestation_key(repo: Path) -> bytes | None: - """Read the local attestation key. Never creates one and never follows links.""" +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("attestation key must not be a symlink") + 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, "attestation key must not be a symlink") from exc + raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc -def _ensure_attestation_key(repo: Path) -> bytes: +def _ensure_local_integrity_key(repo: Path) -> bytes: """Create or read the 32-byte key under the Git common dir. - This binds controller history to later apply/admit checks. It does not - protect against the same OS user deliberately reading the key file. + 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("attestation key must not be a symlink") + 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(_ATTESTATION_KEY_NAME, flags, 0o600, dir_fd=dir_fd) + fd = os.open(_LOCAL_INTEGRITY_KEY_NAME, flags, 0o600, dir_fd=dir_fd) except OSError as exc: - raise _symlink_oserror(exc, "attestation key must not be a symlink") from 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("attestation key must be a regular file") + raise RuntimeError("local integrity key must be a regular file") if current.st_size == 0: - key = os.urandom(_ATTESTATION_KEY_BYTES) + key = os.urandom(_LOCAL_INTEGRITY_KEY_BYTES) written = os.write(fd, key) - if written != _ATTESTATION_KEY_BYTES: - raise RuntimeError("attestation key write was short") + if written != _LOCAL_INTEGRITY_KEY_BYTES: + raise RuntimeError("local integrity key write was short") os.fsync(fd) return key - if current.st_size != _ATTESTATION_KEY_BYTES: - raise RuntimeError("attestation key must be 32 bytes") + 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, _ATTESTATION_KEY_BYTES) - if len(key) != _ATTESTATION_KEY_BYTES: - raise RuntimeError("attestation key must be 32 bytes") + 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("attestation key mode must be 0600") + raise RuntimeError("local integrity key mode must be 0600") return key finally: os.close(fd) @@ -2574,7 +2536,7 @@ def _transport_covers( required: set[str], key: bytes | None, ) -> bool: - if key is None or not _history_attestation_ok(key, record): + if key is None or not _local_integrity_ok(key, record): return False if record.get("schema") != HISTORY_SCHEMA: return False @@ -2587,32 +2549,32 @@ def _transport_covers( return False ref = _valid_transport_ref(transport.get("ref")) parent = transport.get("parent") - signed_commit = transport.get("commit") - if ref is None or not isinstance(parent, str) or not isinstance(signed_commit, str): + 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) != signed_commit: + if _resolve_commit(repo, ref) != recorded_commit: return False if _resolve_commit(repo, parent) != head: return False - if _commit_parents(repo, signed_commit) != [head]: + 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, signed_commit) + 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, signed_commit, required) + return _index_matches_commit(repo, recorded_commit, required) -def _apply_signed_history_ok( +def _apply_local_integrity_ok( repo: Path, receipt: dict[str, Any], *, commit: str, base_head: str, ) -> bool: - key = _read_attestation_key(repo) + key = _read_local_integrity_key(repo) if key is None: return False run_id = receipt.get("run_id") @@ -2648,7 +2610,7 @@ def _apply_signed_history_ok( continue if rec_transport.get("commit") != commit: continue - if _history_attestation_ok(key, record): + if _local_integrity_ok(key, record): return True return False @@ -2717,13 +2679,13 @@ def cmd_apply(repo: Path, receipt_path: Path) -> int: return _reject("apply: mismatched transport", secrets) try: - authorized = _apply_signed_history_ok( + authorized = _apply_local_integrity_ok( repo, receipt, commit=commit, base_head=resolved_base ) except Exception as exc: - return _reject(f"apply: no signed history: {exc}", secrets) + return _reject(f"apply: no local integrity record: {exc}", secrets) if not authorized: - return _reject("apply: no signed history", secrets) + return _reject("apply: no local integrity record", secrets) merge = _git(repo, "merge", "--ff-only", "--quiet", commit, check=False) if merge.returncode != 0: @@ -2736,7 +2698,11 @@ def cmd_apply(repo: Path, receipt_path: Path) -> int: def cmd_admit(repo: Path) -> int: - """Accept staged host-managed paths, or paths exactly covered by one transport.""" + """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: @@ -2747,13 +2713,13 @@ def cmd_admit(repo: Path) -> int: return EXIT_SUCCESS head = _resolve_commit(repo, "HEAD") if head is None: - return _reject("admit: no provenance", secrets) + 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_attestation_key(repo) + key = _read_local_integrity_key(repo) for record in _load_history_records(repo): if _transport_covers( repo, record, head=head, required=required, key=key @@ -2761,9 +2727,9 @@ def cmd_admit(repo: Path) -> int: print("admit: ok") return EXIT_SUCCESS except Exception as exc: - return _reject(f"admit: no provenance: {exc}", secrets) + return _reject(f"admit: no local integrity record: {exc}", secrets) listed = ", ".join(sorted(required)) - return _reject(f"admit: no provenance for {listed}", secrets) + return _reject(f"admit: no local integrity record for {listed}", secrets) if __name__ == "__main__": diff --git a/scripts/agent_exec_adapters.py b/scripts/agent_exec_adapters.py index 12fdbda0..dee9f352 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,177 @@ 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 last valid terminal record embedded in one final message.""" + if not isinstance(text, str) or not text: + return None + text = text[-_MAX_TERMINAL_TEXT_CHARS:] + decoder = json.JSONDecoder() + pos = text.rfind("{") + while pos != -1: + try: + candidate, _ = decoder.raw_decode(text, pos) + except json.JSONDecodeError: + candidate = None + record = _valid_terminal_record(candidate) + if record is not None: + return record + pos = text.rfind("{", 0, pos) + return None + + +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 +671,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/hooks/pre-commit b/scripts/hooks/pre-commit index 08778e53..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) @@ -11,4 +11,6 @@ echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || { exit 1 } 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/tests/test_agent_exec.py b/tests/test_agent_exec.py index 009fced1..029375d9 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -212,6 +212,84 @@ def _antigravity_help() -> str: 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-") @@ -308,19 +386,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", @@ -328,8 +393,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" @@ -338,38 +403,36 @@ 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": "custom", "executable": "worker-b"}}, + role_order=["b"], ) - self.base_snap = _canonical_snapshot(self.repo) 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: @@ -499,7 +562,10 @@ class AgentExecControllerTests(unittest.TestCase): self.assertFalse((self.repo / "src" / "marker.txt").exists()) self._assert_canonical_untouched() - def test_failed_clean_can_continue_after_exact_restore(self) -> None: + 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", @@ -516,12 +582,8 @@ 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 """ ), @@ -534,7 +596,10 @@ class AgentExecControllerTests(unittest.TestCase): 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", @@ -546,29 +611,26 @@ class AgentExecControllerTests(unittest.TestCase): "--receipt", str(receipt), ) - self.assertEqual(proc.returncode, agent_exec.EXIT_SUCCESS, proc.stderr + proc.stdout) + self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, proc.stderr + proc.stdout) 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(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_failed_clean_retries_profile_before_next_success(self) -> None: + 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, - "retryme", - body=textwrap.dedent( - f"""\ - printf 'retryme\\n' >> "{launches}" - workspace="$1" - mkdir -p "$workspace/src" - printf 'tmp\\n' > "$workspace/src/marker.txt" - exit 9 - """ - ), - ) _write_fake_worker( self.bindir, "thenok", @@ -586,7 +648,7 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "retryme": {"adapter": "custom", "executable": "retryme"}, + "retryme": {"adapter": "custom", "executable": "retryme-missing"}, "thenok": {"adapter": "custom", "executable": "thenok"}, }, role_order=["retryme", "thenok"], @@ -611,7 +673,7 @@ class AgentExecControllerTests(unittest.TestCase): observed = [ line for line in launches.read_text(encoding="utf-8").splitlines() if line ] - self.assertEqual(observed, ["retryme", "retryme", "retryme", "thenok"]) + self.assertEqual(observed, ["thenok"]) data = json.loads(receipt.read_text(encoding="utf-8")) self.assertEqual( [ @@ -623,9 +685,9 @@ class AgentExecControllerTests(unittest.TestCase): for attempt in data["attempts"] ], [ - ("retryme", 1, "failed-clean"), - ("retryme", 2, "failed-clean"), - ("retryme", 3, "failed-clean"), + ("retryme", 1, "unavailable"), + ("retryme", 2, "unavailable"), + ("retryme", 3, "unavailable"), ("thenok", 1, "success"), ], ) @@ -634,13 +696,15 @@ class AgentExecControllerTests(unittest.TestCase): ) self._assert_canonical_untouched() - def test_emergency_profile_runs_once_after_regular_failed_clean(self) -> None: + 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", _cursor_help()), - ("antigravity", "agy-regular", _antigravity_help()), - ("pi", "pi-regular", _pi_help()), + ("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( @@ -650,9 +714,6 @@ class AgentExecControllerTests(unittest.TestCase): body=textwrap.dedent( f"""\ printf '{adapter}\\n' >> "{launches}" - mkdir -p src - printf 'tmp\\n' > src/marker.txt - printf '%s\\n' '{{"terminal_status":"failed","summary":"{adapter} failed clean","changed_files":["src/marker.txt"]}}' exit 9 """ ), @@ -736,7 +797,7 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual( recorded, [ - (adapter, ordinal, "failed-clean", False) + (adapter, ordinal, "unavailable", False) for adapter in order for ordinal in (1, 2, 3) ] @@ -748,10 +809,7 @@ class AgentExecControllerTests(unittest.TestCase): observed = [ line for line in launches.read_text(encoding="utf-8").splitlines() if line ] - self.assertEqual( - observed, - ["cursor"] * 3 + ["antigravity"] * 3 + ["pi"] * 3 + ["codex"], - ) + 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() @@ -2068,8 +2126,8 @@ class AgentExecControllerTests(unittest.TestCase): self.assertEqual(data.get("host_fallback_authorizations", 0), 0) self._assert_canonical_untouched() - def test_unproven_restore_is_failed_unknown_and_stops(self) -> None: - """failed-clean is allowed only after _restore_worktree succeeds.""" + 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", @@ -2104,15 +2162,12 @@ class AgentExecControllerTests(unittest.TestCase): ) unit = _write_unit(self.root / f"unit-restore-{label}.json") receipt = self.receipts / f"restore-{label}.json" - with mock.patch.object( - agent_exec, "_restore_worktree", return_value=False - ): - code = agent_exec.cmd_run( - self.repo, - role="implementation", - unit_path=unit, - receipt_path=receipt, - ) + 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") @@ -2251,6 +2306,218 @@ class AgentExecControllerTests(unittest.TestCase): 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```json\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, + 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() + "\nThanks.") + 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_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: @@ -2493,7 +2760,8 @@ class AgentExecHistoryTests(unittest.TestCase): self.assertEqual(identity["status"], "recorded") self.assertEqual(identity["run_id"], receipt["run_id"]) self.assertEqual(identity["record_id"], record["record_id"]) - self.assertEqual(identity["attestation"], record["attestation"]) + 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: @@ -2517,7 +2785,7 @@ class AgentExecHistoryTests(unittest.TestCase): def load() -> None: try: barrier.wait(timeout=5) - found.append(agent_exec._ensure_attestation_key(self.repo)) + found.append(agent_exec._ensure_local_integrity_key(self.repo)) except BaseException as exc: # noqa: BLE001 - race failures are the assertion errors.append(exc) @@ -2563,8 +2831,9 @@ _PROBE_NORMAL_HOLDER = "piholder" 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)\\?"' ) @@ -2671,18 +2940,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: diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py index c97bb854..1d581aa6 100644 --- a/tests/test_agent_exec_admission.py +++ b/tests/test_agent_exec_admission.py @@ -409,7 +409,7 @@ class AgentExecAdmissionTests(unittest.TestCase): ) combined = proc.stdout + proc.stderr self.assertEqual(proc.returncode, agent_exec.EXIT_BLOCKED, combined) - self.assertIn("apply: no signed history", proc.stderr) + self.assertIn("apply: no local integrity record", proc.stderr) self.assertNotIn("apply: fast-forwarded", proc.stdout) self.assertEqual(self._state(), before, label) @@ -471,7 +471,7 @@ class AgentExecAdmissionTests(unittest.TestCase): 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 provenance", proc.stderr) + self.assertIn("admit: no local integrity record", proc.stderr) self.assertEqual(self._state(), before) def test_admit_accepts_exact_transport_paths(self) -> None: @@ -496,7 +496,7 @@ class AgentExecAdmissionTests(unittest.TestCase): 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 provenance", 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"}) @@ -515,7 +515,7 @@ class AgentExecAdmissionTests(unittest.TestCase): 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 provenance", proc.stderr) + self.assertIn("admit: no local integrity record", proc.stderr) self.assertNotIn("admit: ok", proc.stdout) self.assertEqual(self._state(), before) @@ -556,7 +556,7 @@ class AgentExecAdmissionTests(unittest.TestCase): 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 provenance", moved_proc.stderr) + 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) @@ -568,7 +568,7 @@ class AgentExecAdmissionTests(unittest.TestCase): 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 provenance", proc.stderr) + 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: @@ -610,7 +610,7 @@ class AgentExecAdmissionTests(unittest.TestCase): 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 provenance", 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() @@ -618,7 +618,7 @@ class AgentExecAdmissionTests(unittest.TestCase): proc = self._hook() combined = proc.stdout + proc.stderr self.assertNotEqual(proc.returncode, 0, combined) - self.assertIn("admit: no provenance", combined) + self.assertIn("admit: no local integrity record", combined) _git(self.repo, "checkout", "-B", "main", self.head) self._stage("src/extra.txt", "unproven\n") -- 2.54.0 From 253bce37aad02930d1857a36f3f5343ecf2f2156 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Thu, 15 Oct 2026 20:18:51 +0000 Subject: [PATCH 18/23] agent-exec transport 35d469bccb3546fa95ccf1db49e5a7c2/79ece004c6be --- scripts/agent_exec_adapters.py | 37 +++++++++++++++++++++++++--------- tests/test_agent_exec.py | 27 +++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/scripts/agent_exec_adapters.py b/scripts/agent_exec_adapters.py index dee9f352..26aac68f 100644 --- a/scripts/agent_exec_adapters.py +++ b/scripts/agent_exec_adapters.py @@ -429,22 +429,39 @@ def _build_bounded_unit_prompt(packet_text: str) -> str: def extract_terminal_record(text: str | None) -> dict[str, Any] | None: - """Return the last valid terminal record embedded in one final message.""" + """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:] + 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: - try: - candidate, _ = decoder.raw_decode(text, pos) - except json.JSONDecodeError: - candidate = None - record = _valid_terminal_record(candidate) - if record is not None: - return record + candidate, end = decode_at(pos) + if end == len(text) and isinstance(candidate, dict): + break pos = text.rfind("{", 0, pos) - return None + 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: diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index 029375d9..0981c541 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -2325,7 +2325,7 @@ class AgentExecControllerTests(unittest.TestCase): "pi", _pi_help(), _pi_json_events( - "ok\n```json\n" + _terminal_json() + "\n```", + "ok\n" + _terminal_json() + "\n", agent_end_padding=2 * agent_exec.TERMINAL_LINE_MAX_BYTES, ), "success", @@ -2411,12 +2411,35 @@ class TerminalRecordDecodingTests(unittest.TestCase): ) def test_cursor_stream_json_result_is_decoded(self) -> None: - lines = _cursor_stream_events("Done.\n" + _terminal_json() + "\nThanks.") + 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]) -- 2.54.0 From d3c28d5a53d9e3205798b71c89ef1110e5996050 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Thu, 15 Oct 2026 20:48:14 +0000 Subject: [PATCH 19/23] agent-exec transport 5d95baf6a3f7402494208d156f6e6c51/ca161c464f9d --- scripts/agent_exec.py | 7 +- scripts/agent_exec_config.py | 31 +-- tests/test_agent_exec.py | 322 ++++++++++++++++++++--------- tests/test_agent_exec_admission.py | 66 ++++++ tests/test_agent_exec_config.py | 200 ++++++++++++++---- 5 files changed, 465 insertions(+), 161 deletions(-) diff --git a/scripts/agent_exec.py b/scripts/agent_exec.py index 80919a38..d2aca91e 100644 --- a/scripts/agent_exec.py +++ b/scripts/agent_exec.py @@ -2182,8 +2182,11 @@ def _secret_set(*texts: str) -> set[str]: def _normalize_repo_path(path: str) -> str: - """Normalize a repo-relative path without eating dotted names such as ``.task``.""" - norm = path.replace("\\", "/").strip() + """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 diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py index 45d3e7f2..405e8d84 100644 --- a/scripts/agent_exec_config.py +++ b/scripts/agent_exec_config.py @@ -24,11 +24,10 @@ PLACEHOLDER_RE = re.compile(r"\$\{[A-Za-z0-9_]+\}") PERMISSION_VALUES = frozenset({"run-everything"}) FALLBACK_VALUES = frozenset({"all-unavailable", "never"}) -# Exact implementation/repair/test adapters when emergency_profile is set -# (the active project mode). Legacy configs without an emergency profile may -# also name adapter custom for controlled fixtures; they never receive host -# fallback. +# 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"}) @@ -349,8 +348,8 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig: raise ConfigError(f"unknown adapter: {profile.adapter!r}") _validate_emergency_profile(execution, profiles, roles) - _validate_normal_role_adapters(execution, profiles, roles) - _validate_normal_role_exactness(execution, profiles, roles) + _validate_normal_role_adapters(profiles, roles) + _validate_normal_role_exactness(profiles, roles) return ExecutionConfig( schema=schema, @@ -411,7 +410,7 @@ def _build_execution(raw: Any) -> ExecutionPolicy: raw["max_attempts_per_profile"], "max_attempts_per_profile", 1, - 10, + MAX_ATTEMPTS_PER_PROFILE, ) else: max_attempts_per_profile = 1 @@ -464,24 +463,17 @@ def _validate_emergency_profile( def _validate_normal_role_adapters( - execution: ExecutionPolicy, profiles: Mapping[str, Profile], roles: Mapping[str, tuple[str, ...]], ) -> None: - """Reject non-project adapters on implementation, repair, and test. + """Reject adapters outside ``NORMAL_ROLE_ADAPTERS`` on implementation, repair, and test. - Active project mode (``emergency_profile`` configured) allows only - ``NORMAL_ROLE_ADAPTERS``. Legacy configs without an emergency profile may - still use ``custom`` so controller fixtures keep working; that allowance - does not grant host fallback. + Applies to every profile in those roles, enabled or not, in every mode. """ - allowed = NORMAL_ROLE_ADAPTERS - if execution.emergency_profile is None: - allowed = allowed | {"custom"} for role in ("implementation", "repair", "test"): for name in roles[role]: adapter = profiles[name].adapter - if adapter not in allowed: + if adapter not in NORMAL_ROLE_ADAPTERS: raise ConfigError( f"profile {name!r} in role {role!r} must use adapter " "cursor, antigravity, or pi" @@ -489,17 +481,14 @@ def _validate_normal_role_adapters( def _validate_normal_role_exactness( - execution: ExecutionPolicy, profiles: Mapping[str, Profile], roles: Mapping[str, tuple[str, ...]], ) -> None: - """In active project mode each normal role needs one enabled profile per adapter. + """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. """ - if execution.emergency_profile is None: - return for role in ("implementation", "repair", "test"): enabled: dict[str, list[str]] = {} for name in roles[role]: diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index 0981c541..e0e5f282 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -63,6 +63,55 @@ 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, *, @@ -77,6 +126,11 @@ def _write_config( 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(): @@ -93,12 +147,10 @@ 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] - review = ", ".join(review_order) if review_order is not None else first - repair_test = ( - ", ".join(repair_test_order) if repair_test_order is not None else first - ) + 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 @@ -128,10 +180,10 @@ def _write_config( raw_output_bytes: {raw_output_bytes} roles: - implementation: [{order}] - repair: [{repair_test}] - test: [{repair_test}] - review: [{review}] + implementation: [{implementation_line}] + repair: [{repair_line}] + test: [{test_line}] + review: [{review_line}] profiles: """ @@ -169,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" @@ -187,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", ) @@ -194,6 +313,17 @@ 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 @@ -403,9 +533,15 @@ class AgentExecControllerTests(unittest.TestCase): ), ) self._configure( - profiles={"b": {"adapter": "custom", "executable": "worker-b"}}, + profiles={"b": {"adapter": "fake", "executable": "worker-b"}}, role_order=["b"], ) + 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") for label in ("first", "second"): receipt = self.receipts / f"fresh-{label}.json" @@ -455,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"], ) @@ -484,8 +620,8 @@ class AgentExecControllerTests(unittest.TestCase): 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"], ) @@ -511,7 +647,9 @@ class AgentExecControllerTests(unittest.TestCase): 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(len(data["attempts"]), 2) + 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"), 0) # Controller must not implement the unit itself. @@ -531,8 +669,8 @@ class AgentExecControllerTests(unittest.TestCase): def test_legacy_exhaustion_without_emergency_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"], ) @@ -590,8 +728,8 @@ class AgentExecControllerTests(unittest.TestCase): ) 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"], ) @@ -648,8 +786,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "retryme": {"adapter": "custom", "executable": "retryme-missing"}, - "thenok": {"adapter": "custom", "executable": "thenok"}, + "retryme": {"adapter": "fake", "executable": "retryme-missing"}, + "thenok": {"adapter": "fake", "executable": "thenok"}, }, role_order=["retryme", "thenok"], max_attempts_per_profile=3, @@ -711,6 +849,7 @@ class AgentExecControllerTests(unittest.TestCase): self.bindir, executable, help_text=help_text, + native=True, body=textwrap.dedent( f"""\ printf '{adapter}\\n' >> "{launches}" @@ -722,6 +861,7 @@ class AgentExecControllerTests(unittest.TestCase): self.bindir, "codex", help_text=_codex_help(), + native=True, body=textwrap.dedent( f"""\ if [ "$1" = "exec" ] && [ "$2" = "--help" ]; then @@ -842,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"], ) @@ -896,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"], ) @@ -975,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}", }, }, @@ -1026,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( @@ -1067,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, ) @@ -1127,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, @@ -1200,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, @@ -1290,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, @@ -1345,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") @@ -1387,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") @@ -1458,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"], ) @@ -1505,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") @@ -1564,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"], ) @@ -1627,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"], ) @@ -1693,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") @@ -1818,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"], ) @@ -1872,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, ) @@ -1903,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" @@ -1922,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, @@ -1943,7 +2089,7 @@ class AgentExecControllerTests(unittest.TestCase): "--repo", str(self.repo), "--role", - "implementation", + "review", "--unit", str(unit), "--receipt", @@ -1998,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"], ) @@ -2093,8 +2239,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "renamer": {"adapter": "custom", "executable": "renamer"}, - "renamenext": {"adapter": "custom", "executable": "renamenext"}, + "renamer": {"adapter": "fake", "executable": "renamer"}, + "renamenext": {"adapter": "fake", "executable": "renamenext"}, }, role_order=["renamer", "renamenext"], ) @@ -2150,11 +2296,11 @@ class AgentExecControllerTests(unittest.TestCase): self._configure( profiles={ "restorer": { - "adapter": "custom", + "adapter": "fake", "executable": f"restorer-{label}", }, "restorenext": { - "adapter": "custom", + "adapter": "fake", "executable": f"restorenext-{label}", }, }, @@ -2196,8 +2342,8 @@ class AgentExecControllerTests(unittest.TestCase): ) self._configure( profiles={ - "netclean": {"adapter": "custom", "executable": "netclean"}, - "netcleannext": {"adapter": "custom", "executable": "netcleannext"}, + "netclean": {"adapter": "fake", "executable": "netclean"}, + "netcleannext": {"adapter": "fake", "executable": "netcleannext"}, }, role_order=["netclean", "netcleannext"], ) @@ -2271,11 +2417,11 @@ class AgentExecControllerTests(unittest.TestCase): self._configure( profiles={ "failstop": { - "adapter": "custom", + "adapter": "fake", "executable": f"failstop-{label}", }, "failnext": { - "adapter": "custom", + "adapter": "fake", "executable": f"failstop-next-{label}", }, }, @@ -2350,6 +2496,7 @@ class AgentExecControllerTests(unittest.TestCase): self.bindir, executable, help_text=help_text, + native=True, body=textwrap.dedent( f"""\ mkdir -p src @@ -2593,7 +2740,7 @@ class AgentExecHistoryTests(unittest.TestCase): self.repo, profiles={ "writer": { - "adapter": "custom", + "adapter": "fake", "executable": "history-writer", } }, @@ -2619,7 +2766,7 @@ class AgentExecHistoryTests(unittest.TestCase): self.repo, profiles={ "missing": { - "adapter": "custom", + "adapter": "fake", "executable": "missing-history-cli", } }, @@ -2658,7 +2805,7 @@ class AgentExecHistoryTests(unittest.TestCase): self.repo, profiles={ "missing": { - "adapter": "custom", + "adapter": "fake", "executable": "missing-history-cli", } }, @@ -2726,7 +2873,7 @@ class AgentExecHistoryTests(unittest.TestCase): ) _write_config( self.repo, - profiles={name: {"adapter": "custom", "executable": name}}, + profiles={name: {"adapter": "fake", "executable": name}}, role_order=[name], ) unit = _write_unit(self.root / f"{name}.json", unit_id=f"unit-{name}") @@ -2848,9 +2995,10 @@ U5_EXECUTABLE = { "kimi": "kimi", "opencode": "opencode", } -# Normal-role validation rejects every other adapter on implementation/repair/test. -# The probe still has to launch those adapters, so they sit only on review. -_PROBE_NORMAL_HOLDER = "piholder" +# 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" @@ -3048,27 +3196,13 @@ def _cleanup_probe_runtime(repo: Path, receipt: dict[str, object] | None) -> Non def _marker_probe_config( profile: str, ) -> tuple[dict[str, dict[str, object]], list[str], list[str] | None, str]: - """Place one profile where strict normal-role validation allows it to run.""" + """Run exactly the selected profile from review; normal roles get holders.""" selected = { "adapter": profile, "executable": U5_EXECUTABLE[profile], "model": "auto", } - if profile in NORMAL_ROLE_ADAPTERS: - return {profile: selected}, [profile], None, "implementation" - return ( - { - _PROBE_NORMAL_HOLDER: { - "adapter": "pi", - "executable": "pi-role-holder", - "model": "auto", - }, - profile: selected, - }, - [_PROBE_NORMAL_HOLDER], - [profile], - "review", - ) + return {profile: selected}, [], [profile], "review" def execute_marker_probe( @@ -3205,23 +3339,19 @@ def _probe_config_mismatch( ) -> str | None: """Reject a fixture that does not isolate the selected adapter. - Normal adapters stay on implementation. Codex and other non-normal adapters - stay on review, beside an uninvoked pi holder, so strict role validation - still passes and the selected CLI can reach PASS. + 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" - implementation = roles.get("implementation") - if profile in NORMAL_ROLE_ADAPTERS: - if set(profiles) != {profile}: - return "probe configured a profile other than the selection" - if implementation != [profile]: - return "implementation role is not exactly the selected profile" - return None - if set(profiles) != {profile, _PROBE_NORMAL_HOLDER}: + if set(profiles) != {profile, *_PROBE_NORMAL_HOLDERS}: return "probe configured a profile other than the selection" - if implementation != [_PROBE_NORMAL_HOLDER] or roles.get("review") != [profile]: - return "non-normal adapter must run only from the review role" + 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" diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py index 1d581aa6..62505baa 100644 --- a/tests/test_agent_exec_admission.py +++ b/tests/test_agent_exec_admission.py @@ -612,6 +612,72 @@ class AgentExecAdmissionTests(unittest.TestCase): 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") diff --git a/tests/test_agent_exec_config.py b/tests/test_agent_exec_config.py index 572ec9e7..e6f2c88a 100644 --- a/tests/test_agent_exec_config.py +++ b/tests/test_agent_exec_config.py @@ -31,9 +31,9 @@ MINIMAL_BLOCK = textwrap.dedent( raw_output_bytes: 10485760 roles: - implementation: [cursor, pi] - repair: [cursor] - test: [pi] + implementation: [cursor, antigravity, pi] + repair: [cursor, antigravity, pi] + test: [pi, antigravity, cursor] review: [pi] profiles: @@ -43,6 +43,12 @@ MINIMAL_BLOCK = textwrap.dedent( model: auto enabled: true + antigravity: + adapter: antigravity + executable: agy + model: auto + enabled: true + pi: adapter: pi executable: pi @@ -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", "pi")) + 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), []) @@ -127,9 +133,9 @@ class AgentExecConfigTests(unittest.TestCase): raw_output_bytes: 4096 roles: - implementation: [cursor, pi] - repair: [cursor] - test: [pi] + implementation: [cursor, antigravity, pi] + repair: [cursor, antigravity, pi] + test: [cursor, antigravity, pi] review: [pi] profiles: @@ -138,6 +144,11 @@ class AgentExecConfigTests(unittest.TestCase): executable: pi model: auto enabled: true + antigravity: + adapter: antigravity + executable: agy + model: auto + enabled: true cursor: adapter: cursor executable: cursor-agent @@ -147,7 +158,7 @@ class AgentExecConfigTests(unittest.TestCase): ) write_repo(self.repo, block) cfg = load_config(self.repo) - self.assertEqual(cfg.roles["implementation"], ("cursor", "pi")) + 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: @@ -158,8 +169,8 @@ class AgentExecConfigTests(unittest.TestCase): def test_rejects_unknown_profile_reference(self) -> None: block = MINIMAL_BLOCK.replace( - "implementation: [cursor, pi]", - "implementation: [cursor, pi, missing]", + "implementation: [cursor, antigravity, pi]", + "implementation: [cursor, antigravity, pi, missing]", ) write_repo(self.repo, block) with self.assertRaises(ConfigError): @@ -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 @@ -647,16 +641,141 @@ class AgentExecConfigTests(unittest.TestCase): cfg = load_config(self.repo) self.assertEqual(cfg.roles["review"], ("codex",)) - def test_legacy_without_emergency_custom_remains_parseable(self) -> None: + 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.execution.fallback_to_host, "all-unavailable") + self.assertEqual(cfg.roles["review"], ("mycustom",)) self.assertEqual(cfg.profiles["mycustom"].adapter, "custom") - self.assertEqual(cfg.roles["implementation"], ("mycustom",)) - self.assertEqual(cfg.roles["repair"], ("mycustom",)) - self.assertEqual(cfg.roles["test"], ("mycustom",)) - self.assertIn("mycustom", cfg.custom_adapters) + + 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] @@ -675,7 +794,7 @@ class AgentExecConfigTests(unittest.TestCase): def _legacy_custom_block() -> str: - """No emergency_profile: custom stays a parseable fixture adapter.""" + """No emergency_profile and adapter custom on every role.""" return textwrap.dedent( """\ schema: agent-execution/v1 @@ -807,15 +926,12 @@ def _emergency_block( " max_attempts_per_profile: 3\n" " emergency_profile: codex\n", ) - block = block.replace("implementation: [cursor, pi]", f"implementation: {implementation}") - block = block.replace("repair: [cursor]", f"repair: {repair}") - block = block.replace("test: [pi]", f"test: {test}") - block = block.replace("review: [pi]", f"review: {review}") block = block.replace( - " pi:\n", - _profile_lines("antigravity", "antigravity", "true") + "\n\n pi:\n", - 1, + "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( -- 2.54.0 From e288f00116fc2e986dfa411bfeea52c5fe8c594e Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 20:58:42 +0000 Subject: [PATCH 20/23] Record final agent CLI verification Why: The task card must identify the exact candidate, implementation agents, independent tests, and review closure before PR delivery. Evidence: PI reported 110 focused tests and 472 governance tests passing at d3c28d5; independent Codex delta review found no actionable regression. Impact: Task metadata only; product and governance behavior are unchanged. Refs: .task/feature-agent-cli-enforcement.md --- .task/feature-agent-cli-enforcement.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md index d9b72025..c3454d08 100644 --- a/.task/feature-agent-cli-enforcement.md +++ b/.task/feature-agent-cli-enforcement.md @@ -12,7 +12,7 @@ - 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md) - 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入) - 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收 -- 当前阶段:首轮独立 Review 的 6 项阻塞已修复,等待精确 HEAD 重测与复审;当前增量:U1 路由状态机、U2 provenance admission、U3 规范与 Cursor 稳定通道 +- 当前阶段:最终候选 `d3c28d5a53d9e3205798b71c89ef1110e5996050` 已完成独立 PI 回归与独立 Codex Review;等待最终同步、PR HEAD CI、合入与收口 - 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) ## 目标与非目标 @@ -28,6 +28,9 @@ | 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;终态见阶段记录 | ## 关联方向与规划 @@ -60,8 +63,8 @@ | 方案设计 | 待定 | 待执行 | — | — | — | | 实施计划 | 待定 | 待执行 | — | — | — | | 实施与自检 | `818e17f` 及后续 Review 修复 | 通过(SELF) | Cursor/agy/PI sessions + host 集成 | 2026-10-15 | 路由、来源、规范均已实现;实际失败会话如实保留 | -| 正式测试与验收 | `11973f5`(同步前同代码树) | 通过(REPLAY/SELF) | PI MiniMax 独立测试 session | 2026-10-15 | 130 项直接测试通过(1 skip);430 项治理测试通过(19 skips) | -| 代码 Review | `bc5a119...529d8aa` | 首轮 6 项阻塞均已整改,待复审 | 独立 Codex CLI session `01a1409f-4b2d-7e80-b8bd-b55b93fed9d5` | 2026-10-15 | provenance、rename、failed-clean、worker fail-stop、严格配置、host fallback | +| 正式测试与验收 | `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 无可执行回归 | | 合入 | 待定 | 待执行 | — | — | — | | 合入核验与收口 | 待定 | 待执行 | — | — | — | @@ -94,9 +97,9 @@ ## 结果与验证(完成后填写) -- 结果:配置路由、三次安全重试、一次 Codex 紧急档、稳定 Cursor 通道、HMAC transport 来源、apply/admit 与 pre-commit 门禁均已实现。 -- 验证:同步前同代码树的 130 项直接测试与 430 项治理测试通过;首轮独立 Review 的 6 项阻塞已修复;最终同步后复测、复审和 CI 待执行。 -- 影响与剩余项:只剩固定最终候选、独立复审、PR 精确 HEAD CI、合入及任务卡闭合。 +- 结果:配置路由、每候选最多三次安全重试、一次 Codex 紧急档、稳定 Cursor 通道、终态事件解码、local-integrity apply/admit 与 pre-commit 门禁均已实现;本地 HMAC 明确仅为 SELF 完整性证据。 +- 验证:精确 HEAD `d3c28d5` 的 110 项直接测试与 472 项治理测试通过;最终 PI 回归无失败,独立 Codex delta Review 无可执行回归。 +- 影响与剩余项:只剩最终 task-sync、PR 精确 HEAD CI、合入及任务卡闭合。 ## 规划进展与下一步 @@ -110,7 +113,7 @@ ## 合入与清理 -- Review 记录:首轮独立 Codex CLI Review 完成;base `bc5a1194984433c4098f1961f5280b3d9ee20995`,head `529d8aa5c7fc5e19d6dab882ddd1903ec184afda`;6 项阻塞均已通过独立 CLI 单元整改,待固定新 HEAD 复审。 +- Review 记录:首轮及后续复审均使用全新 Codex CLI session;最终候选 `d3c28d5a53d9e3205798b71c89ef1110e5996050` 相对 base `bc5a1194984433c4098f1961f5280b3d9ee20995` 的已知阻塞全部关闭;最终 commit delta Review 无可执行回归。 - 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测) - CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果) - 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验) -- 2.54.0 From 2fcb4c72e8cabb6e6057abb031e4d74060f8650c Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 21:04:40 +0000 Subject: [PATCH 21/23] Record final governance test evidence Why: Delivery policy requires the exact command output and tested object to be written by the evidence tool. Evidence: make evidence recorded make test-governance at e288f001 with exit 0; 472 tests passed and 19 were skipped. Impact: Task evidence only. Refs: .task/feature-agent-cli-enforcement.md --- .task/feature-agent-cli-enforcement.md | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md index c3454d08..bba70e53 100644 --- a/.task/feature-agent-cli-enforcement.md +++ b/.task/feature-agent-cli-enforcement.md @@ -157,3 +157,41 @@ } ``` | 同步(记录前对象) | 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" +} +``` -- 2.54.0 From cae058549ed7dbfcf4c965259b1fa469a0959b7c Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 21:05:31 +0000 Subject: [PATCH 22/23] Record final synchronization evidence Why: The PR candidate must carry a tool-written final task-sync receipt against the authoritative target. Evidence: make evidence recorded make task-sync at 2fcb4c72 with sync base bc5a1194 and changed=0. Impact: Task evidence only. Refs: .task/feature-agent-cli-enforcement.md --- .task/feature-agent-cli-enforcement.md | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md index bba70e53..be32282f 100644 --- a/.task/feature-agent-cli-enforcement.md +++ b/.task/feature-agent-cli-enforcement.md @@ -195,3 +195,41 @@ "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" +} +``` -- 2.54.0 From 822804399820c3281e80e2394926c5bf75d44171 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 21:06:31 +0000 Subject: [PATCH 23/23] Link the agent CLI delivery PR Why: The task card and PR must reference the same primary delivery record. Evidence: Gitea created PR #572 for feature/agent-cli-enforcement. Impact: Task metadata only. Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/572 --- .task/feature-agent-cli-enforcement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.task/feature-agent-cli-enforcement.md b/.task/feature-agent-cli-enforcement.md index be32282f..50d5e150 100644 --- a/.task/feature-agent-cli-enforcement.md +++ b/.task/feature-agent-cli-enforcement.md @@ -1,6 +1,6 @@ # feature/agent-cli-enforcement 任务卡 -任务主记录:;状态:交付中;owner:weichen +任务主记录:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/572;状态:交付中;owner:weichen ## 基线与授权 -- 2.54.0