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
This commit is contained in:
2026-10-15 18:45:08 +00:00
parent 28f000af8b
commit 818e17f421
12 changed files with 494 additions and 112 deletions
+11 -6
View File
@@ -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 U3's execution controller and U4's project wiring are integrated. This file is
the execution-routing authority for implementation, repair, test, and review. the execution-routing authority for implementation, repair, test, and review.
Normal implementation, repair, and test routes stay Cursor, Antigravity Normal implementation, repair, and test routes stay Cursor, Antigravity
(`agy`), and PI. Each profile may be attempted up to (`agy`), and PI. Each of those profiles may be attempted up to
`max_attempts_per_profile`. Only after those candidates safely exhaust their `max_attempts_per_profile`, which is exactly 3 whenever `emergency_profile`
attempts does the enabled emergency `codex` profile run once. Codex remains out is configured. Only after those candidates safely exhaust their attempts does
of the three normal role lists and may also appear in review. A configured the enabled emergency `codex` profile run once. Codex remains out of the three
emergency profile replaces host write fallback. Cursor uses 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 `cursor-agent-stable`. `model: auto` lets each CLI use its configured default
model; PI therefore uses the operator-selected MiniMax default. model; PI therefore uses the operator-selected MiniMax default.
@@ -22,7 +27,7 @@ schema: agent-execution/v1
execution: execution:
fresh_process_per_unit: true fresh_process_per_unit: true
permission: run-everything permission: run-everything
fallback_to_host: all-unavailable fallback_to_host: never
max_parallel: 3 max_parallel: 3
timeout_seconds: 7200 timeout_seconds: 7200
idle_timeout_seconds: 600 idle_timeout_seconds: 600
+3 -1
View File
@@ -45,7 +45,9 @@ policy/delivery.md。
- 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准。每个候选对 - 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准。每个候选对
安全可恢复失败(`unavailable` 或已精确恢复的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。 安全可恢复失败(`unavailable` 或已精确恢复的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。
三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划 三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划
或独立 Review,不进入这三个正常角色。 或独立 Review,不进入这三个正常角色。`fallback_to_host` 为 `never`。安全耗尽不发出
host-fallback-authorized,终态为 unavailable 或 blocked,不授权 host 撰写该单元。旧值
`all-unavailable` 仍可解析,同样不产生 host 撰写授权。
- 显式 fail-stop 立即停止,不重试、不换候选、不启动紧急尝试:`failed-unknown`、`scope-expansion`、 - 显式 fail-stop 立即停止,不重试、不换候选、不启动紧急尝试:`failed-unknown`、`scope-expansion`、
`blocked`、`cancelled`、无效配置、未对账 Git 状态与安全失败。总超时,以及 stdin/stdout/stderr `blocked`、`cancelled`、无效配置、未对账 Git 状态与安全失败。总超时,以及 stdin/stdout/stderr
都没有活动的空闲超时,归入 `failed-unknown`。 都没有活动的空闲超时,归入 `failed-unknown`。
+1 -1
View File
@@ -18,7 +18,7 @@
| standards/verification.md | 验收 | 本地 make test-governance + 组合测试;当前证据与历史 scratch 记录分开 | | standards/verification.md | 验收 | 本地 make test-governance + 组合测试;当前证据与历史 scratch 记录分开 |
| standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) | | standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
| 工作区回收 | 交付终点 | advisory(task-finish);负例:合入前 finish 拒绝输出 | | 工作区回收 | 交付终点 | 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 | | 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 逐行定级+正负例证据) | | 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 | | 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
+5 -3
View File
@@ -106,9 +106,11 @@ Codex 平时只用于规划、方案设计、实施计划,或独立 Review。
host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/specs/**` 和 host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/specs/**` 和
`docs/superpowers/plans/**`。除此之外,host 不直接撰写实施单元。 `docs/superpowers/plans/**`。除此之外,host 不直接撰写实施单元。
未配置紧急候选时,控制器仍可能在安全耗尽后写出 `host-fallback-authorized`。现行 安全耗尽不发出 `host-fallback-authorized`,`host_fallback_authorizations` 保持为 0。
[AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 已配置紧急候选,这个状态不再授权 host 撰写实施单元。 未配置紧急候选时终态为 unavailable 或 blocked,不授权 host 撰写实施单元。现行
已归档任务卡里的旧表述保持原样,不改写成现行规则。 [AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 的 `fallback_to_host` 是 `never`。旧值
`all-unavailable` 仍可解析,但同样不产生 host 撰写授权。已归档任务卡里的旧表述保持原样,
不改写成现行规则。
规划、只读调查、证据捕获、任务卡与 superpowers 规格/计划,以及 apply 或 admit 通过之后的集成、 规划、只读调查、证据捕获、任务卡与 superpowers 规格/计划,以及 apply 或 admit 通过之后的集成、
验证、提交和 PR/CI/合入,属于 host 管理工作,不递归创建另一实施单元。 验证、提交和 PR/CI/合入,属于 host 管理工作,不递归创建另一实施单元。
+2 -1
View File
@@ -18,7 +18,8 @@
实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败
最多 3 次全新尝试,全部安全耗尽后才允许一次 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、 最多 3 次全新尝试,全部安全耗尽后才允许一次 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、
实施计划或独立 Review。显式 fail-stop 立即停止。一次尝试只在显式终态失败、进程退出、总超时, 实施计划或独立 Review。`fallback_to_host` 为 `never`。安全耗尽不发出 host-fallback-authorized,
终态为 unavailable 或 blocked,不授权 host 撰写实施单元。显式 fail-stop 立即停止。一次尝试只在显式终态失败、进程退出、总超时,
或 stdin/stdout/stderr 都没有活动的空闲超时时结束;推理时长、工具次数、规划方式和首次写文件时间 或 stdin/stdout/stderr 都没有活动的空闲超时时结束;推理时长、工具次数、规划方式和首次写文件时间
都不是结束条件。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。路由细则见 都不是结束条件。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。路由细则见
[迭代交付](delivery.md)。 [迭代交付](delivery.md)。
+1 -1
View File
@@ -14,7 +14,7 @@
| A5 回收 | 本地命令检查;写者退出须可证明,无法证明时才请求 owner 决策 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` | | A5 回收 | 本地命令检查;写者退出须可证明,无法证明时才请求 owner 决策 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` |
| A6 秘密 | 本地脱敏及 CI 获取边界;权限与宿主隔离另验 | 命令/输出/摘要脱敏,凭据不持久写 Git 配置 | `tests/test_evidence.py`、`tests/test_ci_credentials.py` | | A6 秘密 | 本地脱敏及 CI 获取边界;权限与宿主隔离另验 | 命令/输出/摘要脱敏,凭据不持久写 Git 配置 | `tests/test_evidence.py`、`tests/test_ci_credentials.py` |
| A7 证据 | 本地 SELF 收据;外部来源独立核验 | 首次捕获、完整脱敏输出/摘要、对象记录、原样导入 | `tests/test_evidence.py`;CI/审阅链接另核 | | 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` 核对生成字段与未授权默认值;智能体核对当前规划、进展、重复任务和建议依据,新任务仍不自动获得授权 | | A9 长期方向与后续任务 | manual;模板不校验规划有效性或自动调度任务 | policy/delivery.md;PROJECT.md 提供规划入口,任务卡关联规划/里程碑并记录下一步建议 | `tests/test_task.py` 核对生成字段与未授权默认值;智能体核对当前规划、进展、重复任务和建议依据,新任务仍不自动获得授权 |
| A10 交付窗口 | manual;无自动互斥或接管 | [合入调度](merge-queue.md)与 #518:同一目标一个最终窗口,等待分支不追 main;轮到后同步、固定、验证并由智能体合入 | 每个实际 PR 核对窗口记录、精确 HEAD 三项 GATE、Review 和合入后 main;不要求额外试行或全写者审计 | | A10 交付窗口 | manual;无自动互斥或接管 | [合入调度](merge-queue.md)与 #518:同一目标一个最终窗口,等待分支不追 main;轮到后同步、固定、验证并由智能体合入 | 每个实际 PR 核对窗口记录、精确 HEAD 三项 GATE、Review 和合入后 main;不要求额外试行或全写者审计 |
| A11 固定后证据 | manual 选择源码外位置;工具捕获绝对未用路径/原始输出;无 OS 冻结 | policy/governance.md;RECEIPT 捕获,后续授权闭合再原样导入 | `tests/test_evidence.py` 固定对象、权限、覆盖拒绝、原样导入与幂等 | | A11 固定后证据 | manual 选择源码外位置;工具捕获绝对未用路径/原始输出;无 OS 冻结 | policy/governance.md;RECEIPT 捕获,后续授权闭合再原样导入 | `tests/test_evidence.py` 固定对象、权限、覆盖拒绝、原样导入与幂等 |
+2 -20
View File
@@ -436,27 +436,9 @@ def cmd_run(
if action == "continue": if action == "continue":
terminal = "blocked" terminal = "blocked"
reason = attempt.reason 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( summary = _bounded_summary(
_build_run_summary(terminal, attempts, reason), _build_run_summary(terminal, attempts, reason),
cfg.execution.summary_bytes, cfg.execution.summary_bytes,
+36 -1
View File
@@ -23,7 +23,12 @@ ALLOWED_PLACEHOLDERS = frozenset(
PLACEHOLDER_RE = re.compile(r"\$\{[A-Za-z0-9_]+\}") PLACEHOLDER_RE = re.compile(r"\$\{[A-Za-z0-9_]+\}")
PERMISSION_VALUES = frozenset({"run-everything"}) 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"}) PROMPT_TRANSPORTS = frozenset({"stdin", "positional", "flag"})
OUTPUT_FORMATS = frozenset({"json", "stream-json", "jsonl", "text"}) 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}") raise ConfigError(f"unknown adapter: {profile.adapter!r}")
_validate_emergency_profile(execution, profiles, roles) _validate_emergency_profile(execution, profiles, roles)
_validate_normal_role_adapters(execution, profiles, roles)
return ExecutionConfig( return ExecutionConfig(
schema=schema, schema=schema,
@@ -438,6 +444,10 @@ def _validate_emergency_profile(
name = execution.emergency_profile name = execution.emergency_profile
if name is None: if name is None:
return 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) profile = profiles.get(name)
if profile is None: if profile is None:
raise ConfigError(f"emergency_profile {name!r} does not exist") 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: def _positive_bounded(value: Any, name: str, lo: int, hi: int) -> int:
if type(value) is not int or isinstance(value, bool): if type(value) is not int or isinstance(value, bool):
raise ConfigError(f"{name} must be an integer") raise ConfigError(f"{name} must be an integer")
+1 -1
View File
@@ -213,7 +213,7 @@ ${parent_note}
## Agent 实施收据 ## 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 | 源码外收据路径 | | 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|
+134 -33
View File
@@ -24,6 +24,7 @@ from pathlib import Path
from unittest import mock from unittest import mock
from scripts import agent_exec 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]: 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]], profiles: dict[str, dict[str, object]],
role_order: list[str], role_order: list[str],
review_order: list[str] | None = None,
timeout_seconds: int = 30, timeout_seconds: int = 30,
idle_timeout_seconds: int | None = None, idle_timeout_seconds: int | None = None,
max_attempts_per_profile: int | None = None, max_attempts_per_profile: int | None = None,
@@ -92,6 +94,7 @@ def _write_config(
custom_lines.append(" output_format: text") custom_lines.append(" output_format: text")
order = ", ".join(role_order) order = ", ".join(role_order)
first = role_order[0] first = role_order[0]
review = ", ".join(review_order) if review_order is not None else first
idle_line = ( idle_line = (
f" idle_timeout_seconds: {idle_timeout_seconds}\n" f" idle_timeout_seconds: {idle_timeout_seconds}\n"
if idle_timeout_seconds is not None if idle_timeout_seconds is not None
@@ -124,7 +127,7 @@ def _write_config(
implementation: [{order}] implementation: [{order}]
repair: [{first}] repair: [{first}]
test: [{first}] test: [{first}]
review: [{first}] review: [{review}]
profiles: profiles:
""" """
@@ -193,6 +196,12 @@ def _codex_help() -> str:
return "\n".join(_REQUIRED_HELP_MARKERS["codex"]) + "\n" 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): class AgentExecControllerTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-ctrl-") 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.assertEqual(log.read_text(encoding="utf-8").strip(), "second")
self._assert_canonical_untouched() 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( self._configure(
profiles={ profiles={
"m1": {"adapter": "custom", "executable": "gone-one"}, "m1": {"adapter": "custom", "executable": "gone-one"},
@@ -422,13 +431,16 @@ class AgentExecControllerTests(unittest.TestCase):
str(receipt), str(receipt),
) )
self.assertEqual( 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")) 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.assertEqual(len(data["attempts"]), 2)
self.assertTrue(all(a["terminal_status"] == "unavailable" for a in data["attempts"])) 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. # Controller must not implement the unit itself.
history_root = self.repo / ".git" / "agent-exec" history_root = self.repo / ".git" / "agent-exec"
after = [ after = [
@@ -440,7 +452,41 @@ class AgentExecControllerTests(unittest.TestCase):
self.assertEqual(data["history"]["status"], "recorded") self.assertEqual(data["history"]["status"], "recorded")
self.assertTrue((history_root / "execution-history.jsonl").is_file()) self.assertTrue((history_root / "execution-history.jsonl").is_file())
self.assertFalse((self.repo / "src" / "marker.txt").exists()) 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() self._assert_canonical_untouched()
def test_failed_clean_can_continue_after_exact_restore(self) -> None: def test_failed_clean_can_continue_after_exact_restore(self) -> None:
@@ -584,12 +630,13 @@ class AgentExecControllerTests(unittest.TestCase):
_write_fake_worker( _write_fake_worker(
self.bindir, self.bindir,
"regular", "regular",
help_text=_pi_help(),
body=textwrap.dedent( body=textwrap.dedent(
f"""\ f"""\
printf 'regular\\n' >> "{launches}" printf 'regular\\n' >> "{launches}"
workspace="$1" mkdir -p src
mkdir -p "$workspace/src" printf 'tmp\\n' > src/marker.txt
printf 'tmp\\n' > "$workspace/src/marker.txt" printf '%s\\n' '{{"terminal_status":"failed","summary":"regular failed clean","changed_files":["src/marker.txt"]}}'
exit 9 exit 9
""" """
), ),
@@ -625,7 +672,7 @@ class AgentExecControllerTests(unittest.TestCase):
) )
self._configure( self._configure(
profiles={ profiles={
"regular": {"adapter": "custom", "executable": "regular"}, "regular": {"adapter": "pi", "executable": "regular", "model": "auto"},
"codex": {"adapter": "codex", "executable": "codex", "model": "auto"}, "codex": {"adapter": "codex", "executable": "codex", "model": "auto"},
}, },
role_order=["regular"], role_order=["regular"],
@@ -636,6 +683,7 @@ class AgentExecControllerTests(unittest.TestCase):
self.assertEqual(loaded.execution.max_attempts_per_profile, 3) self.assertEqual(loaded.execution.max_attempts_per_profile, 3)
self.assertEqual(loaded.execution.emergency_profile, "codex") self.assertEqual(loaded.execution.emergency_profile, "codex")
self.assertIn("codex", loaded.profiles) self.assertIn("codex", loaded.profiles)
self.assertEqual(loaded.profiles["regular"].adapter, "pi")
self.assertEqual(loaded.profiles["codex"].adapter, "codex") self.assertEqual(loaded.profiles["codex"].adapter, "codex")
for role_names in loaded.roles.values(): for role_names in loaded.roles.values():
self.assertNotIn("codex", role_names) self.assertNotIn("codex", role_names)
@@ -2269,7 +2317,7 @@ class AgentExecHistoryTests(unittest.TestCase):
unit_path=fallback_unit, unit_path=fallback_unit,
receipt_path=fallback_receipt, 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") text = self.history_path.read_text(encoding="utf-8")
self.assertNotIn(secret, text) 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]["fresh_session"], True)
self.assertEqual(success["attempts"][0]["session_id"], "not-observable") self.assertEqual(success["attempts"][0]["session_id"], "not-observable")
self.assertEqual(success["attempts"][0]["model_requested"], "auto") self.assertEqual(success["attempts"][0]["model_requested"], "auto")
self.assertEqual(fallback["terminal_status"], "host-fallback-authorized") self.assertEqual(fallback["terminal_status"], "unavailable")
self.assertEqual(fallback["host_fallback_authorizations"], 1) self.assertEqual(fallback["host_fallback_authorizations"], 0)
def test_history_failure_does_not_change_run_outcome(self) -> None: def test_history_failure_does_not_change_run_outcome(self) -> None:
_write_config( _write_config(
@@ -2314,11 +2362,12 @@ class AgentExecHistoryTests(unittest.TestCase):
unit_path=unit, unit_path=unit,
receipt_path=receipt_path, 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") receipt_text = receipt_path.read_text(encoding="utf-8")
self.assertNotIn(secret, receipt_text) self.assertNotIn(secret, receipt_text)
receipt = json.loads(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") self.assertEqual(receipt["history"]["status"], "failed")
def test_invalid_config_also_appends_one_history_record(self) -> None: def test_invalid_config_also_appends_one_history_record(self) -> None:
@@ -2482,6 +2531,9 @@ U5_EXECUTABLE = {
"kimi": "kimi", "kimi": "kimi",
"opencode": "opencode", "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_BRANCH = "feature/u5-real-probe"
U5_MARKER_REL = "src/u5-marker.txt" U5_MARKER_REL = "src/u5-marker.txt"
U5_MARKER_BODY = "U5-REAL-PROBE-MARKER\n" 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) 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( def execute_marker_probe(
profile: str, profile: str,
*, *,
@@ -2699,16 +2777,12 @@ def execute_marker_probe(
raise RuntimeError( raise RuntimeError(
f"probe fixture must use non-default branch {U5_BRANCH}, got {branch}" 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( _write_config(
repo, repo,
profiles={ profiles=profiles,
profile: { role_order=role_order,
"adapter": profile, review_order=review_order,
"executable": U5_EXECUTABLE[profile],
"model": "auto",
}
},
role_order=[profile],
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
summary_bytes=4096, summary_bytes=4096,
raw_output_bytes=65536, raw_output_bytes=65536,
@@ -2734,7 +2808,7 @@ def execute_marker_probe(
"--repo", "--repo",
str(repo), str(repo),
"--role", "--role",
"implementation", probe_role,
"--unit", "--unit",
str(unit), str(unit),
"--receipt", "--receipt",
@@ -2778,6 +2852,36 @@ def execute_marker_probe(
repo_dir.cleanup() 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( def _classify_marker_probe(
*, *,
profile: str, profile: str,
@@ -2824,18 +2928,15 @@ def _classify_marker_probe(
summary = receipt.get("config_summary") summary = receipt.get("config_summary")
profiles = summary.get("profiles") if isinstance(summary, dict) else None profiles = summary.get("profiles") if isinstance(summary, dict) else None
roles = summary.get("roles") 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["capability"] = "failed"
base["reason"] = "probe configured a profile other than the selection" base["reason"] = mismatch
return base return base
assert isinstance(profiles, dict)
selected = profiles[profile] selected = profiles[profile]
if isinstance(selected, dict): if isinstance(selected, dict):
base["configured_model"] = selected.get("model") 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") recorded_base = receipt.get("base")
if isinstance(recorded_base, dict): if isinstance(recorded_base, dict):
base["branch"] = recorded_base.get("branch") base["branch"] = recorded_base.get("branch")
@@ -3055,8 +3156,8 @@ class RealAgentCliProbeTests(unittest.TestCase):
self.assertEqual(outcome["capability"], "unavailable", outcome) self.assertEqual(outcome["capability"], "unavailable", outcome)
self.assertNotEqual(outcome["capability"], "PASS") self.assertNotEqual(outcome["capability"], "PASS")
self.assertEqual(outcome["attempt_status"], "unavailable") self.assertEqual(outcome["attempt_status"], "unavailable")
self.assertEqual(outcome["run_terminal"], "host-fallback-authorized") self.assertEqual(outcome["run_terminal"], "unavailable")
self.assertEqual(outcome["host_fallback_authorizations"], 1) self.assertEqual(outcome["host_fallback_authorizations"], 0)
self.assertFalse(outcome["marker_in_canonical"]) self.assertFalse(outcome["marker_in_canonical"])
self.assertTrue(outcome["canonical_preserved"]) self.assertTrue(outcome["canonical_preserved"])
cleanup = outcome["cleanup"] cleanup = outcome["cleanup"]
+296 -43
View File
@@ -31,10 +31,10 @@ MINIMAL_BLOCK = textwrap.dedent(
raw_output_bytes: 10485760 raw_output_bytes: 10485760
roles: roles:
implementation: [cursor, claude] implementation: [cursor, pi]
repair: [cursor] repair: [cursor]
test: [claude] test: [pi]
review: [claude] review: [pi]
profiles: profiles:
cursor: cursor:
@@ -43,9 +43,9 @@ MINIMAL_BLOCK = textwrap.dedent(
model: auto model: auto
enabled: true enabled: true
claude: pi:
adapter: claude adapter: pi
executable: claude executable: pi
model: auto model: auto
enabled: true enabled: true
""" """
@@ -76,7 +76,7 @@ class AgentExecConfigTests(unittest.TestCase):
self.assertTrue(cfg.execution.fresh_process_per_unit) self.assertTrue(cfg.execution.fresh_process_per_unit)
self.assertEqual(cfg.execution.permission, "run-everything") self.assertEqual(cfg.execution.permission, "run-everything")
self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable") 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.profiles["cursor"].executable, "cursor-agent")
self.assertEqual(cfg.custom_adapters, {}) self.assertEqual(cfg.custom_adapters, {})
self.assertEqual(validate_config(self.repo), []) self.assertEqual(validate_config(self.repo), [])
@@ -112,7 +112,7 @@ class AgentExecConfigTests(unittest.TestCase):
load_config(self.repo) load_config(self.repo)
def test_role_order_does_not_follow_profile_order(self) -> None: 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( block = textwrap.dedent(
"""\ """\
schema: agent-execution/v1 schema: agent-execution/v1
@@ -127,15 +127,15 @@ class AgentExecConfigTests(unittest.TestCase):
raw_output_bytes: 4096 raw_output_bytes: 4096
roles: roles:
implementation: [cursor, claude] implementation: [cursor, pi]
repair: [cursor] repair: [cursor]
test: [claude] test: [pi]
review: [claude] review: [pi]
profiles: profiles:
claude: pi:
adapter: claude adapter: pi
executable: claude executable: pi
model: auto model: auto
enabled: true enabled: true
cursor: cursor:
@@ -147,19 +147,19 @@ class AgentExecConfigTests(unittest.TestCase):
) )
write_repo(self.repo, block) write_repo(self.repo, block)
cfg = load_config(self.repo) cfg = load_config(self.repo)
self.assertEqual(cfg.roles["implementation"], ("cursor", "claude")) self.assertEqual(cfg.roles["implementation"], ("cursor", "pi"))
self.assertEqual(list(cfg.profiles.keys())[0], "claude") self.assertEqual(list(cfg.profiles.keys())[0], "pi")
def test_model_auto_is_preserved_as_none(self) -> None: def test_model_auto_is_preserved_as_none(self) -> None:
write_repo(self.repo, MINIMAL_BLOCK) write_repo(self.repo, MINIMAL_BLOCK)
cfg = load_config(self.repo) cfg = load_config(self.repo)
self.assertIsNone(cfg.profiles["cursor"].model) 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: def test_rejects_unknown_profile_reference(self) -> None:
block = MINIMAL_BLOCK.replace( block = MINIMAL_BLOCK.replace(
"implementation: [cursor, claude]", "implementation: [cursor, pi]",
"implementation: [cursor, claude, missing]", "implementation: [cursor, pi, missing]",
) )
write_repo(self.repo, block) write_repo(self.repo, block)
with self.assertRaises(ConfigError): with self.assertRaises(ConfigError):
@@ -175,15 +175,15 @@ class AgentExecConfigTests(unittest.TestCase):
load_config(self.repo) load_config(self.repo)
secret_key = MINIMAL_BLOCK.replace( secret_key = MINIMAL_BLOCK.replace(
"enabled: true\n\n claude:", "enabled: true\n\n pi:",
"enabled: true\n api_token: \"sk-secret-value\"\n\n claude:", "enabled: true\n api_token: \"sk-secret-value\"\n\n pi:",
) )
write_repo(self.repo, secret_key) write_repo(self.repo, secret_key)
with self.assertRaises(ConfigError): with self.assertRaises(ConfigError):
load_config(self.repo) load_config(self.repo)
secret_val = MINIMAL_BLOCK.replace( secret_val = MINIMAL_BLOCK.replace(
"executable: claude", "executable: pi",
"executable: \"Bearer abcdef0123456789abcdef0123456789\"", "executable: \"Bearer abcdef0123456789abcdef0123456789\"",
) )
write_repo(self.repo, secret_val) write_repo(self.repo, secret_val)
@@ -320,20 +320,20 @@ class AgentExecConfigTests(unittest.TestCase):
def test_quoted_unicode_string_round_trips(self) -> None: def test_quoted_unicode_string_round_trips(self) -> None:
block = MINIMAL_BLOCK.replace( block = MINIMAL_BLOCK.replace(
"executable: claude", "executable: pi",
'executable: "模型-β-测试"', 'executable: "模型-β-测试"',
) )
write_repo(self.repo, block) write_repo(self.repo, block)
cfg = load_config(self.repo) cfg = load_config(self.repo)
self.assertEqual(cfg.profiles["claude"].executable, "模型-β-测试") self.assertEqual(cfg.profiles["pi"].executable, "模型-β-测试")
escaped = MINIMAL_BLOCK.replace( escaped = MINIMAL_BLOCK.replace(
"executable: claude", "executable: pi",
r'executable: "say \"hi\" 模型"', r'executable: "say \"hi\" 模型"',
) )
write_repo(self.repo, escaped) write_repo(self.repo, escaped)
cfg = load_config(self.repo) 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: def test_idle_timeout_defaults_within_total_and_rejects_bounds(self) -> None:
self.assertEqual(DEFAULT_IDLE_TIMEOUT_SECONDS, 600) self.assertEqual(DEFAULT_IDLE_TIMEOUT_SECONDS, 600)
@@ -412,14 +412,14 @@ class AgentExecConfigTests(unittest.TestCase):
" max_attempts_per_profile: 3\n" " max_attempts_per_profile: 3\n"
" emergency_profile: codex\n", " emergency_profile: codex\n",
).replace( ).replace(
" claude:\n" " pi:\n"
" adapter: claude\n" " adapter: pi\n"
" executable: claude\n" " executable: pi\n"
" model: auto\n" " model: auto\n"
" enabled: true\n", " enabled: true\n",
" claude:\n" " pi:\n"
" adapter: claude\n" " adapter: pi\n"
" executable: claude\n" " executable: pi\n"
" model: auto\n" " model: auto\n"
" enabled: true\n" " enabled: true\n"
"\n" "\n"
@@ -454,9 +454,9 @@ class AgentExecConfigTests(unittest.TestCase):
def test_emergency_profile_is_excluded_from_normal_roles(self) -> None: def test_emergency_profile_is_excluded_from_normal_roles(self) -> None:
for role, replacement in ( for role, replacement in (
("implementation", "[cursor, claude, codex]"), ("implementation", "[cursor, pi, codex]"),
("repair", "[cursor, codex]"), ("repair", "[cursor, codex]"),
("test", "[claude, codex]"), ("test", "[pi, codex]"),
): ):
write_repo(self.repo, _emergency_block(**{role: replacement})) write_repo(self.repo, _emergency_block(**{role: replacement}))
with self.assertRaises(ConfigError) as ctx: with self.assertRaises(ConfigError) as ctx:
@@ -465,35 +465,288 @@ class AgentExecConfigTests(unittest.TestCase):
self.assertIn(role, str(ctx.exception)) self.assertIn(role, str(ctx.exception))
def test_review_may_include_emergency_profile(self) -> None: 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) cfg = load_config(self.repo)
self.assertEqual(cfg.execution.emergency_profile, "codex") self.assertEqual(cfg.execution.emergency_profile, "codex")
self.assertTrue(cfg.profiles["codex"].enabled) self.assertTrue(cfg.profiles["codex"].enabled)
self.assertEqual(cfg.profiles["codex"].adapter, "codex") 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["implementation"])
self.assertNotIn("codex", cfg.roles["repair"]) self.assertNotIn("codex", cfg.roles["repair"])
self.assertNotIn("codex", cfg.roles["test"]) 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( def _emergency_block(
*, *,
include_profile: bool = True, include_profile: bool = True,
adapter: str = "codex", adapter: str = "codex",
enabled: str = "true", enabled: str = "true",
implementation: str = "[cursor, claude]", implementation: str = "[cursor, pi]",
repair: str = "[cursor]", repair: str = "[cursor]",
test: str = "[claude]", test: str = "[pi]",
review: str = "[claude]", review: str = "[pi]",
) -> str: ) -> str:
block = MINIMAL_BLOCK.replace( block = MINIMAL_BLOCK.replace(
" raw_output_bytes: 10485760\n", " 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("repair: [cursor]", f"repair: {repair}")
block = block.replace("test: [claude]", f"test: {test}") block = block.replace("test: [pi]", f"test: {test}")
block = block.replace("review: [claude]", f"review: {review}") block = block.replace("review: [pi]", f"review: {review}")
if not include_profile: if not include_profile:
return block return block
return block.replace( return block.replace(
+2 -1
View File
@@ -728,7 +728,6 @@ class TaskLifecycleTests(unittest.TestCase):
"| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |", "| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |",
agent_receipts, agent_receipts,
) )
self.assertNotIn("host-fallback-authorized", agent_receipts)
self.assertIn("Cursor、Antigravity(agy)和 PI", agent_receipts) self.assertIn("Cursor、Antigravity(agy)和 PI", agent_receipts)
self.assertIn("最多 3 次全新尝试", agent_receipts) self.assertIn("最多 3 次全新尝试", agent_receipts)
self.assertIn("一次全新的 Codex CLI 紧急尝试", 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("docs/superpowers/plans/**", agent_receipts)
self.assertIn("fallback 列只记录该行是否为 Codex 紧急尝试", agent_receipts) self.assertIn("fallback 列只记录该行是否为 Codex 紧急尝试", agent_receipts)
self.assertIn("不授权 host 直接撰写该单元", agent_receipts) self.assertIn("不授权 host 直接撰写该单元", agent_receipts)
self.assertIn("安全耗尽不发出 host-fallback-authorized", agent_receipts)
self.assertIn("终态为 unavailable 或 blocked", agent_receipts)
for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"): for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"):
self.assertIn(field + ":", card) self.assertIn(field + ":", card)
self.assertIn("## 交付调度\n", card) self.assertIn("## 交付调度\n", card)