Skip to content
13 changes: 13 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5691,6 +5691,19 @@ def _auto_decompose_tick() -> int:
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
)
while self._running:
try:
# Reap zombie children before per-board work so a board DB
# failure cannot block cleanup of unrelated workers.
pids = await asyncio.to_thread(_kb.reap_worker_zombies)
if pids:
logger.info(
"kanban dispatcher: reaped %d zombie worker(s), pids=%s",
len(pids),
pids,
)
except Exception:
logger.exception("kanban dispatcher: zombie reaper failed")

try:
if auto_decompose_enabled:
await asyncio.to_thread(_auto_decompose_tick)
Expand Down
162 changes: 127 additions & 35 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,34 @@ def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int:
return DEFAULT_CLAIM_TTL_SECONDS


# Grace period after a task transitions to ``running`` during which
# ``detect_crashed_workers`` skips the ``_pid_alive`` check. Covers the
# fork() → /proc-visibility window where liveness can transiently report
# False for a freshly-spawned worker. The 15-minute claim TTL still
# catches genuinely-crashed workers; this only suppresses false positives
# during the launch window.
DEFAULT_CRASH_GRACE_SECONDS = 30


def _resolve_crash_grace_seconds() -> int:
"""Return the crash-detection grace period in seconds.

Reads ``HERMES_KANBAN_CRASH_GRACE_SECONDS`` from the environment;
falls back to ``DEFAULT_CRASH_GRACE_SECONDS`` when absent, empty,
non-integer, or negative. A value of 0 restores immediate-reclaim
behaviour (useful for tests).
"""
raw = os.environ.get("HERMES_KANBAN_CRASH_GRACE_SECONDS", "").strip()
if raw:
try:
parsed = int(raw)
except ValueError:
parsed = -1
if parsed >= 0:
return parsed
return DEFAULT_CRASH_GRACE_SECONDS


# Worker-context caps so build_worker_context() stays bounded on
# pathological boards (retry-heavy tasks, comment storms, giant
# summaries). Values chosen to fit a typical 100k-char LLM prompt with
Expand Down Expand Up @@ -1181,8 +1209,17 @@ def connect(
# See hermes_state._WAL_INCOMPAT_MARKERS for detection logic.
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})")
conn.execute("PRAGMA synchronous=NORMAL")
# FULL (was NORMAL): fsync before each checkpoint to narrow the
# crash window that can leave a b-tree page header torn.
conn.execute("PRAGMA synchronous=FULL")
conn.execute("PRAGMA wal_autocheckpoint=100")
conn.execute("PRAGMA foreign_keys=ON")
# Zero freed pages so a later torn write cannot expose stale
# cell content; persisted in the DB header for new DBs.
conn.execute("PRAGMA secure_delete=ON")
# Surface corrupt cells as read errors instead of silent
# wrong-data returns.
conn.execute("PRAGMA cell_size_check=ON")
needs_init = resolved not in _INITIALIZED_PATHS
if needs_init:
# Idempotent: runs CREATE TABLE IF NOT EXISTS + the additive
Expand Down Expand Up @@ -1466,22 +1503,74 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
)


def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
"""Read the SQLite header page_count and compare against actual file size.

Raises sqlite3.DatabaseError if the file is shorter than the header claims
(torn-extend corruption).
"""
try:
row = conn.execute("PRAGMA database_list").fetchone()
if row is None:
return
path_str = row[2] # column 2 is the file path; empty for in-memory DBs
if not path_str:
return # in-memory or unnamed DB; skip
path = path_str
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
file_size = os.path.getsize(path)
with open(path, "rb") as f:
f.seek(28)
header_bytes = f.read(4)
if len(header_bytes) < 4:
return # can't read header; skip
header_page_count = int.from_bytes(header_bytes, "big")
if header_page_count == 0:
return # new/empty DB; skip
actual_pages = file_size // page_size
if actual_pages < header_page_count:
raise sqlite3.DatabaseError(
f"torn-extend detected: page count mismatch on {path}: "
f"header claims {header_page_count} pages, "
f"file has {actual_pages} pages "
f"(missing {header_page_count - actual_pages} pages, "
f"file_size={file_size}, page_size={page_size})"
)
except sqlite3.DatabaseError:
raise
except Exception:
pass # I/O errors during check are non-fatal; let normal ops continue


