Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/andrexibiza@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
andrexibiza
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
andrexibiza
246 changes: 246 additions & 0 deletions hermes_cli/failure_accounting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"""Failure accounting for the kanban dispatcher circuit breaker.

Extracted verbatim from ``hermes_cli.kanban_db`` (wave 1, shard s4,
cluster c10 / failure_accounting). ``_record_task_failure`` is the unified
non-success bookkeeper; the old spawn-only name and the
``_clear_spawn_failures`` alias are preserved for back-compat.
"""
from __future__ import annotations

import sqlite3
from typing import Optional

# DEFAULT_FAILURE_LIMIT and the txn/run/event helpers stay in kanban_db.py;
# imported at the bottom to avoid a circular import.

def _record_task_failure(
conn: sqlite3.Connection,
task_id: str,
error: str,
*,
outcome: str,
failure_limit: int = None,
force_trip: bool = False,
release_claim: bool = False,
end_run: bool = False,
event_payload_extra: Optional[dict] = None,
) -> bool:
"""Record a non-success outcome (spawn_failed / crashed / timed_out)
and maybe trip the circuit breaker.

Unified replacement for the old spawn-only ``_record_spawn_failure``.
Every path that ends a task with a non-success outcome funnels
through here so the ``consecutive_failures`` counter and the
auto-block threshold stay consistent.

Returns True when the task was auto-blocked (counter reached
``failure_limit``), False when it was just updated in place.

Modes:

* ``release_claim=True, end_run=True`` — spawn-failure path.
Caller has a running task with an open run; this transitions
it back to ``ready`` (or ``blocked`` when the breaker trips),
releases the claim, and closes the run with ``outcome=<outcome>``.

* ``release_claim=False, end_run=False`` — timeout/crash path.
Caller has ALREADY flipped the task to ``ready`` and closed the
run with the appropriate outcome. This just increments the
counter; if the breaker trips, the task is re-transitioned
``ready → blocked`` and a ``gave_up`` event is emitted.

``event_payload_extra`` merges into the ``gave_up`` event payload
when the breaker trips, so callers can include outcome-specific
context (e.g. pid on crash, elapsed on timeout).

Resolution order for the effective threshold:
1. per-task ``max_retries`` if set (nothing else overrides)
2. caller-supplied ``failure_limit`` (gateway passes the config
value from ``kanban.failure_limit``; tests pass fixed values)
3. ``DEFAULT_FAILURE_LIMIT``

``force_trip=True`` trips the breaker unconditionally, skipping the
counter-vs-threshold comparison (the resolution order above is then
only reported in the ``gave_up`` payload, not re-evaluated). Callers
use it when they have already applied their own bounded-retry policy
— e.g. the clean-exit protocol-violation streak in
``detect_crashed_workers``, which resolves the per-task
``max_retries`` override against the violation streak itself. The
failure is still counted into ``consecutive_failures``.
"""
if failure_limit is None:
failure_limit = DEFAULT_FAILURE_LIMIT
blocked = False
with write_txn(conn):
row = conn.execute(
"SELECT consecutive_failures, status, max_retries "
"FROM tasks WHERE id = ?", (task_id,),
).fetchone()
if row is None:
return False
failures = int(row["consecutive_failures"]) + 1

# Per-task override wins over both caller-supplied and default
# thresholds. None (the common case) falls through.
task_override = (
row["max_retries"] if "max_retries" in row.keys() else None
)
if task_override is not None:
effective_limit = int(task_override)
limit_source = "task"
else:
effective_limit = int(failure_limit)
limit_source = "dispatcher"

