eab476e48e
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.
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""Worker heartbeat thread.
|
|
|
|
Per plan v9 (simplified): TTL-only heartbeat. The thread updates
|
|
``workflow_attempts.lock_heartbeat_at`` every ``HEARTBEAT_INTERVAL_S``
|
|
(default 30s). If the UPDATE returns rowcount=0, the lock has been
|
|
reaped (TTL exceeded; reaper reset the row); the heartbeat thread
|
|
raises ``HeartbeatStopped`` via an event the worker thread polls.
|
|
|
|
Activity tracking (v6 enhancement) is intentionally deferred per
|
|
v9 simplification: TTL alone catches dead workers within
|
|
``lock_ttl_seconds`` (default 600s). Re-add activity tracking only
|
|
if real hangs are observed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.engine import Engine
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
HEARTBEAT_INTERVAL_S = int(os.environ.get("CONTROLLER_HEARTBEAT_INTERVAL_S", "30"))
|
|
|
|
|
|
class HeartbeatStopped(RuntimeError):
|
|
"""Raised when the heartbeat thread detects it has lost the lock.
|
|
|
|
Worker code polls ``Heartbeat.lost_lock_event`` and aborts cleanly
|
|
when set (no output write — the reaper has already re-pended
|
|
the attempt; another worker will retry).
|
|
"""
|
|
|
|
|
|
class Heartbeat:
|
|
"""Per-attempt heartbeat thread.
|
|
|
|
Usage:
|
|
hb = Heartbeat(engine, attempt_id, instance_id)
|
|
hb.start()
|
|
try:
|
|
# … long-running work; periodically check hb.lost_lock_event
|
|
if hb.lost_lock_event.is_set():
|
|
raise HeartbeatStopped("lock reaped")
|
|
finally:
|
|
hb.stop()
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
engine: Engine,
|
|
attempt_id: int,
|
|
instance_id: str,
|
|
*,
|
|
interval_s: int = HEARTBEAT_INTERVAL_S,
|
|
) -> None:
|
|
self.engine = engine
|
|
self.attempt_id = attempt_id
|
|
self.instance_id = instance_id
|
|
self.interval_s = interval_s
|
|
self.lost_lock_event = threading.Event()
|
|
self._stop_event = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._last_heartbeat_at: datetime | None = None
|
|
|
|
def start(self) -> None:
|
|
if self._thread is not None:
|
|
raise RuntimeError("heartbeat already started")
|
|
self._thread = threading.Thread(
|
|
target=self._loop,
|
|
name=f"hb-{self.attempt_id}",
|
|
daemon=True,
|
|
)
|
|
self._thread.start()
|
|
|
|
def stop(self, *, timeout: float = 5.0) -> None:
|
|
self._stop_event.set()
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=timeout)
|
|
|
|
def last_heartbeat_at(self) -> datetime | None:
|
|
"""Last successful heartbeat write (UTC). None until first
|
|
UPDATE succeeds."""
|
|
return self._last_heartbeat_at
|
|
|
|
def _loop(self) -> None:
|
|
# Fire immediately so the lock_heartbeat_at column reflects
|
|
# the worker's activity before the first interval lapses.
|
|
if not self._tick():
|
|
return
|
|
while not self._stop_event.wait(self.interval_s):
|
|
if not self._tick():
|
|
return
|
|
|
|
def _tick(self) -> bool:
|
|
"""Update lock_heartbeat_at. Returns False if lock was lost
|
|
(worker should abort)."""
|
|
now = datetime.now(timezone.utc)
|
|
try:
|
|
with self.engine.connect() as conn:
|
|
result = conn.execute(
|
|
text(
|
|
"UPDATE workflow_attempts "
|
|
"SET lock_heartbeat_at = :now "
|
|
"WHERE attempt_id = :attempt_id "
|
|
" AND locked_by_instance = :instance"
|
|
),
|
|
{
|
|
"now": now,
|
|
"attempt_id": self.attempt_id,
|
|
"instance": self.instance_id,
|
|
},
|
|
)
|
|
conn.commit()
|
|
rowcount = result.rowcount
|
|
except Exception as exc: # noqa: BLE001 — best-effort heartbeat
|
|
# Transient DB error. Log + retry next tick. The lock_ttl
|
|
# eventually trips if these stack up.
|
|
logger.warning(
|
|
"heartbeat UPDATE failed for attempt_id=%s: %s",
|
|
self.attempt_id, exc,
|
|
)
|
|
return True
|
|
|
|
if rowcount == 0:
|
|
logger.warning(
|
|
"heartbeat lost lock for attempt_id=%s instance=%s — "
|
|
"reaper has re-pended; worker should abort",
|
|
self.attempt_id, self.instance_id,
|
|
)
|
|
self.lost_lock_event.set()
|
|
return False
|
|
|
|
self._last_heartbeat_at = now
|
|
return True
|