python-config-type-coercion
Root cause: subprocess.run(timeout=...) requires an int/float/None. PyYAML loads test_timeout: 300s as the string '300s', which produced a TypeError inside check_go_tests — reproduced pre-fix:
unsupported operand type(s) for +: 'float' and 'str'
Fix: a single choke point _coerce_timeout in engine/guards.py, applied twice — eagerly at GuardManager.__init__ (both test_timeout and hook_timeout) and defensively inside check_go_tests at call time:
_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)")
def _coerce_timeout(value, key, default=None):
if value is None:
return default
if isinstance(value, bool): # bool is an int subclass — check first
raise ValueError(f"config key {key!r}: timeout must be a positive number "
f"of seconds, got bool {value!r}")
if isinstance(value, numbers.Real):
parsed = float(value)
elif isinstance(value, str):
match = _DURATION_RE.match(value) # leading-digits parse: "300s" -> 300
if match is None:
raise ValueError(f"config key {key!r}: cannot parse timeout from {value!r} "
f"(expected a positive number or a duration string like '300s')")
parsed = float(match.group(1))
else:
raise ValueError(f"config key {key!r}: unsupported timeout type "
f"{type(value).__name__} ({value!r})")
if parsed <= 0:
raise ValueError(f"config key {key!r}: timeout must be > 0 seconds, got {value!r}")
return int(parsed) if parsed.is_integer() else parsed
GuardManager integration (engine/guards.py):
class GuardManager:
def __init__(self, config=None, *,
default_test_timeout=DEFAULT_TEST_TIMEOUT, # 600
default_hook_timeout=DEFAULT_HOOK_TIMEOUT): # 300
self.config = dict(config or {})
self.test_timeout = _coerce_timeout(self.config.get("test_timeout"),
"test_timeout", default_test_timeout)
self.hook_timeout = _coerce_timeout(self.config.get("hook_timeout"),
"hook_timeout", default_hook_timeout)
def check_go_tests(self, cmd=None, timeout=None):
raw = timeout if timeout is not None else self.config.get("test_timeout")
safe_timeout = _coerce_timeout(raw, "test_timeout", self.test_timeout)
return subprocess.run(cmd or ["go", "test", "./..."],
capture_output=True, text=True, timeout=safe_timeout)
Key properties: "300s" → 300 (int), "2.5" → 2.5, None → default (or None = no timeout), garbage/bool/<=0 → ValueError naming the exact YAML key so operators can find the bad line in the fleet config.
`pytest tests/test_guards.py` — **12/12 passed** (Python 3.14.4, pytest 9.0.2): | # | Test | Result | |---|------|--------| | 1 | int passthrough `300` → `300` | PASS | | 2 | **`"300s"` → `300` (int)** — the fleet-wide crash vector | PASS | | 3 | `"2.5"` → `2.5` | PASS | | 4 | float `2.5` → `2.5` | PASS | | 5 | `None` → default `600` | PASS | | 6 | `None` w/o default → `None` (no-timeout, valid for subprocess) | PASS | | 7 | `" 300s "` whitespace → `300` | PASS | | 8 | `True`/`False` → ValueError naming `test_timeout` | PASS | | 9 | `"abc"`, `"s300"`, `""`, `" "`, `"timeout=300"` → ValueError naming key | PASS | | 10 | `0`, `-5`, `"0s"`, `"-1"` → ValueError naming key | PASS | | 11 | Init coerces **both** keys (`test_timeout: 300s`→300, `hook_timeout: 60s`→60) + defaults when absent | PASS | | 12 | `check_go_tests` defensive coercion: raw `"300s"` at call time → real subprocess runs, rc=0 | PASS | Live consumer-repo repro (`repro_consumer_repo.py`) — **PASS**: ``` loaded config: test_timeout='300s' (type=str) GuardManager: test_timeout=300 hook_timeout=90 go_tests stage: returncode=0 stdout='go tests ok' bad-config guard OK: config key 'test_timeout': timeout must be a positive number of seconds, got bool True ... REPRO: PASS ``` Edge cases verified: leading-digits parse ignores unit suffixes; bool rejected before the int-subclass trap; empty/`s300`-style strings fail with the key named; `<=0` rejected; `None` never raises (maps to default or `timeout=None` = no limit).
{"model": "deepseek-v4-flash", "problem_class": "python-config-type-coercion", "result": "passed", "tests": 12}