if force_trip or failures >= effective_limit:
# Trip the breaker.
if release_claim:
# Spawn path: still running, also clear claim state.
conn.execute(
"UPDATE tasks SET status = 'blocked', claim_lock = NULL, "
"claim_expires = NULL, worker_pid = NULL, "
"consecutive_failures = ?, last_failure_error = ? "
"WHERE id = ? AND status IN ('running', 'ready')",
(failures, error[:500], task_id),
)
else:
# Timeout/crash path: task is already at ``ready``
# with claim cleared; just flip to blocked + update
# counter fields.
conn.execute(
"UPDATE tasks SET status = 'blocked', "
"consecutive_failures = ?, last_failure_error = ? "
"WHERE id = ? AND status IN ('ready', 'running')",
(failures, error[:500], task_id),
)
run_id = None
if end_run:
# Only the spawn path has an open run to close.
run_id = _end_run(
conn, task_id,
outcome="gave_up", status="gave_up",
error=error[:500],
metadata={
"failures": failures,
"trigger_outcome": outcome,
"effective_limit": effective_limit,
"limit_source": limit_source,
},
)
payload = {
"failures": failures,
"effective_limit": effective_limit,
"limit_source": limit_source,
"error": error[:500],
"trigger_outcome": outcome,
}
if event_payload_extra:
payload.update(event_payload_extra)
_append_event(
conn, task_id, "gave_up", payload, run_id=run_id,
)
blocked = True
else:
# Below threshold.
if release_claim:
# Spawn path: transition running → ready + clear claim.
conn.execute(
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
"claim_expires = NULL, worker_pid = NULL, "
"consecutive_failures = ?, last_failure_error = ? "
"WHERE id = ? AND status = 'running'",
(failures, error[:500], task_id),
)
else:
# Timeout/crash path: task is already at ``ready`` via
# its own UPDATE. Just bookkeep the counter + last error.
conn.execute(
"UPDATE tasks SET consecutive_failures = ?, "
"last_failure_error = ? WHERE id = ?",
(failures, error[:500], task_id),
)
if end_run:
# Spawn path: close the open run with outcome.
run_id = _end_run(
conn, task_id,
outcome=outcome, status=outcome,
error=error[:500],
metadata={"failures": failures},
)
_append_event(
conn, task_id, outcome,
{"error": error[:500], "failures": failures},
run_id=run_id,
)
# Timeout/crash path's caller already emitted its own event.
return blocked


# Backward-compat alias. Old name is referenced from tests and possibly
# third-party callers. New code should call ``_record_task_failure``.
def _record_spawn_failure(
conn: sqlite3.Connection,
task_id: str,
error: str,
*,
failure_limit: int = None,
) -> bool:
return _record_task_failure(
conn, task_id, error,
outcome="spawn_failed",
failure_limit=failure_limit,
release_claim=True,
end_run=True,
)


def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None:
"""Record the spawned child's pid + emit a ``spawned`` event.

The event's payload carries the pid so a human reading ``hermes kanban
tail`` can correlate log lines with OS-level traces without opening
the drawer.
"""
with write_txn(conn):
conn.execute(
"UPDATE tasks SET worker_pid = ? WHERE id = ?",
(int(pid), task_id),
)
run_id = _current_run_id(conn, task_id)
if run_id is not None:
conn.execute(
"UPDATE task_runs SET worker_pid = ? WHERE id = ?",
(int(pid), run_id),
)
_append_event(conn, task_id, "spawned", {"pid": int(pid)}, run_id=run_id)


def _clear_failure_counter(conn: sqlite3.Connection, task_id: str) -> None:
"""Reset the unified consecutive-failures counter.

Called from ``complete_task`` on successful completion — a fresh
success means the task + profile combination is working and any
past failures are history. NOT called on spawn success anymore:
a successful spawn proves the worker could start but says nothing
about whether the run will succeed, so we need to let timeouts and
crashes accumulate across spawn boundaries.
"""
with write_txn(conn):
conn.execute(
"UPDATE tasks SET consecutive_failures = 0, "
"last_failure_error = NULL WHERE id = ?",
(task_id,),
)


# Legacy alias for test-code and anything else that still imports it.
_clear_spawn_failures = _clear_failure_counter


from hermes_cli.kanban_db import ( # noqa: E402
DEFAULT_FAILURE_LIMIT,
_append_event,
_current_run_id,
_end_run,
write_txn,
)
Loading
Loading