Skip to content
Merged
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
25 changes: 24 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3645,6 +3645,24 @@ async def _kanban_dispatcher_watcher(self) -> None:
if max_spawn is not None:
logger.info(f"kanban dispatcher: max_spawn={max_spawn}")

raw_failure_limit = kanban_cfg.get("failure_limit", _kb.DEFAULT_FAILURE_LIMIT)
try:
failure_limit = int(raw_failure_limit)
except (TypeError, ValueError):
logger.warning(
"kanban dispatcher: invalid kanban.failure_limit=%r; using default %d",
raw_failure_limit,
_kb.DEFAULT_FAILURE_LIMIT,
)
failure_limit = _kb.DEFAULT_FAILURE_LIMIT
if failure_limit < 1:
logger.warning(
"kanban dispatcher: kanban.failure_limit=%r is below 1; using default %d",
raw_failure_limit,
_kb.DEFAULT_FAILURE_LIMIT,
)
failure_limit = _kb.DEFAULT_FAILURE_LIMIT

# Initial delay so the gateway finishes wiring adapters before the
# dispatcher spawns workers (those workers may hit gateway notify
# subscriptions etc.). Matches the notifier watcher's delay.
Expand Down Expand Up @@ -3673,7 +3691,12 @@ def _tick_once_for_board(slug: str) -> "Optional[object]":
_kb.init_db(board=slug) # idempotent, handles first-run
except Exception:
pass
return _kb.dispatch_once(conn, board=slug, max_spawn=max_spawn)
return _kb.dispatch_once(
conn,
board=slug,
max_spawn=max_spawn,
failure_limit=failure_limit,
)
except Exception:
logger.exception("kanban dispatcher: tick failed on board %s", slug)
return None
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,10 @@ def _ensure_hermes_home_managed(home: Path):
# Seconds between dispatcher ticks (idle or not). Lower = snappier
# pickup of newly-ready tasks; higher = less SQL pressure.
"dispatch_interval_seconds": 60,
# Auto-block after this many consecutive non-success attempts for the
# same task/profile (spawn_failed, timed_out, or crashed). Reassignment
# resets the streak for the new profile.
"failure_limit": 2,
},

# execute_code settings — controls the tool used for programmatic tool calls.
Expand Down
5 changes: 3 additions & 2 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,8 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
help="Cap number of spawns this pass")
p_disp.add_argument("--failure-limit", type=int,
default=kb.DEFAULT_SPAWN_FAILURE_LIMIT,
help=f"Auto-block a task after this many consecutive spawn failures "
f"(default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})")
help=f"Auto-block a task after this many consecutive non-success attempts "
f"(spawn_failed, timed_out, or crashed; default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})")
p_disp.add_argument("--json", action="store_true")

# --- daemon (deprecated) ---
Expand Down Expand Up @@ -1657,6 +1657,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
" kanban:\n"
" dispatch_in_gateway: true # default\n"
" dispatch_interval_seconds: 60\n"
" failure_limit: 2 # consecutive non-success attempts before auto-block\n"
"\n"
"Running both the gateway AND this standalone daemon will\n"
"race for claims. If you truly need the old standalone\n"
Expand Down
181 changes: 142 additions & 39 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str])
profile = _canonical_assignee(profile)
with write_txn(conn):
row = conn.execute(
"SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,)
"SELECT status, claim_lock, assignee FROM tasks WHERE id = ?", (task_id,)
).fetchone()
if not row:
return False
Expand All @@ -1389,7 +1389,17 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str])
f"cannot reassign {task_id}: currently running (claimed). "
"Wait for completion or reclaim the stale lock first."
)
conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id))
if row["assignee"] != profile:
# The retry guard is scoped to the task/profile combination. A
# human reassigning the task is an explicit recovery action, so the
# new profile should not inherit the previous profile's streak.
conn.execute(
"UPDATE tasks SET assignee = ?, consecutive_failures = 0, "
"last_failure_error = NULL WHERE id = ?",
(profile, task_id),
)
else:
conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id))
_append_event(conn, task_id, "assigned", {"assignee": profile})
return True

