30dfd92021
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.
201 lines
6.3 KiB
Python
201 lines
6.3 KiB
Python
"""Worker startup janitor: orphan-workspace + orphan-session cleanup.
|
|
|
|
Plan v6: at worker startup, before entering the main loop, the worker
|
|
scans the workspace root for orphan sidecars:
|
|
|
|
For each ``{workspace_root}/pr-*/worker.session``:
|
|
1. Read the sidecar; if missing/malformed → just delete the workspace.
|
|
2. If the recorded subprocess_pid is alive AND no DB row is held by
|
|
this instance_id → orphan. Try OpenCode session-cancel API;
|
|
SIGKILL fallback. Delete sidecar.
|
|
3. Delete the workspace dir.
|
|
|
|
The DB query for "is there a row held by instance_id X" is the
|
|
authoritative liveness check; the local pid is the fallback signal.
|
|
|
|
For this skeleton, the OpenCode-cancel-API call is parameterised as
|
|
``cancel_callback``. Production wires it to a real HTTP POST against
|
|
``{opencode_url}/sessions/{session_id}/cancel``; tests inject a fake
|
|
that records calls.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.engine import Engine
|
|
|
|
from .session_sidecar import WorkerSession, read_sidecar
|
|
from .workspace import DEFAULT_WORKSPACE_ROOT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Callback the production worker injects to ask OpenCode to cancel a
|
|
# session. Returns True iff the cancel succeeded (or the session
|
|
# already didn't exist). False → fall back to SIGKILL.
|
|
CancelCallback = Callable[[WorkerSession], bool]
|
|
|
|
|
|
@dataclass
|
|
class JanitorReport:
|
|
"""Summary of one sweep — used by tests + by the worker's
|
|
structured logging."""
|
|
|
|
workspaces_scanned: int = 0
|
|
sidecars_orphan: int = 0
|
|
sessions_cancelled_via_api: int = 0
|
|
sessions_killed: int = 0
|
|
workspaces_removed: int = 0
|
|
errors: list[str] = None # type: ignore[assignment]
|
|
|
|
def __post_init__(self):
|
|
if self.errors is None:
|
|
self.errors = []
|
|
|
|
|
|
def _pid_alive(pid: int) -> bool:
|
|
"""Cross-platform best-effort ``kill -0`` equivalent."""
|
|
if pid <= 0:
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
# Permission denied means the pid exists but we can't signal
|
|
# it. From orphan-detection's standpoint: it's still alive.
|
|
return True
|
|
return True
|
|
|
|
|
|
def _kill_with_grace(pid: int, *, grace_s: float = 5.0) -> bool:
|
|
"""SIGTERM + wait grace + SIGKILL.
|
|
|
|
Returns True iff we issued at least one kill signal that the
|
|
kernel accepted (ESRCH = nothing to kill; that counts as "already
|
|
dead" → True). After SIGKILL is delivered we DON'T re-check
|
|
``_pid_alive`` because zombie processes (parent hasn't reaped)
|
|
still report alive via ``kill -0``; reaping zombies isn't the
|
|
janitor's responsibility.
|
|
"""
|
|
if not _pid_alive(pid):
|
|
return True
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
return True
|
|
deadline = time.monotonic() + grace_s
|
|
while time.monotonic() < deadline:
|
|
if not _pid_alive(pid):
|
|
return True
|
|
time.sleep(0.1)
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
return True
|
|
# SIGKILL was delivered. Zombie-reaping is the parent's job; we
|
|
# consider our work done.
|
|
return True
|
|
|
|
|
|
def sweep_orphans(
|
|
engine: Engine,
|
|
*,
|
|
workspace_root: Path | None = None,
|
|
cancel_callback: CancelCallback | None = None,
|
|
) -> JanitorReport:
|
|
"""Scan ``workspace_root`` for orphan sidecars + clean them up.
|
|
|
|
A sidecar is orphaned iff:
|
|
- No DB row has ``status='in_progress'`` AND
|
|
``locked_by_instance == sidecar.instance_id``.
|
|
|
|
For each orphan: try OpenCode cancel API → SIGKILL → delete
|
|
sidecar → delete workspace dir.
|
|
"""
|
|
root = workspace_root or DEFAULT_WORKSPACE_ROOT
|
|
report = JanitorReport()
|
|
if not root.is_dir():
|
|
return report
|
|
|
|
# Pre-query the DB once for the set of live instance_ids on
|
|
# in_progress rows. Cheap; lets us skip per-sidecar DB hits.
|
|
live_instances = _query_live_instances(engine)
|
|
|
|
for entry in sorted(root.iterdir()):
|
|
if not entry.is_dir() or not entry.name.startswith("pr-"):
|
|
continue
|
|
report.workspaces_scanned += 1
|
|
sidecar_path = entry / "worker.session"
|
|
|
|
sidecar = read_sidecar(sidecar_path)
|
|
if sidecar is None:
|
|
# No sidecar (or malformed) → workspace is orphan with no
|
|
# session info; just delete.
|
|
_safe_rmtree(entry, report)
|
|
continue
|
|
|
|
if sidecar.instance_id in live_instances:
|
|
# Active worker owns this. Leave it alone.
|
|
continue
|
|
|
|
# Orphan: clean it up.
|
|
report.sidecars_orphan += 1
|
|
if cancel_callback is not None:
|
|
try:
|
|
if cancel_callback(sidecar):
|
|
report.sessions_cancelled_via_api += 1
|
|
else:
|
|
if _kill_with_grace(sidecar.subprocess_pid):
|
|
report.sessions_killed += 1
|
|
except Exception as exc: # noqa: BLE001 — best-effort cleanup
|
|
report.errors.append(
|
|
f"cancel_callback raised for {sidecar.instance_id}: {exc}"
|
|
)
|
|
if _kill_with_grace(sidecar.subprocess_pid):
|
|
report.sessions_killed += 1
|
|
else:
|
|
# No callback → SIGKILL fallback.
|
|
if _kill_with_grace(sidecar.subprocess_pid):
|
|
report.sessions_killed += 1
|
|
|
|
_safe_rmtree(entry, report)
|
|
|
|
return report
|
|
|
|
|
|
def _query_live_instances(engine: Engine) -> set[str]:
|
|
"""Return the set of instance_id values currently holding
|
|
in_progress rows."""
|
|
with engine.connect() as conn:
|
|
rows = conn.execute(
|
|
text(
|
|
"SELECT DISTINCT locked_by_instance FROM workflow_attempts "
|
|
"WHERE status = 'in_progress' AND locked_by_instance IS NOT NULL"
|
|
)
|
|
).all()
|
|
return {row[0] for row in rows}
|
|
|
|
|
|
def _safe_rmtree(path: Path, report: JanitorReport) -> None:
|
|
try:
|
|
shutil.rmtree(path)
|
|
report.workspaces_removed += 1
|
|
except OSError as exc:
|
|
report.errors.append(f"rmtree {path}: {exc}")
|
|
|
|
|
|
__all__ = [
|
|
"CancelCallback",
|
|
"JanitorReport",
|
|
"sweep_orphans",
|
|
]
|