diff --git a/AGENT-EXECUTION.md b/AGENT-EXECUTION.md index 1a8f2023..93a3ade0 100644 --- a/AGENT-EXECUTION.md +++ b/AGENT-EXECUTION.md @@ -7,11 +7,14 @@ parser accepts exactly one `agent-execution` fenced block. U3's execution controller and U4's project wiring are integrated. This file is the execution-routing authority for implementation, repair, test, and review. -Active routes are intentionally limited to Cursor, Antigravity (`agy`), and -PI. `model: auto` lets each CLI use its configured default model; PI therefore -uses the operator-selected MiniMax default. A provider that makes no successful -stdin write or stdout/stderr read for `idle_timeout_seconds` is stopped before -`timeout_seconds`. +Normal implementation, repair, and test routes stay Cursor, Antigravity +(`agy`), and PI. Each profile may be attempted up to +`max_attempts_per_profile`. Only after those candidates safely exhaust their +attempts does the enabled emergency `codex` profile run once. Codex remains out +of the three normal role lists and may also appear in review. A configured +emergency profile replaces host write fallback. Cursor uses +`cursor-agent-stable`. `model: auto` lets each CLI use its configured default +model; PI therefore uses the operator-selected MiniMax default. ```agent-execution schema: agent-execution/v1 @@ -25,17 +28,19 @@ execution: idle_timeout_seconds: 600 summary_bytes: 16384 raw_output_bytes: 10485760 + max_attempts_per_profile: 3 + emergency_profile: codex roles: implementation: [cursor, antigravity, pi] repair: [cursor, antigravity, pi] test: [pi, antigravity, cursor] - review: [pi, antigravity, cursor] + review: [pi, antigravity, cursor, codex] profiles: cursor: adapter: cursor - executable: cursor-agent + executable: cursor-agent-stable model: auto enabled: true @@ -50,4 +55,10 @@ profiles: executable: pi model: auto enabled: true + + codex: + adapter: codex + executable: codex + model: auto + enabled: true ``` diff --git a/scripts/agent_exec_config.py b/scripts/agent_exec_config.py index e213fe14..12470725 100644 --- a/scripts/agent_exec_config.py +++ b/scripts/agent_exec_config.py @@ -343,6 +343,8 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig: if profile.adapter not in custom_adapters: raise ConfigError(f"unknown adapter: {profile.adapter!r}") + _validate_emergency_profile(execution, profiles, roles) + return ExecutionConfig( schema=schema, execution=execution, @@ -428,6 +430,28 @@ def _build_execution(raw: Any) -> ExecutionPolicy: ) +def _validate_emergency_profile( + execution: ExecutionPolicy, + profiles: Mapping[str, Profile], + roles: Mapping[str, tuple[str, ...]], +) -> None: + name = execution.emergency_profile + if name is None: + return + profile = profiles.get(name) + if profile is None: + raise ConfigError(f"emergency_profile {name!r} does not exist") + if not profile.enabled: + raise ConfigError(f"emergency_profile {name!r} must be enabled") + if profile.adapter != "codex": + raise ConfigError(f"emergency_profile {name!r} must use adapter codex") + for role in ("implementation", "repair", "test"): + if name in roles[role]: + raise ConfigError( + f"emergency_profile {name!r} must not appear in role {role!r}" + ) + + def _positive_bounded(value: Any, name: str, lo: int, hi: int) -> int: if type(value) is not int or isinstance(value, bool): raise ConfigError(f"{name} must be an integer") diff --git a/tests/test_agent_exec_config.py b/tests/test_agent_exec_config.py index 6261c3b2..4a0d5405 100644 --- a/tests/test_agent_exec_config.py +++ b/tests/test_agent_exec_config.py @@ -434,6 +434,80 @@ class AgentExecConfigTests(unittest.TestCase): self.assertEqual(cfg.execution.max_attempts_per_profile, 3) self.assertEqual(cfg.execution.emergency_profile, "codex") + def test_configured_emergency_profile_must_exist_be_enabled_and_use_adapter_codex( + self, + ) -> None: + write_repo(self.repo, _emergency_block(include_profile=False)) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("does not exist", str(ctx.exception)) + + write_repo(self.repo, _emergency_block(enabled="false")) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("must be enabled", str(ctx.exception)) + + write_repo(self.repo, _emergency_block(adapter="pi")) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("adapter codex", str(ctx.exception)) + + def test_emergency_profile_is_excluded_from_normal_roles(self) -> None: + for role, replacement in ( + ("implementation", "[cursor, claude, codex]"), + ("repair", "[cursor, codex]"), + ("test", "[claude, codex]"), + ): + write_repo(self.repo, _emergency_block(**{role: replacement})) + with self.assertRaises(ConfigError) as ctx: + load_config(self.repo) + self.assertIn("must not appear", str(ctx.exception)) + self.assertIn(role, str(ctx.exception)) + + def test_review_may_include_emergency_profile(self) -> None: + write_repo(self.repo, _emergency_block(review="[claude, codex]")) + cfg = load_config(self.repo) + self.assertEqual(cfg.execution.emergency_profile, "codex") + self.assertTrue(cfg.profiles["codex"].enabled) + self.assertEqual(cfg.profiles["codex"].adapter, "codex") + self.assertEqual(cfg.roles["review"], ("claude", "codex")) + self.assertNotIn("codex", cfg.roles["implementation"]) + self.assertNotIn("codex", cfg.roles["repair"]) + self.assertNotIn("codex", cfg.roles["test"]) + + +def _emergency_block( + *, + include_profile: bool = True, + adapter: str = "codex", + enabled: str = "true", + implementation: str = "[cursor, claude]", + repair: str = "[cursor]", + test: str = "[claude]", + review: str = "[claude]", +) -> str: + block = MINIMAL_BLOCK.replace( + " raw_output_bytes: 10485760\n", + " raw_output_bytes: 10485760\n emergency_profile: codex\n", + ) + block = block.replace("implementation: [cursor, claude]", f"implementation: {implementation}") + block = block.replace("repair: [cursor]", f"repair: {repair}") + block = block.replace("test: [claude]", f"test: {test}") + block = block.replace("review: [claude]", f"review: {review}") + if not include_profile: + return block + return block.replace( + " enabled: true\n", + " enabled: true\n" + "\n" + " codex:\n" + f" adapter: {adapter}\n" + " executable: codex\n" + " model: auto\n" + f" enabled: {enabled}\n", + 1, + ) + if __name__ == "__main__": unittest.main()