Expand Down Expand Up @@ -1859,34 +1869,47 @@ def heartbeat_claim(
return False


def release_stale_claims(conn: sqlite3.Connection) -> int:
def release_stale_claims(
conn: sqlite3.Connection,
*,
signal_fn=None,
) -> int:
"""Reset any ``running`` task whose claim has expired.

Returns the number of stale claims reclaimed. Safe to call often.
"""
now = int(time.time())
reclaimed = 0
with write_txn(conn):
stale = conn.execute(
"SELECT id, claim_lock FROM tasks "
"WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?",
(now,),
).fetchall()
for row in stale:
conn.execute(
stale = conn.execute(
"SELECT id, claim_lock, worker_pid FROM tasks "
"WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?",
(now,),
).fetchall()
for row in stale:
termination = _terminate_reclaimed_worker(
row["worker_pid"], row["claim_lock"], signal_fn=signal_fn,
)
with write_txn(conn):
cur = conn.execute(
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
"claim_expires = NULL, worker_pid = NULL "
"WHERE id = ? AND status = 'running'",
(row["id"],),
"WHERE id = ? AND status = 'running' AND claim_lock IS ? "
"AND claim_expires IS NOT NULL AND claim_expires < ?",
(row["id"], row["claim_lock"], now),
)
if cur.rowcount != 1:
continue
run_id = _end_run(
conn, row["id"],
outcome="reclaimed", status="reclaimed",
error=f"stale_lock={row['claim_lock']}",
metadata=termination,
)
payload = {"stale_lock": row["claim_lock"]}
payload.update(termination)
_append_event(
conn, row["id"], "reclaimed",
{"stale_lock": row["claim_lock"]},
payload,
run_id=run_id,
)
reclaimed += 1
Expand All @@ -1898,6 +1921,7 @@ def reclaim_task(
task_id: str,
*,
reason: Optional[str] = None,
signal_fn=None,
) -> bool:
"""Operator-driven reclaim: release the claim and reset to ``ready``.

Expand All @@ -1910,40 +1934,47 @@ def reclaim_task(
Returns True if a reclaim happened, False if the task isn't in a
reclaimable state (not running, or doesn't exist).
"""
row = conn.execute(
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return False
if row["status"] != "running" and row["claim_lock"] is None:
# Nothing to reclaim — already ready / blocked / done.
return False
prev_lock = row["claim_lock"]
termination = _terminate_reclaimed_worker(
row["worker_pid"], prev_lock, signal_fn=signal_fn,
)
with write_txn(conn):
row = conn.execute(
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return False
if row["status"] != "running" and row["claim_lock"] is None:
# Nothing to reclaim — already ready / blocked / done.
return False
prev_lock = row["claim_lock"]
prev_pid = row["worker_pid"]
conn.execute(
cur = conn.execute(
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
"claim_expires = NULL, worker_pid = NULL "
"WHERE id = ? AND status IN ('running', 'ready', 'blocked')",
(task_id,),
"WHERE id = ? AND status IN ('running', 'ready', 'blocked') "
"AND claim_lock IS ?",
(task_id, prev_lock),
)
if cur.rowcount != 1:
return False
run_id = _end_run(
conn, task_id,
outcome="reclaimed", status="reclaimed",
error=(
f"manual_reclaim: {reason}" if reason
else f"manual_reclaim lock={prev_lock}"
),
metadata=termination,
)
payload = {
"manual": True,
"reason": reason,
"prev_lock": prev_lock,
}
payload.update(termination)
_append_event(
conn, task_id, "reclaimed",
{
"manual": True,
"reason": reason,
"prev_lock": prev_lock,
"prev_pid": prev_pid,
},
payload,
run_id=run_id,
)
# Operator intervention — they've looked at the task, so the
Expand Down Expand Up @@ -2548,11 +2579,11 @@ def set_workspace_path(
# Dispatcher (one-shot pass)
# ---------------------------------------------------------------------------

# After this many consecutive `spawn_failed` events on a task, the dispatcher
# stops retrying and parks the task in ``blocked`` with a reason so a human
# can investigate. Prevents the dispatcher from thrashing forever on a task
# whose profile doesn't exist, whose workspace is unmountable, etc.
DEFAULT_FAILURE_LIMIT = 5
# After this many consecutive non-success attempts on a task/profile, the
# dispatcher stops retrying and parks the task in ``blocked`` with a reason so
# a human can investigate. Prevents retry storms when a worker repeatedly times
# out, crashes, or cannot spawn.
DEFAULT_FAILURE_LIMIT = 2
# Legacy alias — callers / tests still reference the old name.
DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT

Expand Down Expand Up @@ -2652,6 +2683,59 @@ def _pid_alive(pid: Optional[int]) -> bool:
return True


def _terminate_reclaimed_worker(
pid: Optional[int],
claim_lock: Optional[str],
*,
signal_fn=None,
) -> dict[str, Any]:
"""Best-effort host-local worker termination for reclaim paths."""
import signal

info: dict[str, Any] = {
"prev_pid": int(pid) if pid else None,
"host_local": False,
"termination_attempted": False,
"terminated": False,
"sigkill": False,
}
if not pid or pid <= 0 or not claim_lock:
return info

host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
if not str(claim_lock).startswith(host_prefix):
return info
info["host_local"] = True

kill = signal_fn if signal_fn is not None else (
os.kill if hasattr(os, "kill") else None
)
if kill is None:
return info

info["termination_attempted"] = True
try:
kill(int(pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
return info

for _ in range(10):
if not _pid_alive(pid):
info["terminated"] = True
return info
time.sleep(0.5)

if _pid_alive(pid):
try:
kill(int(pid), signal.SIGKILL)
info["sigkill"] = True
except (ProcessLookupError, OSError):
return info

info["terminated"] = not _pid_alive(pid)
return info


def heartbeat_worker(
conn: sqlite3.Connection,
task_id: str,
Expand Down Expand Up @@ -3150,6 +3234,25 @@ def dispatch_once(
``board`` pins workspace/log/db resolution for this tick to a specific
board. When omitted, the current-board resolution chain is used.
"""
# Reap zombie children from previously spawned workers.
# The gateway-embedded dispatcher is the parent of every worker spawned
# via _default_spawn (start_new_session=True only detaches the
# controlling tty, not the parent). Without an explicit waitpid, each
# completed worker becomes a <defunct> entry that lingers until gateway
# exit. WNOHANG keeps this non-blocking; ChildProcessError means no
# children to reap. Bounded: at most one tick's worth of completions
# can be in <defunct> at once.
try:
while True:
try:
_pid, _status = os.waitpid(-1, os.WNOHANG)
except ChildProcessError:
break
if _pid == 0:
break
except Exception:
pass

result = DispatchResult()
result.reclaimed = release_stale_claims(conn)
result.crashed = detect_crashed_workers(conn)
Expand Down
6 changes: 6 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@
"wysie@users.noreply.github.com": "wysie",
"jkausel@gmail.com": "jkausel-ai",
"e.silacandmr@gmail.com": "Es1la",
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"zjtan1@gmail.com": "zeejaytan",
"asslaenn5@gmail.com": "Aslaaen",
Expand Down Expand Up @@ -439,6 +442,9 @@
"xowiekk@gmail.com": "Xowiek",
"1243352777@qq.com": "zons-zhaozhy",
"e.silacandmr@gmail.com": "Es1la",
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
"h3057183414@gmail.com": "CoreyNoDream",
"franksong2702@gmail.com": "franksong2702",
"673088860@qq.com": "ambition0802",
Expand Down
Loading
Loading