481 lines
19 KiB
Python
481 lines
19 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
|
|
import supervisor_state
|
|
|
|
|
|
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 _resume_ordinal(payload: Any) -> int | None:
|
|
"""Read the coordinator-issued source ordinal without accepting task text."""
|
|
receipt = payload.get("scm_resume") if isinstance(payload, dict) else None
|
|
if receipt is None:
|
|
return None
|
|
source = receipt.get("source") if isinstance(receipt, dict) else None
|
|
ordinal = source.get("worker_ordinal") if isinstance(source, dict) else None
|
|
if isinstance(ordinal, bool) or not isinstance(ordinal, int) or ordinal not in range(3):
|
|
raise ProtocolError("publication retry ordinal is invalid")
|
|
return ordinal
|
|
|
|
|
|
def _issue_retry(record: dict[str, Any]) -> None:
|
|
"""Fence a persisted receipt to its one fresh durable assignment."""
|
|
if _resume_ordinal(record.get("payload")) is not None:
|
|
supervisor_state.issue_publication_retry(
|
|
record["board"], record["task_id"], record["run_id"]
|
|
)
|
|
|
|
|
|
def _pending_retry_ordinal(board: str, task: Any) -> int | None:
|
|
"""Look up an unissued receipt before the ready card receives its fresh run."""
|
|
task_id = str(_task_value(task, "id", "") or "")
|
|
if not task_id:
|
|
return None
|
|
receipt = supervisor_state.publication_retry(board, task_id, "")
|
|
return _resume_ordinal({"scm_resume": receipt}) if receipt is not None else None
|
|
|
|
|
|
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, retry_exhausted: bool = False,
|
|
retry_receipt: Any = None,
|
|
) -> 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
|
|
)
|
|
status = str(_task_value(task, "status", "") or "")
|
|
if current != run_id:
|
|
# Native block_task clears current_run_id. Only a receipt-bearing run
|
|
# may reconstruct that exact coordinator fence; every other moved run
|
|
# remains stale rather than treating a human block as pool-owned.
|
|
if current is None and status == "blocked" and retry_receipt is not None and not retry_exhausted:
|
|
return "finalized"
|
|
return "stale"
|
|
if status != "running":
|
|
return "finalized"
|
|
from publication_retry_fence import block as block_with_fence
|
|
if block_with_fence(
|
|
kanban_db, connection, binding["board"], binding["task_id"], str(run_id),
|
|
"Publication retry budget exhausted; inspect the retained SCM evidence." if retry_exhausted else
|
|
f"Distributed worker lease expired after {binding['attempt']} fenced attempts",
|
|
"capability" if retry_exhausted else "transient", retry_receipt if not retry_exhausted else None,
|
|
):
|
|
return "fenced" if retry_receipt is not None and not retry_exhausted else "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
|
|
retry_exhausted = False
|
|
retry_receipt = None
|
|
if _resume_ordinal(record.get("payload")) is not None:
|
|
retry_exhausted = not supervisor_state.reissue_publication_retry(
|
|
binding["board"], binding["task_id"], binding["run_id"]
|
|
)
|
|
retry_receipt = record.get("payload", {}).get("scm_resume")
|
|
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, retry_exhausted, retry_receipt)
|
|
if retry_receipt is not None and not retry_exhausted and state in {"fenced", "finalized"}:
|
|
# A crash after native block but before the sidecar write reaches
|
|
# here with ``finalized``. Reconstruct only the exact marker.
|
|
from publication_retry_fence import recover
|
|
fence = recover(binding["board"], binding["task_id"], str(run_id), connection, retry_receipt)
|
|
if fence is None or not pool.store.record_publication_lease_failure(binding, retry_receipt, fence[1]):
|
|
raise ProtocolError("publication lease fence cannot be recovered")
|
|
state = "finalized"
|
|
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
|
|
ordinal = _resume_ordinal(payload)
|
|
if ordinal is not None:
|
|
if ordinal not in ordinals:
|
|
_defer(f"{board}/{task_id} recovery retry", ProtocolError("source ordinal is unavailable"))
|
|
return
|
|
else:
|
|
ordinal = ordinals[0]
|
|
binding = {
|
|
"board": board, "task_id": task_id, "run_id": run_id,
|
|
"worker_ordinal": ordinal, "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))
|
|
_issue_retry({**binding, "payload": payload})
|
|
ordinals.remove(ordinal)
|
|
|
|
|
|
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:
|
|
_issue_retry(record)
|
|
_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")
|
|
board_names = [name for raw in boards if (name := cli_lane_dispatch._board_slug(raw))]
|
|
from publication_retry_scheduler import reopen_due
|
|
try:
|
|
reopen_due(pool, kanban_db, board_names)
|
|
except Exception as error: # noqa: BLE001 - dispatch still serves unrelated ready work
|
|
_defer("publication-retry", error)
|
|
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)
|
|
expected_ordinal = _resume_ordinal(payload)
|
|
if expected_ordinal is not None and expected_ordinal != ordinal:
|
|
raise ProtocolError("publication retry was not assigned to its source ordinal")
|
|
if (board, task_id, run_id) in known:
|
|
return
|
|
added = pool.store.add(
|
|
{
|
|
"board": board, "task_id": task_id, "run_id": run_id,
|
|
"worker_ordinal": ordinal, "attempt": 1,
|
|
},
|
|
payload,
|
|
)
|
|
if added:
|
|
_issue_retry({
|
|
"board": board, "task_id": task_id, "run_id": run_id, "payload": 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
|
|
retry_ordinals: dict[tuple[str, str], int | None] = {}
|
|
|
|
def retry_ordinal(board: str, task: Any) -> int | None:
|
|
key = (board, str(_task_value(task, "id", "") or ""))
|
|
if key not in retry_ordinals:
|
|
retry_ordinals[key] = _pending_retry_ordinal(board, task)
|
|
return retry_ordinals[key]
|
|
|
|
def eligible(board: str, task: Any) -> bool:
|
|
if not distributed_workspace_eligible(task):
|
|
return False
|
|
try:
|
|
required = retry_ordinal(board, task)
|
|
except (OSError, ValueError) as error:
|
|
_defer(f"{board}/{_task_value(task, 'id', '')} retry lookup", error)
|
|
return False
|
|
return required is None or required in ordinals
|
|
|
|
def priority(board: str, task: Any) -> int:
|
|
try:
|
|
return 0 if retry_ordinal(board, task) is not None else 1
|
|
except (OSError, ValueError) as error:
|
|
_defer(f"{board}/{_task_value(task, 'id', '')} retry lookup", error)
|
|
return 2
|
|
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),
|
|
eligible,
|
|
claimer="execution-pool",
|
|
priority=priority,
|
|
)
|
|
except RECOVERABLE_BOARD_ERRORS as error:
|
|
_defer("claim-ready", error)
|
|
return
|
|
_settled("claim-ready")
|
|
known = pool.store.known_runs()
|
|
assigned: set[int] = set()
|
|
ordered = sorted(
|
|
claimed,
|
|
key=lambda item: 0 if retry_ordinals.get(item) is not None else 1,
|
|
)
|
|
for board, task_id in ordered:
|
|
ordinal = retry_ordinals.get((board, task_id))
|
|
if ordinal is None:
|
|
ordinal = next((value for value in ordinals if value not in assigned), None)
|
|
if ordinal is None or ordinal in assigned:
|
|
_defer(f"{board}/{task_id} dispatch", ProtocolError("no safe worker ordinal is available"))
|
|
continue
|
|
_materialize(pool, kanban_db, board, task_id, ordinal, known)
|
|
assigned.add(ordinal)
|