@contextlib.contextmanager
def write_txn(conn: sqlite3.Connection):
"""Context manager for an IMMEDIATE write transaction.

Use for any multi-statement write (creating a task + link, claiming a
task + recording an event, etc.). A claim CAS inside this context is
atomic -- at most one concurrent writer can succeed.

The explicit ROLLBACK on exception is wrapped in try/except so that
a SQLite auto-rollback (which leaves no active transaction) does not
shadow the original exception with a spurious rollback error.
"""
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
except Exception:
conn.execute("ROLLBACK")
try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
# SQLite has already auto-rolled-back the transaction (typical
# under EIO, lock contention, or corruption). Nothing to undo;
# do not let this secondary failure shadow the real one.
pass
raise
else:
conn.execute("COMMIT")
# Post-commit file-length check: header page_count must match actual file pages.
# A discrepancy means a torn-extend — raise now rather than silently corrupt.
_check_file_length_invariant(conn)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -4169,6 +4258,30 @@ def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]":
return ("unknown", None)


def reap_worker_zombies() -> "list[int]":
"""Reap all zombie children of this process without blocking.

Returns the list of reaped PIDs. Safe to call when there are no
children (returns []). No-op on Windows.
"""
if os.name == "nt":
return []
reaped: "list[int]" = []
try:
while True:
try:
pid, status = os.waitpid(-1, os.WNOHANG)
except ChildProcessError:
break
if pid == 0:
break
_record_worker_exit(pid, status)
reaped.append(pid)
except Exception:
pass
return reaped


def _pid_alive(pid: Optional[int]) -> bool:
"""Return True if ``pid`` is still running on this host.

Expand Down Expand Up @@ -4635,7 +4748,7 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
# (task_id, pid, claimer, protocol_violation, error_text)
with write_txn(conn):
rows = conn.execute(
"SELECT id, worker_pid, claim_lock FROM tasks "
"SELECT id, worker_pid, claim_lock, started_at FROM tasks "
"WHERE status = 'running' AND worker_pid IS NOT NULL"
).fetchall()
host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
Expand All @@ -4644,6 +4757,14 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
lock = row["claim_lock"] or ""
if not lock.startswith(host_prefix):
continue
# Skip liveness check inside the launch-window grace period
# so a freshly-spawned worker isn't reclaimed before its PID
# is visible on /proc.
started_at = row["started_at"] if "started_at" in row.keys() else None
if started_at is not None:
grace = _resolve_crash_grace_seconds()
if time.time() - started_at < grace:
continue
if _pid_alive(row["worker_pid"]):
continue

Expand Down Expand Up @@ -5125,38 +5246,9 @@ 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.
#
# We also record the exit status keyed by pid, so
# ``detect_crashed_workers`` can distinguish a worker that exited
# cleanly without calling ``kanban_complete`` / ``kanban_block``
# (protocol violation — auto-block) from a real crash (OOM killer,
# SIGKILL, non-zero exit — existing counter behavior).
#
# Windows has no zombies / no os.WNOHANG — subprocess.Popen handles
# are freed when the Python object is garbage-collected or .wait() is
# called explicitly. The kanban dispatcher discards the Popen handle
# after spawn (``_default_spawn`` → abandon), so on Windows there's
# nothing to reap here — skip the whole block.
if os.name != "nt":
try:
while True:
try:
_pid, _status = os.waitpid(-1, os.WNOHANG)
except ChildProcessError:
break
if _pid == 0:
break
_record_worker_exit(_pid, _status)
except Exception:
pass
# Reap zombie children from previously spawned workers. See
# reap_worker_zombies() for the full rationale.
reap_worker_zombies()

result = DispatchResult()
result.reclaimed = release_stale_claims(conn)
Expand Down
37 changes: 36 additions & 1 deletion hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
_WAL_INCOMPAT_MARKERS = (
"locking protocol", # SQLITE_PROTOCOL on NFS/SMB
"not authorized", # Some FUSE mounts block WAL pragma outright
"disk i/o error", # Flaky network FS during WAL setup
)

# Last SessionDB() init error, per-process. Surfaced in /resume and
Expand Down Expand Up @@ -125,6 +124,27 @@ def format_session_db_unavailable(prefix: str = "Session database not available"
return f"{prefix}: {cause}{hint}."


def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
"""Read the journal mode from the SQLite DB header on disk.

