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
7 changes: 6 additions & 1 deletion agent/command_token_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@ def materialize_probe_api_key(api_key: object) -> str:


def _mint(command: str, label: str) -> tuple[str, Optional[float]]:
"""Run *command*, returning ``(token, ttl_seconds_or_None)``."""
"""Run *command*, returning ``(token, ttl_seconds_or_None)``. The helper runs FOR the profile whose
provider is being minted: it gets that profile's own env (secrets + HERMES_HOME), never the multiplexer's
launch environ — an ``op read`` / ``vault kv get`` helper must sign in as the served profile."""
from tools.environments.local import served_profile_child_env

try:
completed = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=_MINT_TIMEOUT_SECONDS,
env=served_profile_child_env(inherit_credentials=True),
)
except subprocess.TimeoutExpired as exc:
raise CommandTokenError(
Expand Down
4 changes: 2 additions & 2 deletions agent/memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ def ctx_bound(fn: Callable[..., Any]) -> Callable[..., Any]:


def spawn_context_thread(target: Callable[..., Any], *, name: str, daemon: bool = True,
args: tuple = ()) -> threading.Thread:
args: tuple = (), kwargs: Optional[Dict[str, Any]] = None) -> threading.Thread:
"""Unstarted thread running *target* under the spawner's contextvars (see :func:`ctx_bound`).
Every memory-provider background job (prefetch, sync, writer loops) must go through this."""
return threading.Thread(target=ctx_bound(target), args=args, name=name, daemon=daemon)
return threading.Thread(target=ctx_bound(target), args=args, kwargs=kwargs, name=name, daemon=daemon)

# v1 = best-effort on_pre_compress() with the raw message list; v2 = opt-in fail-closed
# checkpoint (normalized evidence handoff + strict-mode failure propagation).
Expand Down
14 changes: 8 additions & 6 deletions agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import json
import logging
import re
import threading
from contextlib import suppress
from typing import Any, Callable, Optional

