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:
+11
-6
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 管理工作,不递归创建另一实施单元。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user