Returns the mode string (e.g. ``"wal"``, ``"delete"``), or ``None``
if the value cannot be determined (new DB, or PRAGMA read failed).
"""
try:
row = conn.execute("PRAGMA journal_mode").fetchone()
except sqlite3.OperationalError:
return None
if row is None:
return None
mode = row[0]
if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
try:
mode = mode.decode("ascii")
except UnicodeDecodeError:
return None
return str(mode).strip().lower() if mode is not None else None


def apply_wal_with_fallback(
conn: sqlite3.Connection,
*,
Expand All @@ -147,7 +167,18 @@ def apply_wal_with_fallback(

Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
both databases get identical fallback behavior.

Never downgrades to DELETE if the on-disk DB header reports WAL — see _on_disk_journal_mode.
"""
# Read-only probe — no flock, no checkpoint, no WAL/SHM unlink.
# Skipping the set-pragma prevents WAL-init from unlinking files other connections hold open.
try:
current_mode = conn.execute("PRAGMA journal_mode").fetchone()
if current_mode and current_mode[0] == "wal":
return "wal"
except sqlite3.OperationalError:
pass

try:
conn.execute("PRAGMA journal_mode=WAL")
return "wal"
Expand All @@ -156,6 +187,10 @@ def apply_wal_with_fallback(
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
# Unrelated OperationalError — don't silently swallow.
raise
# Don't downgrade if another process already set WAL on disk.
existing = _on_disk_journal_mode(conn)
if existing == "wal":
raise
_log_wal_fallback_once(db_label, exc)
conn.execute("PRAGMA journal_mode=DELETE")
return "delete"
Expand Down
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
"schepers.zander1@gmail.com": "Strontvod",
"ed@bebop.crew": "someaka",
"anadi.jaggia@gmail.com": "Jaggia",
"steve@steveonjava.com": "steveonjava",
"steveonjava@gmail.com": "steveonjava",
"32201324+simpolism@users.noreply.github.com": "simpolism",
"simpolism@gmail.com": "simpolism",
"jake@nousresearch.com": "simpolism",
Expand Down
21 changes: 20 additions & 1 deletion tests/hermes_cli/test_kanban_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,19 @@ def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Existing crash-detection tests pre-date the grace window; pin to 0
# so they keep their immediate-reclaim semantics.
monkeypatch.setenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", "0")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Disable the detect_crashed_workers grace period for legacy tests in
# this file that claim a task and immediately expect
# ``detect_crashed_workers`` to act on it. The grace period (30s by
# default, see ``DEFAULT_CRASH_GRACE_SECONDS``) prevents the
# multi-dispatcher reap race in production; setting it to 0 here
# restores the pre-fix instant-reclaim semantics these tests were
# written against. The grace-period itself is covered by dedicated
# tests in tests/hermes_cli/test_kanban_db.py.
monkeypatch.setenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", "0")
kb.init_db()
return home

Expand Down Expand Up @@ -3655,9 +3667,16 @@ def _connect(*args, **kwargs):
raise sqlite3.DatabaseError("file is not a database")

async def _to_thread(fn, *args, **kwargs):
# PR salvage (#32857 commit 7): the dispatcher now reaps zombies at
# the top of each tick via ``asyncio.to_thread(_kb.reap_worker_zombies)``
# BEFORE the per-board tick work. Each tick now issues 3 ``to_thread``
# calls (reaper + ``_tick_once`` + ``_ready_nonempty``) instead of 2,
# so this counter must reach 6 to allow the same 2 dispatch ticks the
# pre-reaper test expected at 4. Connect counts in the assertion below
# are unchanged.
calls["to_thread"] += 1
result = fn(*args, **kwargs)
if calls["to_thread"] >= 4:
if calls["to_thread"] >= 6:
runner._running = False
return result

Expand Down
Loading
Loading