Why: prevent silent provider stalls from consuming the full execution timeout Evidence: 57 focused tests, configuration validation, 401-test governance regression, and diff check Impact: adds backward-compatible idle_timeout_seconds and fail-stop process supervision Refs: U5 runtime acceptance; docs/superpowers/plans/2026-10-15-project-agent-execution.md
411 lines
14 KiB
Python
411 lines
14 KiB
Python
"""Configuration parser and schema contract for AGENT-EXECUTION.md."""
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from scripts.agent_exec_config import (
|
|
DEFAULT_IDLE_TIMEOUT_SECONDS,
|
|
ConfigError,
|
|
ExecutionConfig,
|
|
load_config,
|
|
validate_config,
|
|
)
|
|
|
|
|
|
SCHEMA = "agent-execution/v1"
|
|
|
|
MINIMAL_BLOCK = textwrap.dedent(
|
|
"""\
|
|
schema: agent-execution/v1
|
|
|
|
execution:
|
|
fresh_process_per_unit: true
|
|
permission: run-everything
|
|
fallback_to_host: all-unavailable
|
|
max_parallel: 3
|
|
timeout_seconds: 7200
|
|
summary_bytes: 16384
|
|
raw_output_bytes: 10485760
|
|
|
|
roles:
|
|
implementation: [cursor, claude]
|
|
repair: [cursor]
|
|
test: [claude]
|
|
review: [claude]
|
|
|
|
profiles:
|
|
cursor:
|
|
adapter: cursor
|
|
executable: cursor-agent
|
|
model: auto
|
|
enabled: true
|
|
|
|
claude:
|
|
adapter: claude
|
|
executable: claude
|
|
model: auto
|
|
enabled: true
|
|
"""
|
|
)
|
|
|
|
|
|
def write_repo(root: Path, body: str, *, extra_blocks: list[str] | None = None) -> Path:
|
|
parts = ["# Fixture\n", "```agent-execution\n", body.rstrip(), "\n```\n"]
|
|
if extra_blocks:
|
|
for block in extra_blocks:
|
|
parts.extend(["\n```agent-execution\n", block.rstrip(), "\n```\n"])
|
|
(root / "AGENT-EXECUTION.md").write_text("".join(parts), encoding="utf-8")
|
|
return root
|
|
|
|
|
|
class AgentExecConfigTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-config-")
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.repo = Path(self.tmp.name)
|
|
|
|
def test_loads_single_agent_execution_block(self) -> None:
|
|
write_repo(self.repo, MINIMAL_BLOCK)
|
|
self.assertTrue(issubclass(ConfigError, ValueError))
|
|
cfg = load_config(self.repo)
|
|
self.assertIsInstance(cfg, ExecutionConfig)
|
|
self.assertEqual(cfg.schema, SCHEMA)
|
|
self.assertTrue(cfg.execution.fresh_process_per_unit)
|
|
self.assertEqual(cfg.execution.permission, "run-everything")
|
|
self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable")
|
|
self.assertEqual(cfg.roles["implementation"], ("cursor", "claude"))
|
|
self.assertEqual(cfg.profiles["cursor"].executable, "cursor-agent")
|
|
self.assertEqual(cfg.custom_adapters, {})
|
|
self.assertEqual(validate_config(self.repo), [])
|
|
|
|
def test_rejects_missing_or_duplicate_blocks(self) -> None:
|
|
(self.repo / "AGENT-EXECUTION.md").write_text("# no block\n", encoding="utf-8")
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
write_repo(self.repo, MINIMAL_BLOCK, extra_blocks=[MINIMAL_BLOCK])
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
def test_rejects_unknown_schema_and_unknown_keys(self) -> None:
|
|
bad_schema = MINIMAL_BLOCK.replace(
|
|
"schema: agent-execution/v1", "schema: agent-execution/v0"
|
|
)
|
|
write_repo(self.repo, bad_schema)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
bad_key = MINIMAL_BLOCK + "\nextra_top_level: true\n"
|
|
write_repo(self.repo, bad_key)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
bad_exec = MINIMAL_BLOCK.replace(
|
|
"raw_output_bytes: 10485760\n",
|
|
"raw_output_bytes: 10485760\n mystery: 1\n",
|
|
)
|
|
write_repo(self.repo, bad_exec)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
def test_role_order_does_not_follow_profile_order(self) -> None:
|
|
# profiles listed claude before cursor; role order must stay cursor -> claude
|
|
block = 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: [cursor, claude]
|
|
repair: [cursor]
|
|
test: [claude]
|
|
review: [claude]
|
|
|
|
profiles:
|
|
claude:
|
|
adapter: claude
|
|
executable: claude
|
|
model: auto
|
|
enabled: true
|
|
cursor:
|
|
adapter: cursor
|
|
executable: cursor-agent
|
|
model: auto
|
|
enabled: true
|
|
"""
|
|
)
|
|
write_repo(self.repo, block)
|
|
cfg = load_config(self.repo)
|
|
self.assertEqual(cfg.roles["implementation"], ("cursor", "claude"))
|
|
self.assertEqual(list(cfg.profiles.keys())[0], "claude")
|
|
|
|
def test_model_auto_is_preserved_as_none(self) -> None:
|
|
write_repo(self.repo, MINIMAL_BLOCK)
|
|
cfg = load_config(self.repo)
|
|
self.assertIsNone(cfg.profiles["cursor"].model)
|
|
self.assertIsNone(cfg.profiles["claude"].model)
|
|
|
|
def test_rejects_unknown_profile_reference(self) -> None:
|
|
block = MINIMAL_BLOCK.replace(
|
|
"implementation: [cursor, claude]",
|
|
"implementation: [cursor, claude, missing]",
|
|
)
|
|
write_repo(self.repo, block)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
def test_rejects_shell_command_and_secret_shaped_values(self) -> None:
|
|
shell = MINIMAL_BLOCK.replace(
|
|
"executable: cursor-agent",
|
|
"executable: \"cursor-agent && rm -rf /\"",
|
|
)
|
|
write_repo(self.repo, shell)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
secret_key = MINIMAL_BLOCK.replace(
|
|
"enabled: true\n\n claude:",
|
|
"enabled: true\n api_token: \"sk-secret-value\"\n\n claude:",
|
|
)
|
|
write_repo(self.repo, secret_key)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
secret_val = MINIMAL_BLOCK.replace(
|
|
"executable: claude",
|
|
"executable: \"Bearer abcdef0123456789abcdef0123456789\"",
|
|
)
|
|
write_repo(self.repo, secret_val)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
def test_custom_placeholders_must_fill_whole_argv_items(self) -> None:
|
|
good = 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
|
|
"""
|
|
)
|
|
write_repo(self.repo, good)
|
|
cfg = load_config(self.repo)
|
|
self.assertEqual(
|
|
cfg.custom_adapters["mycustom"].workspace_argv,
|
|
("--cwd", "${workspace}"),
|
|
)
|
|
|
|
bad = good.replace(
|
|
'workspace_argv: ["--cwd", "${workspace}"]',
|
|
'workspace_argv: ["--cwd=${workspace}"]',
|
|
)
|
|
write_repo(self.repo, bad)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
unknown_ph = good.replace(
|
|
'base_argv: ["run", "${unit_packet}"]',
|
|
'base_argv: ["run", "${other}"]',
|
|
)
|
|
write_repo(self.repo, unknown_ph)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
def test_numeric_limits_are_positive_and_bounded(self) -> None:
|
|
zero = MINIMAL_BLOCK.replace("max_parallel: 3", "max_parallel: 0")
|
|
write_repo(self.repo, zero)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
negative = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: -1")
|
|
write_repo(self.repo, negative)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
huge = MINIMAL_BLOCK.replace("summary_bytes: 16384", "summary_bytes: 999999999")
|
|
write_repo(self.repo, huge)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
inverted = MINIMAL_BLOCK.replace(
|
|
"summary_bytes: 16384\n raw_output_bytes: 10485760",
|
|
"summary_bytes: 10485760\n raw_output_bytes: 16384",
|
|
)
|
|
write_repo(self.repo, inverted)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
def test_fresh_process_per_unit_must_be_true(self) -> None:
|
|
block = MINIMAL_BLOCK.replace(
|
|
"fresh_process_per_unit: true",
|
|
"fresh_process_per_unit: false",
|
|
)
|
|
write_repo(self.repo, block)
|
|
with self.assertRaises(ConfigError) as ctx:
|
|
load_config(self.repo)
|
|
self.assertIn("fresh_process_per_unit", str(ctx.exception))
|
|
|
|
def test_rejects_indentation_jump(self) -> None:
|
|
# root mapping child jumps from 0 spaces to 4 spaces (must be exactly +2)
|
|
jumped = 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: [cursor]
|
|
repair: [cursor]
|
|
test: [cursor]
|
|
review: [cursor]
|
|
|
|
profiles:
|
|
cursor:
|
|
adapter: cursor
|
|
executable: cursor-agent
|
|
model: auto
|
|
enabled: true
|
|
"""
|
|
)
|
|
write_repo(self.repo, jumped)
|
|
with self.assertRaises(ConfigError) as ctx:
|
|
load_config(self.repo)
|
|
self.assertRegex(str(ctx.exception).lower(), r"indent|indentation")
|
|
|
|
def test_quoted_unicode_string_round_trips(self) -> None:
|
|
block = MINIMAL_BLOCK.replace(
|
|
"executable: claude",
|
|
'executable: "模型-β-测试"',
|
|
)
|
|
write_repo(self.repo, block)
|
|
cfg = load_config(self.repo)
|
|
self.assertEqual(cfg.profiles["claude"].executable, "模型-β-测试")
|
|
|
|
escaped = MINIMAL_BLOCK.replace(
|
|
"executable: claude",
|
|
r'executable: "say \"hi\" 模型"',
|
|
)
|
|
write_repo(self.repo, escaped)
|
|
cfg = load_config(self.repo)
|
|
self.assertEqual(cfg.profiles["claude"].executable, 'say "hi" 模型')
|
|
|
|
def test_idle_timeout_defaults_within_total_and_rejects_bounds(self) -> None:
|
|
self.assertEqual(DEFAULT_IDLE_TIMEOUT_SECONDS, 600)
|
|
write_repo(self.repo, MINIMAL_BLOCK)
|
|
cfg = load_config(self.repo)
|
|
self.assertEqual(cfg.execution.idle_timeout_seconds, 600)
|
|
self.assertLessEqual(cfg.execution.idle_timeout_seconds, cfg.execution.timeout_seconds)
|
|
|
|
short = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: 60")
|
|
write_repo(self.repo, short)
|
|
cfg = load_config(self.repo)
|
|
self.assertEqual(cfg.execution.timeout_seconds, 60)
|
|
self.assertEqual(cfg.execution.idle_timeout_seconds, 60)
|
|
|
|
explicit = MINIMAL_BLOCK.replace(
|
|
"timeout_seconds: 7200\n",
|
|
"timeout_seconds: 7200\n idle_timeout_seconds: 600\n",
|
|
)
|
|
write_repo(self.repo, explicit)
|
|
self.assertEqual(load_config(self.repo).execution.idle_timeout_seconds, 600)
|
|
|
|
equal = MINIMAL_BLOCK.replace(
|
|
"timeout_seconds: 7200\n",
|
|
"timeout_seconds: 100\n idle_timeout_seconds: 100\n",
|
|
)
|
|
write_repo(self.repo, equal)
|
|
cfg = load_config(self.repo)
|
|
self.assertEqual(cfg.execution.idle_timeout_seconds, 100)
|
|
self.assertEqual(cfg.execution.timeout_seconds, 100)
|
|
|
|
above = MINIMAL_BLOCK.replace(
|
|
"timeout_seconds: 7200\n",
|
|
"timeout_seconds: 100\n idle_timeout_seconds: 101\n",
|
|
)
|
|
write_repo(self.repo, above)
|
|
with self.assertRaises(ConfigError) as ctx:
|
|
load_config(self.repo)
|
|
self.assertIn("idle_timeout_seconds", str(ctx.exception))
|
|
|
|
zero = MINIMAL_BLOCK.replace(
|
|
"timeout_seconds: 7200\n",
|
|
"timeout_seconds: 7200\n idle_timeout_seconds: 0\n",
|
|
)
|
|
write_repo(self.repo, zero)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
negative = MINIMAL_BLOCK.replace(
|
|
"timeout_seconds: 7200\n",
|
|
"timeout_seconds: 7200\n idle_timeout_seconds: -5\n",
|
|
)
|
|
write_repo(self.repo, negative)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
flagged = MINIMAL_BLOCK.replace(
|
|
"timeout_seconds: 7200\n",
|
|
"timeout_seconds: 7200\n idle_timeout_seconds: true\n",
|
|
)
|
|
write_repo(self.repo, flagged)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
fractional = MINIMAL_BLOCK.replace(
|
|
"timeout_seconds: 7200\n",
|
|
"timeout_seconds: 7200\n idle_timeout_seconds: 1.5\n",
|
|
)
|
|
write_repo(self.repo, fractional)
|
|
with self.assertRaises(ConfigError):
|
|
load_config(self.repo)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|