0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
161 lines
5.4 KiB
Python
161 lines
5.4 KiB
Python
"""Worker session sidecar: per-workspace file tracking the live
|
|
OpenCode session + subprocess for orphan recovery.
|
|
|
|
Per plan v6: at attempt spawn, the worker writes
|
|
``{workspace}/worker.session`` BEFORE issuing the OpenCode prompt
|
|
that begins agent work. The sidecar records:
|
|
|
|
- ``opencode_server_url``: which OpenCode the worker is talking to
|
|
(different worker machines may use different OpenCode instances).
|
|
- ``session_id``: the OpenCode session id (needed for the
|
|
cancel-via-API path on orphan cleanup).
|
|
- ``subprocess_pid``: the local OS pid that owns the session (fallback
|
|
for SIGKILL when the API cancel fails).
|
|
- ``spawned_by_controller_pid``: this worker process's PID. Lets the
|
|
janitor identify "sessions spawned by a worker that is no longer
|
|
running on this machine."
|
|
- ``instance_id``: the worker instance the lock is held by. Janitor
|
|
cross-checks against DB.
|
|
- ``spawned_at``: ISO timestamp.
|
|
|
|
Janitor (janitor.py) reads every sidecar on this machine at worker
|
|
startup; sessions whose owning instance is no longer alive get
|
|
cancelled (OpenCode API first, SIGKILL fallback) and the sidecar +
|
|
workspace are deleted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorkerSession:
|
|
"""The sidecar's shape. Frozen dataclass = JSON serialisable +
|
|
immutable after write.
|
|
|
|
``subprocess_starttime`` (Phase 1k+ refinement): the Linux process
|
|
start-time read from ``/proc/{pid}/stat`` field 22 at sidecar
|
|
write. Used by the janitor to defend against PID reuse — between
|
|
sidecar write and janitor sweep, the OS may have reused the PID
|
|
for an unrelated process; without starttime confirmation the
|
|
janitor would SIGTERM/SIGKILL the wrong process. Optional (None)
|
|
for cross-platform compatibility + backward compat with sidecars
|
|
written before this column existed.
|
|
"""
|
|
|
|
opencode_server_url: str
|
|
session_id: str
|
|
subprocess_pid: int
|
|
spawned_by_controller_pid: int
|
|
instance_id: str
|
|
spawned_at: str # ISO 8601 UTC
|
|
subprocess_starttime: int | None = None # /proc/pid/stat field 22
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "WorkerSession":
|
|
# Forward-compat: tolerate sidecars missing newer fields (e.g.
|
|
# subprocess_starttime added in Phase 1k+) AND sidecars carrying
|
|
# extra fields a future version might add.
|
|
known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
|
|
return cls(**known)
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def read_proc_starttime(pid: int) -> int | None:
|
|
"""Read a Linux process's start time from ``/proc/{pid}/stat`` field
|
|
22 (in clock ticks since system boot).
|
|
|
|
Returns None for non-Linux hosts, missing PID, permission denied,
|
|
or any parse error. Callers should treat None as "starttime
|
|
confirmation unavailable" — fall back to PID-alone semantics for
|
|
that process.
|
|
|
|
The starttime is monotonic for a given (boot, pid): two processes
|
|
with the same PID on the same boot have the same starttime IFF
|
|
they are the same process. PID reuse across reboots is handled by
|
|
a reboot also resetting the starttime baseline.
|
|
"""
|
|
if pid <= 0:
|
|
return None
|
|
try:
|
|
with open(f"/proc/{pid}/stat", "rb") as f:
|
|
raw = f.read()
|
|
except (OSError, PermissionError):
|
|
return None
|
|
# /proc/[pid]/stat field 2 is the (parenthesized) comm; a comm that
|
|
# contains spaces or close-parens breaks naive split(). Take the
|
|
# text after the LAST ')' as the start of fields 3+.
|
|
end_paren = raw.rfind(b")")
|
|
if end_paren < 0:
|
|
return None
|
|
fields = raw[end_paren + 1 :].split()
|
|
# Field indexing on the split portion: field 3 is at index 0
|
|
# (state), field 22 (starttime) is at index 19.
|
|
if len(fields) < 20:
|
|
return None
|
|
try:
|
|
return int(fields[19])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def write_sidecar(path: Path, session: WorkerSession) -> None:
|
|
"""Write the sidecar JSON atomically (write to ``.tmp`` + rename).
|
|
|
|
Atomic so a partial write isn't read by the janitor. fsync before
|
|
rename ensures durability across power loss.
|
|
"""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
payload = json.dumps(session.to_dict(), sort_keys=True, indent=2)
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
f.write(payload)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def read_sidecar(path: Path) -> WorkerSession | None:
|
|
"""Read + parse the sidecar JSON. Returns ``None`` for missing,
|
|
empty, or malformed sidecars (janitor treats those as
|
|
fully-orphaned + cleans up)."""
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
if not text.strip():
|
|
return None
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
try:
|
|
return WorkerSession.from_dict(data)
|
|
except (TypeError, KeyError):
|
|
return None
|
|
|
|
|
|
__all__ = [
|
|
"WorkerSession",
|
|
"now_iso",
|
|
"read_proc_starttime",
|
|
"read_sidecar",
|
|
"write_sidecar",
|
|
]
|