feat(kanban): stale detection for running tasks in dispatcher
Salvages #23790 by @thewillhuang. Adds detect_stale_running() to the dispatcher cycle. Running tasks that have been started for longer than dispatch_stale_timeout_seconds (default 14400 = 4h) without a heartbeat in the last hour are auto-reclaimed to ready. - New config kanban.dispatch_stale_timeout_seconds (default 14400, 0 disables) - New 'stale' field on DispatchResult - detect_stale_running() in kanban_db.py with heartbeat freshness check - Records outcome='stale' on run close + 'stale' event; ticks failure counter - Wires config through gateway embedded dispatcher - Updates _cmd_dispatch verbose/JSON output and daemon logging Resolved test-file end-of-file conflict by appending both halves.
This commit is contained in:
@@ -4344,3 +4344,66 @@ def test_reclaim_task_clears_failure_counter(kanban_home):
|
||||
assert task.status == "ready"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_dispatch_once_integrates_stale_detection(kanban_home, monkeypatch):
|
||||
"""dispatch_once with stale_timeout_seconds reclaims stale running tasks."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="stale-dispatch", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
kb._set_worker_pid(conn, t, 99999) # fake PID — avoid killing test
|
||||
|
||||
five_hours_ago = int(time.time()) - (5 * 3600)
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(five_hours_ago, t),
|
||||
)
|
||||
|
||||
res = kb.dispatch_once(
|
||||
conn,
|
||||
spawn_fn=lambda tsk, ws: None,
|
||||
stale_timeout_seconds=14400,
|
||||
)
|
||||
assert t in res.stale, "Stale task should appear in result.stale"
|
||||
assert kb.get_task(conn, t).status == "ready"
|
||||
|
||||
|
||||
def test_dispatch_once_stale_disabled_when_timeout_zero(kanban_home, monkeypatch):
|
||||
"""dispatch_once with stale_timeout_seconds=0 skips stale detection."""
|
||||
# Use os.getpid() so _pid_alive → True, preventing detect_crashed_workers
|
||||
# from reclaiming. Only stale detection (disabled via timeout=0) is tested.
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="skip-stale", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
# Claim sets worker_pid to 0 initially. Set it to os.getpid() so the
|
||||
# crash detector sees a live PID and skips it.
|
||||
kb._set_worker_pid(conn, t, os.getpid())
|
||||
|
||||
five_hours_ago = int(time.time()) - (5 * 3600)
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(five_hours_ago, t),
|
||||
)
|
||||
|
||||
res = kb.dispatch_once(
|
||||
conn,
|
||||
spawn_fn=lambda tsk, ws: None,
|
||||
stale_timeout_seconds=0,
|
||||
)
|
||||
assert res.stale == [], "stale_timeout_seconds=0 should disable detection"
|
||||
assert kb.get_task(conn, t).status == "running"
|
||||
|
||||
@@ -2232,3 +2232,187 @@ def test_dispatch_review_does_not_claim_ready_tasks(
|
||||
# claim_review_task should NOT claim a ready task
|
||||
claimed = kb.claim_review_task(conn, t)
|
||||
assert claimed is None
|
||||
|
||||
# Stale detection — detect_stale_running
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detect_stale_returns_running_task_with_no_heartbeat(kanban_home, monkeypatch):
|
||||
"""A task running > timeout with zero heartbeats gets reclaimed as stale."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="stale-no-hb", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
kb._set_worker_pid(conn, t, os.getpid())
|
||||
|
||||
# Rewind started_at so the task appears to have been running for 5 hours.
|
||||
five_hours_ago = int(time.time()) - (5 * 3600)
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(five_hours_ago, t),
|
||||
)
|
||||
# No heartbeat set — last_heartbeat_at stays NULL.
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
killed = []
|
||||
stale = kb.detect_stale_running(
|
||||
conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: killed.append(s),
|
||||
)
|
||||
assert t in stale, "Task with no heartbeat for >4h should be reclaimed"
|
||||
task = kb.get_task(conn, t)
|
||||
assert task.status == "ready"
|
||||
|
||||
|
||||
def test_detect_stale_returns_task_with_stale_heartbeat(kanban_home, monkeypatch):
|
||||
"""A task running > timeout with a heartbeat older than 1h gets reclaimed."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="stale-hb", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
kb._set_worker_pid(conn, t, os.getpid())
|
||||
|
||||
five_hours_ago = int(time.time()) - (5 * 3600)
|
||||
heartbeat_2h_ago = int(time.time()) - (2 * 3600)
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ?, last_heartbeat_at = ? "
|
||||
"WHERE id = ?",
|
||||
(five_hours_ago, heartbeat_2h_ago, t),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(five_hours_ago, t),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
stale = kb.detect_stale_running(
|
||||
conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
|
||||
)
|
||||
assert t in stale, (
|
||||
"Task with heartbeat >1h old and started >4h ago should be stale"
|
||||
)
|
||||
assert kb.get_task(conn, t).status == "ready"
|
||||
|
||||
|
||||
def test_detect_stale_skips_task_with_recent_heartbeat(kanban_home, monkeypatch):
|
||||
"""A task running > timeout but with a recent heartbeat is NOT reclaimed."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="alive-hb", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
kb._set_worker_pid(conn, t, os.getpid())
|
||||
|
||||
five_hours_ago = int(time.time()) - (5 * 3600)
|
||||
heartbeat_now = int(time.time()) # heartbeat just happened
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ?, last_heartbeat_at = ? "
|
||||
"WHERE id = ?",
|
||||
(five_hours_ago, heartbeat_now, t),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(five_hours_ago, t),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
|
||||
stale = kb.detect_stale_running(
|
||||
conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
|
||||
)
|
||||
assert stale == [], "Task with recent heartbeat should not be reclaimed"
|
||||
assert kb.get_task(conn, t).status == "running"
|
||||
|
||||
|
||||
def test_detect_stale_skips_recently_started_task(kanban_home, monkeypatch):
|
||||
"""A task started < timeout ago is NOT reclaimed even with no heartbeat."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="fresh", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
kb._set_worker_pid(conn, t, os.getpid())
|
||||
|
||||
# Started only 1 hour ago — well within the 4h threshold.
|
||||
one_hour_ago = int(time.time()) - 3600
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ? WHERE id = ?", (one_hour_ago, t)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(one_hour_ago, t),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
|
||||
stale = kb.detect_stale_running(
|
||||
conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
|
||||
)
|
||||
assert stale == [], "Task started <4h ago should not be reclaimed"
|
||||
assert kb.get_task(conn, t).status == "running"
|
||||
|
||||
|
||||
def test_detect_stale_skips_when_timeout_zero(kanban_home, monkeypatch):
|
||||
"""stale_timeout_seconds=0 disables stale detection entirely."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="disabled", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
kb._set_worker_pid(conn, t, os.getpid())
|
||||
|
||||
five_hours_ago = int(time.time()) - (5 * 3600)
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(five_hours_ago, t),
|
||||
)
|
||||
|
||||
stale = kb.detect_stale_running(
|
||||
conn, stale_timeout_seconds=0, signal_fn=lambda p, s: None,
|
||||
)
|
||||
assert stale == [], "timeout=0 should disable stale detection"
|
||||
assert kb.get_task(conn, t).status == "running"
|
||||
|
||||
|
||||
def test_detect_stale_skips_blocked_tasks(kanban_home, monkeypatch):
|
||||
"""Blocked tasks are NOT reclaimed by stale detection."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="blocked-task", assignee="worker")
|
||||
kb.claim_task(conn, t)
|
||||
kb._set_worker_pid(conn, t, os.getpid())
|
||||
|
||||
five_hours_ago = int(time.time()) - (5 * 3600)
|
||||
with kb.write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET started_at = ? "
|
||||
"WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
|
||||
(five_hours_ago, t),
|
||||
)
|
||||
# Block the task explicitly.
|
||||
kb.block_task(conn, t, reason="human requested block")
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
stale = kb.detect_stale_running(
|
||||
conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
|
||||
)
|
||||
assert stale == [], "Blocked task should not be reclaimed by stale detection"
|
||||
assert kb.get_task(conn, t).status == "blocked"
|
||||
|
||||
Reference in New Issue
Block a user