Expand Down Expand Up @@ -488,10 +487,13 @@ def maybe_auto_title(
logger.debug("Auto-title skipped: auxiliary.title_generation.enabled=false")
return
apply_instant_title(session_db, session_id, user_message, title_callback)
threading.Thread(
target=auto_title_session,
# The thread must resolve auxiliary.title_generation (config, provider key, language) for the
# profile whose turn this is: a bare Thread starts with an empty context and lands on the launch
# profile under multiplex, titling X's session with the default profile's model and billing its key.
from agent.memory_provider import spawn_context_thread
spawn_context_thread(
auto_title_session, name="auto-title",
args=(session_db, session_id, user_message),
kwargs=dict(failure_callback=failure_callback, main_runtime=main_runtime, title_callback=title_callback, runtime_validator=runtime_validator),
daemon=True,
name="auto-title",
kwargs=dict(failure_callback=failure_callback, main_runtime=main_runtime, title_callback=title_callback,
runtime_validator=runtime_validator),
).start()
36 changes: 22 additions & 14 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,10 @@ def from_row(cls, row: sqlite3.Row) -> "Event":
-- exceeds DEFAULT_FAILURE_LIMIT consecutive non-successes.
consecutive_failures INTEGER NOT NULL DEFAULT 0,
worker_pid INTEGER,
-- Start-time fingerprint of worker_pid (gateway.status.get_process_start_time) recorded at
-- spawn: liveness and kills require pid AND fingerprint to agree, so a PID recycled after a
-- reboot is never read as our worker or signalled. NULL = legacy row (pre-fingerprint spawn).
worker_started_at INTEGER,
-- Short excerpt of the most recent failure's error text.
last_failure_error TEXT,
max_runtime_seconds INTEGER,
Expand Down Expand Up @@ -2305,7 +2309,7 @@ def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int:
reclaimed = 0
host_prefix = _host_prefix()
stale = conn.execute(
"SELECT id, claim_lock, worker_pid, claim_expires, last_heartbeat_at, "
"SELECT id, claim_lock, worker_pid, worker_started_at, claim_expires, last_heartbeat_at, "
" assignee "
"FROM tasks "
"WHERE status = 'running' AND claim_expires IS NOT NULL "
Expand All @@ -2317,12 +2321,14 @@ def release_stale_claims(conn: sqlite3.Connection, *, signal_fn=None) -> int:
# Backstop: a heartbeat older than the max-stale threshold means no
# observable progress — reclaim even if the PID is alive (logic loop).
heartbeat_stale = hb is not None and (now - int(hb)) > DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS
if host_local and row["worker_pid"] and _pid_alive(row["worker_pid"]) and not heartbeat_stale:
started_at = _row_get(row, "worker_started_at")
if (host_local and row["worker_pid"] and _worker_alive(row["worker_pid"], started_at)
and not heartbeat_stale):
_extend_live_stale_claim(conn, row, now)
continue

termination = _terminate_reclaimed_worker(
row["worker_pid"], row["claim_lock"], signal_fn=signal_fn,
row["worker_pid"], row["claim_lock"], signal_fn=signal_fn, started_at=started_at,
)
# A live worker of ours must keep its claim (else a duplicate spawns beside it).
if _worker_survived_termination(termination):
Expand Down Expand Up @@ -2416,15 +2422,16 @@ def reclaim_task(
"""Operator reclaim regardless of TTL: release the claim, restore the source
phase, reset the failure counter. False when not running."""
row = conn.execute(
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?", (task_id,),
"SELECT status, claim_lock, worker_pid, worker_started_at 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)
termination = _terminate_reclaimed_worker(
row["worker_pid"], prev_lock, signal_fn=signal_fn, started_at=row["worker_started_at"])
with write_txn(conn):
retry_status = _retry_status_for_run(conn, task_id)
cur = conn.execute(
Expand Down Expand Up @@ -3403,12 +3410,12 @@ def invalidate_descendants_for_parent_reopen(
action), the opposite of :func:`reopen_review_task`.

Returns ``{"invalidated": [{id, prior_status, new_status, resume_status}],
"terminations": [(worker_pid, claim_lock)]}``.
"terminations": [(worker_pid, claim_lock, worker_started_at)]}``.
"""
caller_owns_txn = bool(conn.in_transaction)
now = int(time.time())
invalidated: list[dict[str, Any]] = []
terminations: list[tuple[Optional[int], Optional[str]]] = []
terminations: list[tuple[Optional[int], Optional[str], Optional[int]]] = []
with write_txn(conn, allow_nested=True):
rows = conn.execute(
"""
Expand All @@ -3419,7 +3426,7 @@ def invalidate_descendants_for_parent_reopen(
FROM task_links l
JOIN descendants d ON d.id = l.parent_id
)
SELECT t.id, t.status, t.current_run_id, t.worker_pid, t.claim_lock
SELECT t.id, t.status, t.current_run_id, t.worker_pid, t.claim_lock, t.worker_started_at
FROM descendants d
JOIN tasks t ON t.id = d.id
ORDER BY t.id
Expand All @@ -3436,7 +3443,7 @@ def invalidate_descendants_for_parent_reopen(
resume_status = "review"
elif previous_status == "running":
resume_status = _retry_status_for_run(conn, row["id"], row["current_run_id"])
terminations.append((row["worker_pid"], row["claim_lock"]))
terminations.append((row["worker_pid"], row["claim_lock"], row["worker_started_at"]))
run_id = _end_run(
conn, row["id"], outcome="reclaimed", status="todo",
summary=f"ancestor {task_id} reopened",
Expand Down Expand Up @@ -3476,8 +3483,8 @@ def invalidate_descendants_for_parent_reopen(
if not caller_owns_txn:
# Standalone: committed above, audit trail durable, safe to kill now.
# Composed calls leave this to the caller post-commit.
for pid, claim_lock in terminations:
_terminate_reclaimed_worker(pid, claim_lock)
for pid, claim_lock, started_at in terminations:
_terminate_reclaimed_worker(pid, claim_lock, started_at=started_at)
return {"invalidated": invalidated, "terminations": terminations}


Expand Down Expand Up @@ -3554,13 +3561,13 @@ def archive_task(conn: sqlite3.Connection, task_id: str, *, signal_fn=None) -> b
"""
with write_txn(conn):
row = conn.execute(
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
"SELECT status, claim_lock, worker_pid, worker_started_at FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return False
was_running = row["status"] == "running"
prev_pid, prev_lock = row["worker_pid"], row["claim_lock"]
prev_pid, prev_lock, prev_started = row["worker_pid"], row["claim_lock"], row["worker_started_at"]
cur = conn.execute(
"UPDATE tasks SET status = 'archived', "
" claim_lock = NULL, claim_expires = NULL, worker_pid = NULL "
Expand All @@ -3575,7 +3582,7 @@ def archive_task(conn: sqlite3.Connection, task_id: str, *, signal_fn=None) -> b
)
_append_event(conn, task_id, "archived", None, run_id=run_id)
if was_running:
termination = _terminate_reclaimed_worker(prev_pid, prev_lock, signal_fn=signal_fn)
termination = _terminate_reclaimed_worker(prev_pid, prev_lock, signal_fn=signal_fn, started_at=prev_started)
with write_txn(conn):
_append_event(conn, task_id, "archive_worker_termination", termination, run_id=run_id)
# ``archived`` parents no longer block children; promote them now.
Expand Down Expand Up @@ -4122,6 +4129,7 @@ def latest_summaries(conn: sqlite3.Connection, task_ids: Iterable[str]) -> dict[
_defer_reclaim_for_live_worker,
_pid_alive,
_terminate_reclaimed_worker,
_worker_alive,
_worker_survived_termination,
_worker_terminal_timeout_env,
)
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/kanban_db_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,8 @@ def init_db(db_path: Optional[Path] = None, *, board: Optional[str] = None) -> P
# Typed block reason (VALID_BLOCK_KINDS); NULL = generic human blocker.
("block_kind", "block_kind TEXT"),
("block_recurrences", "block_recurrences INTEGER NOT NULL DEFAULT 0"),
# Spawn-time start fingerprint of worker_pid (PID-reuse guard; NULL = legacy row).
("worker_started_at", "worker_started_at INTEGER"),
)

_NOTIFY_SUB_COLUMNS = (
Expand Down
79 changes: 59 additions & 20 deletions hermes_cli/kanban_db_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,17 +256,41 @@ def _pid_alive(pid: Optional[int]) -> bool:
return True


def _worker_alive(pid: Optional[int], started_at: Optional[int]) -> bool:
"""True when ``pid`` is live AND is still the worker we spawned. ``started_at`` is the start-time
fingerprint recorded by ``_set_worker_pid``; after a reboot (or any PID recycle) an unrelated
process can own the number, so bare existence is never enough to extend a claim or to signal.
A legacy row without a fingerprint keeps the existence answer: killing it is the pre-fingerprint
behaviour and the row is rewritten with a fingerprint on its next spawn."""
return _kb._pid_alive(pid) and not _pid_recycled(pid, started_at)


def _pid_recycled(pid: Optional[int], started_at: Optional[int]) -> bool:
"""True when a live ``pid`` is NOT the process fingerprinted at spawn (or the fingerprint can no
longer be read). Signalling it would hit a stranger. ``None`` fingerprint = legacy row, never recycled."""
if started_at is None or not pid:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — None is safe only for truly legacy rows, but _set_worker_pid() can create it today. get_process_start_time() is optional; if a new spawn's lookup fails, this PR persists worker_started_at=NULL, then this branch deliberately treats the live PID as our worker and the timeout/reclaim paths can signal it by bare number. That recreates the #89614/#99558 authority failure for a post-migration row. Preserve legacy compatibility separately, but a new unverified spawn must fail closed for destructive signaling. Add a test that forces fingerprint capture to None, then presents a live/reused PID and proves TERM/KILL is never issued.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on head 42e01d9 (#112685, 9cb9fd0d0cb9c): a failed capture persists 'unverified', never NULL — held while live, never signalled by timeout/stale-claim/manual reclaim/archive/reaper, reclaimed once gone; NULL stays legacy-only — …::test_unverified_fingerprint_capture_never_authorizes_a_signal (red on base).

return False
from gateway.status import _start_times_agree, get_process_start_time
current = get_process_start_time(int(pid))
if current is None:
return True
try:
return not _start_times_agree(current, started_at)
except (TypeError, ValueError):
return True


def _kill_fn(signal_fn) -> Optional[Callable[[int, int], None]]:
"""``signal_fn`` test hook, else ``os.kill`` when the platform has one."""
if signal_fn is not None:
return signal_fn
return os.kill if hasattr(os, "kill") else None


def _poll_worker_exit(pid: int) -> bool:
def _poll_worker_exit(pid: int, started_at: Optional[int] = None) -> bool:
"""Poll ~5 s (10 x 0.5 s) for ``pid`` to die; True once it is gone."""
for _ in range(10):
if not _kb._pid_alive(pid):
if not _worker_alive(pid, started_at):
return True
time.sleep(0.5)
return False
Expand All @@ -287,8 +311,11 @@ def _terminate_reclaimed_worker(
claim_lock: Optional[str],
*,
signal_fn=None,
started_at: Optional[int] = None,
) -> dict[str, Any]:
"""Best-effort host-local worker termination for reclaim paths."""
"""Best-effort host-local worker termination for reclaim paths. ``started_at`` is the spawn-time
fingerprint: when the live process no longer matches it, the PID was recycled and nothing is
signalled — the worker is gone, which is what the reclaim wanted (``terminated`` = True)."""
info: dict[str, Any] = {
"prev_pid": int(pid) if pid else None,
"host_local": False,
Expand All @@ -305,6 +332,10 @@ def _terminate_reclaimed_worker(
kill = _kill_fn(signal_fn)
if kill is None:
return info
if _kb._pid_alive(pid) and _pid_recycled(pid, started_at):
info["terminated"] = True
info["pid_recycled"] = True
return info

info["termination_attempted"] = True
try:
Expand All @@ -317,14 +348,14 @@ def _terminate_reclaimed_worker(
except OSError:
return info

if _poll_worker_exit(pid):
if _poll_worker_exit(pid, started_at):
info["terminated"] = True
return info
if _kb._pid_alive(pid):
if _worker_alive(pid, started_at):
if not _sigkill(kill, pid):
return info
info["sigkill"] = True
info["terminated"] = not _kb._pid_alive(pid)
info["terminated"] = not _worker_alive(pid, started_at)
return info


Expand Down Expand Up @@ -427,7 +458,7 @@ def enforce_max_runtime(conn: sqlite3.Connection, *, signal_fn=None) -> list[str
host_prefix = _kb._host_prefix()

rows = conn.execute(
"SELECT t.id, t.worker_pid, "
"SELECT t.id, t.worker_pid, t.worker_started_at, "
" COALESCE(r.started_at, t.started_at) AS active_started_at, "
" t.max_runtime_seconds, t.claim_lock "
"FROM tasks t "
Expand All @@ -449,16 +480,18 @@ def enforce_max_runtime(conn: sqlite3.Connection, *, signal_fn=None) -> list[str

pid = int(row["worker_pid"])
tid = row["id"]
started_at = _kb._row_get(row, "worker_started_at")
# SIGTERM then SIGKILL after 5 s grace; workers wanting a cleaner
# shutdown install their own SIGTERM handler.
# shutdown install their own SIGTERM handler. A recycled PID (fingerprint
# mismatch) is never signalled: the worker is already gone.
killed = False
kill = _kill_fn(signal_fn)
if kill is not None:
if kill is not None and not (_kb._pid_alive(pid) and _pid_recycled(pid, started_at)):
with contextlib.suppress(ProcessLookupError, OSError):
kill(pid, signal.SIGTERM)
# Short polling wait — no time.sleep on the write txn.
_poll_worker_exit(pid)
if _kb._pid_alive(pid):
_poll_worker_exit(pid, started_at)
if _worker_alive(pid, started_at):
killed = _sigkill(kill, pid)

error = f"elapsed {int(elapsed)}s > limit {limit}s"
Expand Down Expand Up @@ -529,7 +562,7 @@ def detect_stale_running(
reclaimed: list[str] = []

rows = conn.execute(
"SELECT t.id, t.worker_pid, t.last_heartbeat_at, t.claim_lock, "
"SELECT t.id, t.worker_pid, t.worker_started_at, t.last_heartbeat_at, t.claim_lock, "
" COALESCE(r.started_at, t.started_at) AS active_started_at "
"FROM tasks t "
"LEFT JOIN task_runs r ON r.id = t.current_run_id "
Expand All @@ -552,7 +585,8 @@ def detect_stale_running(
tid = row["id"]
lock = row["claim_lock"] or ""

termination = _kb._terminate_reclaimed_worker(pid, lock, signal_fn=signal_fn)
termination = _kb._terminate_reclaimed_worker(
pid, lock, signal_fn=signal_fn, started_at=_kb._row_get(row, "worker_started_at"))

# Never release a claim while our own worker is still alive: that would
# spawn a duplicate beside it. Hold the claim and retry next tick.
Expand Down Expand Up @@ -615,14 +649,14 @@ def reconcile_orphaned_running(conn: sqlite3.Connection) -> list[str]:
now = int(time.time())
reconciled: list[str] = []
rows = conn.execute(
"SELECT id, claim_lock, claim_expires, worker_pid FROM tasks "
"SELECT id, claim_lock, claim_expires, worker_pid, worker_started_at FROM tasks "
"WHERE status = 'running' "
" AND (claim_lock IS NULL OR claim_expires IS NULL)"
).fetchall()
for row in rows:
tid = row["id"]
pid = row["worker_pid"]
if pid and _kb._pid_alive(pid):
if pid and _worker_alive(pid, _kb._row_get(row, "worker_started_at")):
# Never requeue beside a live process. Retry next tick.
_kb._log.debug(
"kanban reconcile: task %s has broken claim bookkeeping but "
Expand Down Expand Up @@ -807,7 +841,7 @@ def _reclaim_dead_workers(conn: sqlite3.Connection) -> _CrashSweep:
sweep = _CrashSweep()
with _kb.write_txn(conn):
rows = conn.execute(
"SELECT id, worker_pid, claim_lock, started_at, assignee "
"SELECT id, worker_pid, worker_started_at, claim_lock, started_at, assignee "
"FROM tasks "
"WHERE status = 'running' AND worker_pid IS NOT NULL"
).fetchall()
Expand All @@ -821,7 +855,7 @@ def _reclaim_dead_workers(conn: sqlite3.Connection) -> _CrashSweep:
started_at = _kb._row_get(row, "started_at")
if started_at is not None and time.time() - started_at < _kb._resolve_crash_grace_seconds():
continue
if _kb._pid_alive(row["worker_pid"]):
if _worker_alive(row["worker_pid"], _kb._row_get(row, "worker_started_at")):
continue

pid = int(row["worker_pid"])
Expand Down Expand Up @@ -1098,13 +1132,18 @@ def _record_task_failure(


def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None:
"""Record the spawned child's pid + emit a ``spawned`` event carrying it."""
"""Record the spawned child's pid + its start-time fingerprint, and emit a ``spawned`` event
carrying them. The fingerprint is what lets every later liveness/kill decision tell OUR worker
from a process that recycled the PID after a reboot."""
from gateway.status import get_process_start_time
started_at = get_process_start_time(int(pid))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — this persisted fingerprint is not reboot-stable on Linux, so it does not prove the threat model in the docstring. gateway.status.get_process_start_time() first returns /proc/<pid>/stat field 22 on Linux: boot-relative clock ticks. The DB survives reboot; that counter does not. A later boot can therefore produce an unrelated process with the same PID and the same boot-relative tick value, and _start_times_agree() accepts it. Persist an incarnation witness with the process start (for example the existing gateway.drain_control.current_instantiation_epoch() / Linux boot_id, or an equivalent restart-stable creation-time identity), then require both to match before liveness extends a claim or any signal is authorized. Hostile test: same PID + same start tick, different boot identity => zero signals.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on head 42e01d9 (#112685, 9cb9fd0d0cb9c): fingerprint is now "<current_instantiation_epoch()>|<start>" (boot_id + PID-1 start), both halves must match — test_kanban_worker_pid_fingerprint.py::test_same_pid_and_start_tick_on_another_boot_is_foreign (same PID + same tick, other boot id → zero signals; red on base). Live: real sleeper child left untouched on the reboot-shaped row, SIGTERM'd on the matching one.

with _kb.write_txn(conn):
conn.execute("UPDATE tasks SET worker_pid = ? WHERE id = ?", (int(pid), task_id))
conn.execute("UPDATE tasks SET worker_pid = ?, worker_started_at = ? WHERE id = ?",
(int(pid), started_at, task_id))
run_id = _kb._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))
_kb._append_event(conn, task_id, "spawned", {"pid": int(pid)}, run_id=run_id)
_kb._append_event(conn, task_id, "spawned", {"pid": int(pid), "started_at": started_at}, run_id=run_id)


def _clear_failure_counter(conn: sqlite3.Connection, task_id: str) -> None:
Expand Down
Loading
Loading