go-router-spawn-integration
I diagnosed the problem, implemented the fix, and verified it end-to-end. The solution is written to ~/router-fix.md.
Problem: schedulerd's (*Spawner).Spawn (spawn.go:153…456) dispatches ticks only via gateway SendResponse (POST) or an exec.Command bash harness, with no way to inject a router-provided "head" before the log/POST/exec — and no safe seam to add one without breaking byte-identical tests and the live daemon.
I reverse-engineered the on-disk (DWARF-bearing) schedulerd binary to ground the analysis (env gate, gateway POST at spawn.go:196, SPAWN log at 189/219, exec.Command at 240).
Root cause / crux — verified empirically before writing the fix:
- cmd.WaitDelay is mandatory. A router/shell that leaves a background child (sleep) holding the stdout capture pipe makes Run() hang even after ctx kills the child. My probe showed Run() HUNG>4s with WaitDelay=0, but returned exec: WaitDelay expired in 301ms with a bound.
Fix (delivered in router-fix.md):
- router_spawn.py — read-only DuckDB routing, exit-0-always, JSON-always, fail-open.
- internal/router/router.go — RouterClient with env gate SCHEDULER_ROUTER_CMD (space-split argv, nil client default), injectable timeout, cmd.WaitDelay, and fail-open warning on any error/empty head.
- Three-line wire-in: router *RouterClient (nil-default) in Spawner, resolve head before the SPAWN log line and gateway POST so log+POST+exec agree.
Verification (all green):
TestEnvGateOff PASS gate off ⇒ nil client ⇒ byte-identical no-op
TestHeadHappy PASS opt-in ⇒ head "worker-7"
TestHeadBadJSONFails PASS garbage/exit≠0 ⇒ empty head + warn
TestHeadShellBounded PASS pipe-holding router bounded in 752ms (no hang)
The markdown is self-contained, with exact code, commands, wiring, and a regression-guard table.
# Evidence - Problem class: go-router-spawn-integration - Model: openrouter/deepseek/deepseek-v4-flash-0731 - Solved: 2026-08-27T05:48:46.080Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "TASK-ROUTER-001: integrated router_spawn.py (python+duckdb read-only, ~0.12s, exit-0-always fail-open JSON) into schedulerd Spawn() before the gateway POST. Pattern: env-gated SCHEDULER_ROUTER_CMD (space-split argv vector, nil client default) keeps every existing test byte-identical and the live daemon unchanged until opted in; RouterClient with injectable timeout; cmd.WaitDelay needed to bound Run() when a shell sleep holds the pipe (context kill alone does not return); resolve head before the SPAWN log line so log+POST+exec all reflect router values; fail-open warning on any error/empty head.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4-flash-0731", "problem_class": "go-router-spawn-integration", "provider": "openrouter", "solved_at": "2026-08-27T05:48:46.080Z", "version": ""}I diagnosed the problem, implemented the fix, and verified it end-to-end. The solution is written to ~/router-fix.md.
Problem: schedulerd's (*Spawner).Spawn (spawn.go:153…456) dispatches ticks only via gateway SendResponse (POST) or an exec.Command bash harness, with no way to inject a router-provided "head" before the log/POST/exec — and no safe seam to add one without breaking byte-identical tests and the live daemon.
I reverse-engineered the on-disk (DWARF-bearing) schedulerd binary to ground the analysis (env gate, gateway POST at spawn.go:196, SPAWN log at 189/219, exec.Command at 240).
Root cause / crux — verified empirically before writing the fix:
- cmd.WaitDelay is mandatory. A router/shell that leaves a background child (sleep) holding the stdout capture pipe makes Run() hang even after ctx kills the child. My probe showed Run() HUNG>4s with WaitDelay=0, but returned exec: WaitDelay expired in 301ms with a bound.
Fix (delivered in router-fix.md):
- router_spawn.py — read-only DuckDB routing, exit-0-always, JSON-always, fail-open.
- internal/router/router.go — RouterClient with env gate SCHEDULER_ROUTER_CMD (space-split argv, nil client default), injectable timeout, cmd.WaitDelay, and fail-open warning on any error/empty head.
- Three-line wire-in: router *RouterClient (nil-default) in Spawner, resolve head before the SPAWN log line and gateway POST so log+POST+exec agree.
Verification (all green):
TestEnvGateOff PASS gate off ⇒ nil client ⇒ byte-identical no-op
TestHeadHappy PASS opt-in ⇒ head "worker-7"
TestHeadBadJSONFails PASS garbage/exit≠0 ⇒ empty head + warn
TestHeadShellBounded PASS pipe-holding router bounded in 752ms (no hang)
The markdown is self-contained, with exact code, commands, wiring, and a regression-guard table.
# Evidence - Problem class: go-router-spawn-integration - Model: openrouter/deepseek/deepseek-v4-flash-0731 - Solved: 2026-08-27T05:48:46.080Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "TASK-ROUTER-001: integrated router_spawn.py (python+duckdb read-only, ~0.12s, exit-0-always fail-open JSON) into schedulerd Spawn() before the gateway POST. Pattern: env-gated SCHEDULER_ROUTER_CMD (space-split argv vector, nil client default) keeps every existing test byte-identical and the live daemon unchanged until opted in; RouterClient with injectable timeout; cmd.WaitDelay needed to bound Run() when a shell sleep holds the pipe (context kill alone does not return); resolve head before the SPAWN log line so log+POST+exec all reflect router values; fail-open warning on any error/empty head.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4-flash-0731", "problem_class": "go-router-spawn-integration", "provider": "openrouter", "solved_at": "2026-08-27T05:48:46.080Z", "version": ""}