Independent review t_5975c06a blocked this branch on a P1: a Kanban write that failed while a lease expired left a `lease_failed` row that was invisible to every pass, immortal to garbage collection, and fatal to the coordinator. It poisoned `reconcile()` forever with a conflicting-duplicate primary key, produced a spurious capability `block_task` from `dispatch()`, and -- because startup maintenance ran unguarded before the port bound, against a store on a PVC -- crash-looped the coordinator with no automatic recovery. `lease_failed` is now a retryable state that every maintenance pass drains, and a row only reaches a terminal state on authoritative evidence about its exact Kanban run, so nothing is collected before its outcome is known and nothing is silently dropped. Each row, task, and board is processed in isolation, and a coordinator-side fault is never converted into a Kanban mutation. Startup runs through the same guarded cycle as the steady-state loop. The wire protocol and the durable store are now separate modules, and the maintenance passes moved out of the coordinator, so each file stays under the managed line ceiling with room for the recovery logic. Also closes three consequential handoff risks the same review raised: * mediator-N pinned itself hard to worker-N while sharing a ReadWriteOnce claim, so a drain or preemption that moved only the lower-priority worker deadlocked the ordinal on Multi-Attach until an operator deleted a Pod. The shared workspace is now ReadWriteMany (as the hermes-chat tenant workspaces already are on the same class), colocation is a preference, and the mediator shares the worker's preemption priority, so each Pod reschedules on its own. * the broker permits only branch creation, so a retry that added commits could never submit and the run's work was discarded with the failure. Submission now targets a fresh attempt- or content-scoped ref in the same reviewed namespace -- never an update -- and is idempotent under replay. A refused submission downgrades the result and says why instead of unwinding the run. * the provider CLIs were reinstalled into an emptyDir on every Pod start inside the 10m Flux health window for the whole hermes app. They now install once per pinned version onto a durable volume, re-verified against the real binaries and time-bounded, and the best-effort pool no longer gates the health of the app its dependents wait on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
14 KiB
Python
372 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Recoverable maintenance passes for the Hermes execution-pool coordinator.
|
|
|
|
Every pass here runs against a shared Kanban SQLite database that other writers
|
|
(the agent UI, the local CLI lane, crons) hold concurrently, so *any* board call
|
|
can fail transiently. The rules this module enforces:
|
|
|
|
* No pass ever leaves a claimed run without a durable row that some later pass
|
|
will revisit. A Kanban write that fails leaves the row in its retryable state.
|
|
* No single poisoned row, task, or board aborts the work queued behind it.
|
|
* A durable row only reaches a terminal state on authoritative evidence about
|
|
its Kanban run, because terminal rows are the only ones ever collected.
|
|
* A coordinator-side fault is never converted into a Kanban mutation. Only a
|
|
genuine capability failure of the run itself parks that exact run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from typing import Any
|
|
|
|
import cli_lane_dispatch
|
|
from cli_lane_config import canonical_run_id
|
|
from execution_pool_project import ProjectPolicyError, distributed_workspace_eligible
|
|
from execution_pool_protocol import ProtocolError
|
|
|
|
|
|
RECOVERABLE_BOARD_ERRORS = (OSError, sqlite3.Error)
|
|
DEFERRALS: dict[str, str] = {}
|
|
MAX_DEFERRALS = 64
|
|
|
|
|
|
def _task_value(task: Any, name: str, default: Any = None) -> Any:
|
|
return getattr(task, name, default)
|
|
|
|
|
|
def _defer(context: str, error: BaseException) -> None:
|
|
"""Report one recoverable pool-side failure once per distinct detail.
|
|
|
|
The maintenance loop re-runs every few seconds, so an unchanged failure is
|
|
reported once rather than on every pass. The table is bounded because its
|
|
keys include run identities.
|
|
"""
|
|
detail = f"{type(error).__name__}: {error}"
|
|
if DEFERRALS.get(context) == detail:
|
|
return
|
|
if len(DEFERRALS) >= MAX_DEFERRALS:
|
|
DEFERRALS.clear()
|
|
DEFERRALS[context] = detail
|
|
print(f"pool work deferred for {context}: {detail}", file=sys.stderr, flush=True)
|
|
|
|
|
|
def _settled(context: str) -> None:
|
|
DEFERRALS.pop(context, None)
|
|
|
|
|
|
def _context(record: dict[str, Any]) -> str:
|
|
return f"{record['board']}/{record['task_id']}#{record['run_id']}"
|
|
|
|
|
|
def _block_exact(
|
|
kanban_db: Any,
|
|
board: str,
|
|
task_id: str,
|
|
run_id: int | None,
|
|
reason: str,
|
|
*,
|
|
kind: str = "capability",
|
|
) -> None:
|
|
"""Park exactly one run, never letting the write itself escape."""
|
|
try:
|
|
with kanban_db.scoped_current_board(board):
|
|
connection = kanban_db.connect(board=board)
|
|
try:
|
|
kanban_db.block_task(
|
|
connection, task_id, reason=reason, kind=kind,
|
|
expected_run_id=run_id,
|
|
)
|
|
finally:
|
|
connection.close()
|
|
except Exception as error: # noqa: BLE001 - a failed park must not wedge the pass
|
|
_defer(f"{board}/{task_id} block", error)
|
|
|
|
|
|
def recover_results(pool: Any) -> None:
|
|
"""Re-apply each durable result in isolation from its neighbours."""
|
|
for record in pool.store.pending_results():
|
|
try:
|
|
pool.finalize(record)
|
|
_settled(_context(record))
|
|
except Exception as error: # noqa: BLE001 - one bad result must not block the rest
|
|
print(
|
|
f"result recovery deferred: {type(error).__name__}: {error}",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
|
|
|
|
def _lease_failure_state(
|
|
kanban_db: Any, connection: Any, binding: dict[str, Any], run_id: int
|
|
) -> str:
|
|
"""Classify one exhausted lease from authoritative Kanban evidence only."""
|
|
task = kanban_db.get_task(connection, binding["task_id"])
|
|
current = (
|
|
canonical_run_id(_task_value(task, "current_run_id", None))
|
|
if task is not None
|
|
else None
|
|
)
|
|
if current != run_id:
|
|
return "stale"
|
|
if str(_task_value(task, "status", "") or "") != "running":
|
|
return "finalized"
|
|
if kanban_db.block_task(
|
|
connection,
|
|
binding["task_id"],
|
|
reason=(
|
|
"Distributed worker lease expired after "
|
|
f"{binding['attempt']} fenced attempts"
|
|
),
|
|
kind="transient",
|
|
expected_run_id=run_id,
|
|
):
|
|
return "finalized"
|
|
# Kanban refused the exact-run park yet still reports the run as running, so
|
|
# there is no terminal evidence. Keep the row for the next pass to retry.
|
|
raise ProtocolError("Kanban refused the exact-run lease park")
|
|
|
|
|
|
def record_lease_failure(pool: Any, record: dict[str, Any]) -> None:
|
|
"""Record one exhausted lease against its exact Kanban run, idempotently.
|
|
|
|
The row stays ``lease_failed`` unless this pass obtains authoritative
|
|
evidence: the run moved on or vanished (``stale``), Kanban already parked it
|
|
(``finalized``), or this call parked it (``finalized``). Anything else --
|
|
including a refused write or an unreadable board -- leaves the row for the
|
|
next pass, so no run is ever silently dropped.
|
|
"""
|
|
from hermes_cli import kanban_db
|
|
|
|
binding = pool._binding(record)
|
|
run_id = canonical_run_id(binding["run_id"])
|
|
if run_id is None:
|
|
pool.store.finalize(binding, "stale")
|
|
return
|
|
with pool._kanban_lock, kanban_db.scoped_current_board(binding["board"]):
|
|
connection = kanban_db.connect(board=binding["board"])
|
|
try:
|
|
state = _lease_failure_state(kanban_db, connection, binding, run_id)
|
|
finally:
|
|
connection.close()
|
|
pool.store.finalize(binding, state)
|
|
_settled(_context(record))
|
|
|
|
|
|
def recover_lease_failures(pool: Any) -> None:
|
|
"""Retry every released-but-unrecorded lease failure, each in isolation."""
|
|
for record in pool.store.failed_leases():
|
|
try:
|
|
record_lease_failure(pool, record)
|
|
except Exception as error: # noqa: BLE001 - retried on the next pass
|
|
_defer(f"{_context(record)} lease park", error)
|
|
|
|
|
|
def expire_leases(pool: Any) -> None:
|
|
"""Fence dead attempts, then drain every unconfirmed terminal record."""
|
|
pool.store.expire_leases()
|
|
recover_lease_failures(pool)
|
|
|
|
|
|
def _release_moved_run(pool: Any, kanban_db: Any, record: dict[str, Any]) -> None:
|
|
"""Release one durable assignment whose Kanban run is no longer ours."""
|
|
with kanban_db.scoped_current_board(record["board"]):
|
|
connection = kanban_db.connect(board=record["board"])
|
|
try:
|
|
task = kanban_db.get_task(connection, record["task_id"])
|
|
current_run_id = (
|
|
canonical_run_id(_task_value(task, "current_run_id", None))
|
|
if task
|
|
else None
|
|
)
|
|
current = str(current_run_id or "")
|
|
status = str(_task_value(task, "status", "") or "") if task else ""
|
|
finally:
|
|
connection.close()
|
|
if current != record["run_id"] or status != "running":
|
|
pool.store.finalize(record, "stale")
|
|
|
|
|
|
def _adopt_task(
|
|
pool: Any,
|
|
kanban_db: Any,
|
|
connection: Any,
|
|
board: str,
|
|
task: Any,
|
|
ordinals: list[int],
|
|
known: set[tuple[str, str, str]],
|
|
) -> None:
|
|
"""Adopt one orphaned pool-eligible run onto a free ordinal."""
|
|
task_id = str(_task_value(task, "id", "") or "")
|
|
raw_run_id = canonical_run_id(_task_value(task, "current_run_id", None))
|
|
run_id = str(raw_run_id or "")
|
|
assignee = str(_task_value(task, "assignee", "") or "")
|
|
if (
|
|
not task_id
|
|
or not run_id
|
|
or str(_task_value(task, "status", "")) != "running"
|
|
or not assignee.startswith("cli-")
|
|
or not distributed_workspace_eligible(task)
|
|
or (board, task_id, run_id) in known
|
|
):
|
|
return
|
|
try:
|
|
payload = pool.assignment_payload(kanban_db, connection, task, board)
|
|
except RECOVERABLE_BOARD_ERRORS as error:
|
|
_defer(f"{board}/{task_id} recovery payload", error)
|
|
return
|
|
except Exception as error: # noqa: BLE001 - a real capability failure of this run
|
|
_block_exact(
|
|
kanban_db, board, task_id, raw_run_id,
|
|
"Distributed assignment recovery failed: "
|
|
f"{type(error).__name__}: {error}",
|
|
)
|
|
return
|
|
binding = {
|
|
"board": board, "task_id": task_id, "run_id": run_id,
|
|
"worker_ordinal": ordinals[0], "attempt": 1,
|
|
}
|
|
try:
|
|
pool.store.add(binding, payload)
|
|
except ProtocolError as error:
|
|
# A durable row already owns this identity or ordinal. Leave the ordinal
|
|
# free and leave Kanban untouched; a later pass revisits it.
|
|
_defer(f"{board}/{task_id} adoption", error)
|
|
return
|
|
known.add((board, task_id, run_id))
|
|
ordinals.pop(0)
|
|
|
|
|
|
def _adopt_board(
|
|
pool: Any,
|
|
kanban_db: Any,
|
|
board: str,
|
|
ordinals: list[int],
|
|
known: set[tuple[str, str, str]],
|
|
) -> None:
|
|
"""Adopt this board's orphaned runs without touching any other board."""
|
|
with kanban_db.scoped_current_board(board):
|
|
connection = kanban_db.connect(board=board)
|
|
try:
|
|
for task in kanban_db.list_tasks(connection):
|
|
if not ordinals:
|
|
return
|
|
_adopt_task(pool, kanban_db, connection, board, task, ordinals, known)
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def reconcile(pool: Any) -> None:
|
|
"""Recover the narrow claim/assignment crash gaps without double execution."""
|
|
from hermes_cli import kanban_db
|
|
|
|
for record in pool.store.active_assignments():
|
|
try:
|
|
_release_moved_run(pool, kanban_db, record)
|
|
except Exception as error: # noqa: BLE001 - other assignments still reconcile
|
|
_defer(f"{_context(record)} release", error)
|
|
ordinals = pool.store.available_ordinals()
|
|
if not ordinals:
|
|
return
|
|
known = pool.store.known_runs()
|
|
try:
|
|
boards = kanban_db.list_boards(include_archived=False)
|
|
except RECOVERABLE_BOARD_ERRORS as error:
|
|
_defer("board-registry", error)
|
|
return
|
|
_settled("board-registry")
|
|
for raw_board in boards:
|
|
board = cli_lane_dispatch._board_slug(raw_board)
|
|
if not board or not ordinals:
|
|
continue
|
|
try:
|
|
_adopt_board(pool, kanban_db, board, ordinals, known)
|
|
_settled(f"{board} adoption")
|
|
except Exception as error: # noqa: BLE001 - later boards must still run
|
|
_defer(f"{board} adoption", error)
|
|
|
|
|
|
def _materialize(
|
|
pool: Any,
|
|
kanban_db: Any,
|
|
board: str,
|
|
task_id: str,
|
|
ordinal: int,
|
|
known: set[tuple[str, str, str]],
|
|
) -> None:
|
|
"""Turn one freshly claimed run into exactly one durable assignment."""
|
|
database_run_id: int | None = None
|
|
try:
|
|
with kanban_db.scoped_current_board(board):
|
|
connection = kanban_db.connect(board=board)
|
|
try:
|
|
task = kanban_db.get_task(connection, task_id)
|
|
if task is None:
|
|
return
|
|
raw_run = canonical_run_id(
|
|
_task_value(task, "current_run_id", None)
|
|
)
|
|
if not distributed_workspace_eligible(task):
|
|
if raw_run is not None:
|
|
kanban_db.block_task(
|
|
connection, task_id,
|
|
reason=(
|
|
"Distributed claim fenced because an existing "
|
|
"workspace is owned by the local lane"
|
|
),
|
|
kind="capability", expected_run_id=raw_run,
|
|
)
|
|
return
|
|
if raw_run is None:
|
|
raise ProjectPolicyError("claimed task has no canonical run ID")
|
|
database_run_id = raw_run
|
|
payload = pool.assignment_payload(kanban_db, connection, task, board)
|
|
finally:
|
|
connection.close()
|
|
run_id = str(database_run_id)
|
|
if (board, task_id, run_id) in known:
|
|
return
|
|
pool.store.add(
|
|
{
|
|
"board": board, "task_id": task_id, "run_id": run_id,
|
|
"worker_ordinal": ordinal, "attempt": 1,
|
|
},
|
|
payload,
|
|
)
|
|
known.add((board, task_id, run_id))
|
|
_settled(f"{board}/{task_id} dispatch")
|
|
except (ProtocolError, *RECOVERABLE_BOARD_ERRORS) as error:
|
|
# Coordinator-side storage/identity conditions. Never convert these into
|
|
# a Kanban capability block; reconcile() adopts the claim on a later pass.
|
|
_defer(f"{board}/{task_id} dispatch", error)
|
|
except Exception as error: # noqa: BLE001 - a real capability failure of this run
|
|
_block_exact(
|
|
kanban_db, board, task_id, database_run_id,
|
|
"Distributed assignment preparation failed: "
|
|
f"{type(error).__name__}: {error}",
|
|
)
|
|
|
|
|
|
def dispatch(pool: Any) -> None:
|
|
"""Claim up to the free ordinal count and materialize assignments."""
|
|
from hermes_cli import kanban_db
|
|
|
|
ordinals = pool.store.available_ordinals()
|
|
if not ordinals:
|
|
return
|
|
try:
|
|
# Only storage faults are absorbed here. An incompatible claim API must
|
|
# still surface, because silently skipping the eligibility predicate is
|
|
# how the pool would start claiming the local lane's owned workspaces.
|
|
claimed = cli_lane_dispatch.claim_ready(
|
|
set(), len(ordinals),
|
|
lambda _board, task: distributed_workspace_eligible(task),
|
|
)
|
|
except RECOVERABLE_BOARD_ERRORS as error:
|
|
_defer("claim-ready", error)
|
|
return
|
|
_settled("claim-ready")
|
|
known = pool.store.known_runs()
|
|
for ordinal, (board, task_id) in zip(ordinals, claimed, strict=False):
|
|
_materialize(pool, kanban_db, board, task_id, ordinal, known)
|