Commit Graph

2 Commits

Author SHA1 Message Date
drew 30dfd92021 feat(controller): Phase 1c-2 — workspace + session sidecar + orphan janitor
Per-PR workspace umbrella manager (per plan v6) + the worker.session
sidecar that tracks live OpenCode session metadata for orphan cleanup
+ the startup janitor that sweeps orphaned workspaces from previous
worker crashes.

tools/controller/worker/:

- workspace.py: PerPRWorkspace + WorkspaceIdentity (frozen dataclass
  yielding the canonical pr-{owner}-{repo}-{N} dir name). ensure_present
  is idempotent. clone_if_absent runs `git clone --no-single-branch`
  if .git/ missing; idempotent on re-call. fetch_and_validate runs
  `git fetch origin --prune`, compares origin/<ref> to expected
  head_sha, raises StaleInputError on mismatch (v6 stale-input fix),
  then `git reset --hard <expected_sha>` + `git clean -fdx` to wipe
  worktree residue from prior attempts. remove() rm -rf's the
  workspace.

- session_sidecar.py: WorkerSession frozen dataclass +
  write_sidecar (atomic via tmp+rename+fsync) + read_sidecar (tolerates
  missing/empty/malformed/wrong-shape gracefully). Sidecar captures
  opencode_server_url / session_id / subprocess_pid /
  spawned_by_controller_pid / instance_id / spawned_at.

- janitor.py: sweep_orphans pre-queries the DB once for the set of
  live instance_ids (workflow_attempts.status='in_progress'), then
  scans workspace_root for pr-* dirs. For each:
  - no sidecar → just delete (crashed pre-spawn)
  - sidecar's instance_id is in live set → preserve (active worker)
  - else → orphan. Try cancel_callback (production wires to OpenCode
    cancel API); fall back to SIGTERM/grace/SIGKILL on the
    subprocess_pid. Then delete sidecar + workspace. JanitorReport
    summarises each sweep for structured logging.

Key v6 design points implemented:
- worker.session sidecar atomicity → orphan detection is robust
  against partial writes (e.g., worker crashed mid-spawn).
- Cancel-callback-then-SIGKILL fallback → production prefers the
  graceful OpenCode-side cancel; tests inject a fake.
- _kill_with_grace returns True after SIGKILL delivery; zombie
  reaping is the parent's responsibility, not the janitor's.

27 new tests:
- WorkspaceIdentity (format + frozen)
- PerPRWorkspace paths + ensure_present idempotency
- clone_if_absent (creates worktree, idempotent, raises without URL)
- fetch_and_validate (matching sha resets clean; mismatched raises
  StaleInputError; unknown ref raises)
- Sidecar I/O round-trip + atomicity + missing/empty/malformed handling
- Janitor: empty root / no-sidecar / active-lock / orphan-with-dead-pid
  / orphan-with-live-pid / cancel-callback (3 sub-paths: ok / fails /
  raises) / mixed-workspaces / non-pr-skip

Total: 184 controller tests; full auto_agents suite 2546 pass.
2026-05-18 13:29:08 -04:00
drew eab476e48e feat(controller): Phase 1c — worker controller skeleton
The dequeue+lock+heartbeat+runner+loop machinery. Production
OpenCode + MCP invocation slots in via the agent_runner callable
(Phase 1c-2). This commit is the structural foundation:

tools/controller/worker/:

- identity.py: build_instance_id() → "{hostname}/{pid}/{worker_uuid}"
  per plan v9 (slash delimiter; IPv6-safe; uuid4 prefix for
  per-instance uniqueness).

- heartbeat.py: Heartbeat thread that updates lock_heartbeat_at
  every interval (default 30s). v9 simplified: TTL-only (no activity
  tracking). UPDATE … WHERE locked_by_instance=us; rowcount=0 →
  lost_lock_event.set() and thread exits, letting reaper handle it.

- runner.py: run_one_attempt() drives one attempt end-to-end.
  Starts heartbeat → invokes agent_runner → on success writes
  status='complete' + output_payload; on WorkerError writes
  status='failed' with outcome label; on WorkerLostLock or detected
  stolen-lock-at-write returns aborted (no DB write — reaper has
  already re-pended). Defense-in-depth: even if agent returns
  successfully, lost_lock_event.is_set() check skips the write.

- loop.py: worker_main_loop() polls the DB for pending attempts up
  to MAX_CONCURRENT_WORKERS_PER_MACHINE, submits each to a
  ThreadPoolExecutor. Honors stop_event for graceful shutdown
  (drains in-flight before exit).

tools/controller/db/session.py: StaticPool for in-memory SQLite so
the heartbeat thread + runner write + dequeue all see the same DB
(without this, ":memory:" gives each connection an independent DB).

16 new tests in test_worker.py: instance ID format/uniqueness;
heartbeat tick (hold + steal); runner happy path; 5 error paths
(worker error / unexpected exception / WorkerLostLock raised /
stolen lock at write / lost_lock_event set defense-in-depth); 4
loop scenarios (single attempt, role filter skip, empty queue
exit-on-stop, explicit instance_id).

Total: 157 controller tests; full auto_agents suite 2519 pass.
2026-05-18 13:08:27 -04:00