From 818e17f4216fa3253d16c886e37c10a04408334c Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 18:44:55 +0000 Subject: [PATCH] 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)