Skip to content

fix: drain embedded Kanban workers before gateway restart - #103961

Open
sycamoregroupltd wants to merge 2 commits into
NousResearch:mainfrom
sycamoregroupltd:fix/t_8f98ac9b
Open

sycamoregroupltd wants to merge 2 commits into
NousResearch:mainfrom
sycamoregroupltd:fix/t_8f98ac9b

Conversation

@sycamoregroupltd

@sycamoregroupltd sycamoregroupltd commented Sep 6, 2026 •

Copy link
Copy Markdown

Follow-up to #77184.

Problem

The gateway restart after-turn wait counted messaging turns but not embedded Kanban worker subprocesses. The observed restart series on 2026-09-03 was 00:13, 00:29, 00:57, 07:20, 07:38, 09:27, 21:17, and 21:28 BST (the incident report labels this as seven restarts but lists eight timestamps); each was recorded as a crash wave. The two late restarts were legitimate (Slack integration at 21:16 and dashboard activity), and left 7 jarvis-os plus 6 sycode-trading workers under systemd KillMode=mixed; all 13 were SIGKILLed and re-dispatched after pid-not-alive crashes.

Fix

  • Include live host-local Kanban workers from every non-archived board in gateway active-work accounting.
  • Pause embedded Kanban dispatch while the gateway is draining.
  • Wait for non-wedged workers under the existing restart-after-turn cap.
  • On the force path, durably block each still-live worker with reason gateway restart and run-id CAS before sending SIGTERM, so supervisor escalation cannot charge a crash retry.
  • Add regression coverage for worker discovery, wedged exclusion, durable blocking/SIGTERM ordering, and gateway work accounting.

Verification

python -m pytest tests/hermes_cli/test_gateway_restart_kanban_workers.py tests/hermes_cli/test_kanban_gateway_restart_handoff.py -q -o addopts= -> 9 passed.

This is a fork branch only; no live gateway or production install was restarted.

Copilot AI lite review requested due to automatic review settings September 6, 2026 00:41
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/cron Cron scheduler and job management sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Sep 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

There are a few correctness/operability issues (notably failing-open accounting, worker handling when run_id is NULL, and misleading SIGTERM logging) that could cause the restart drain to miss or misreport live Kanban workers.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Updates the gateway restart “after-turn” drain logic to account for embedded Kanban worker subprocesses, preventing systemd KillMode escalation from force-killing workers without a durable handoff marker.

Changes:

  • Adds Kanban worker accounting to gateway active-work and restart wait calculations, plus a force-interrupt hook for restart deadlines.
  • Introduces DB-backed worker discovery and a force-path helper to durably transition running tasks before signaling worker PIDs.
  • Adds regression tests covering wedged exclusion, force-path ordering, and active-work totals.
File summaries
File Description
tests/hermes_cli/test_gateway_restart_kanban_workers.py New regression tests for worker discovery, wedged handling, and force-path behavior.
hermes_cli/kanban_db.py Re-exports new dispatcher helper functions from kanban_db_dispatch.
hermes_cli/kanban_db_dispatch.py Adds worker listing and force-restart preparation (durable transition + SIGTERM).
gateway/run_shutdown.py Incorporates Kanban workers into active-work and awaitable-work logic; invokes Kanban interrupt hook on force paths.
gateway/kanban_watchers.py Enumerates live workers across boards, pauses dispatch while draining, and implements force cleanup during restart drain.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread gateway/kanban_watchers.py Outdated
Comment on lines +241 to +265
try:
from hermes_cli import kanban_db as _kb
from hermes_cli import kanban_db_connect as _kbc
from hermes_cli import kanban_db_dispatch as _kbd
boards = _kb.list_boards(include_archived=False)
except Exception:
logger.exception("kanban restart drain: cannot enumerate boards")
return []

workers: list[dict] = []
for board_meta in boards:
slug = board_meta.get("slug") or _kb.DEFAULT_BOARD
conn = None
try:
conn = _kbc.connect(board=slug)
workers.extend(_kbd.list_running_workers(conn, include_wedged=include_wedged))
except Exception:
logger.debug("kanban restart drain: worker scan failed on board %s", slug, exc_info=True)
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
return workers
Comment thread gateway/run_shutdown.py
Comment on lines +134 to 139
count_kanban = getattr(self, "_active_kanban_worker_count", None)
try:
active_kanban = max(0, int(count_kanban())) if callable(count_kanban) else 0
except Exception:
active_kanban = 0
return (
Comment on lines +571 to +575
prepared: list[dict[str, Any]] = []
for worker in list_running_workers(conn, include_wedged=True):
run_id = worker["run_id"]
if run_id is None:
continue
Comment on lines +296 to +300
if prepared:
logger.warning(
"kanban restart drain: blocked and SIGTERM'd %d worker(s) on board %s",
len(prepared), slug,
)

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed exact head 1cbd5910479f3be130c2c4427aefc312e9d34743 against current main 245e48008fa814b3251f50755eb656bd9fb86cb1 (merge base 96ed0e71ea2d75d325cc05c3e6468d213de2408d; this branch is currently 3 commits behind main). I read all five changed files, the existing restart-handoff test on current main, the shutdown/dispatcher ownership paths, the worker spawn/scope helper, current review threads, #77184, merged #101940, open #101911, and the Kanban decomposition owner #78632.

The incident evidence is valuable, and the core ordering instinct here is good: if a run really must be interrupted, durable state must win before an external kill can be interpreted as a crash. The new tests also pin the intended block-before-signal ordering rather than merely checking an exception. Exact-head CI is green: CI run 34001954780, Docker 34001954495, and Nix 34001954467 all succeeded; this PR has one commit, so every PR commit currently has a green exact-object receipt.

I do not think this head is safe to land yet, for three architecture-level reasons beyond the existing Copilot inline findings.

1. This currently reverses the restart-survival contract already on main and competes directly with #101911

Merged #101940 deliberately changed managed-systemd Kanban spawn to restart_safe_gateway_child_argv(). Current-main tests/hermes_cli/test_kanban_gateway_restart_handoff.py::test_managed_gateway_worker_is_spawned_in_restart_safe_scope asserts that the worker runs in its own .scope and specifically not in hermes-gateway.service; the fail-closed tests refuse a managed spawn when that scope cannot be established. In other words, for the topology this PR is trying to protect, current main already says the worker's effect lifetime is not the gateway process lifetime.

This head then counts those restart-safe workers as gateway-owned work and, on every force path, calls block_task(... kind="transient", expected_run_id=run_id) and SIGTERMs them. That turns "survive the gateway restart" into "planned gateway restart terminalizes the Kanban run." It avoids charging a crash retry, but it discards the durable-work contract that #101940 intentionally established.

Open #101911 is the existing full carrier for exactly the unresolved other half: worker self-registration, PID/start-time identity, cgroup-truth liveness, re-adoption after gateway restart, verified whole-scope teardown, and an explicit worker_isolation_stop_on_shutdown policy whose default is survive + re-adopt. It also collides with this PR in gateway/kanban_watchers.py and hermes_cli/kanban_db.py. That is not merely adjacent work; these are competing lifecycle policies for the same defect class.

Required resolution: preserve one semantic owner. Either fold this production incident + focused regression evidence into #101911, preserving its contributor history, or reduce this PR to a genuinely non-competing delta that handles only a proven remainder (for example, legacy/unisolated workers) without terminating the restart-safe scoped workers current main intentionally detached. Please make the merge order explicit rather than landing two different answers to "who owns a Kanban worker across gateway restart?"

2. A gateway that does not own the machine-global dispatcher can currently block and signal another process's workers

GatewayKanbanWatchersMixin._kanban_dispatcher_boot() acquires the machine-global .dispatcher.lock for the process lifetime. A sibling gateway that sees the lock contended explicitly returns without becoming the dispatcher. But the new _active_kanban_worker_count(), _wedged_kanban_worker_count(), and _interrupt_kanban_workers_for_restart() paths do not require _owns_kanban_dispatcher_lock() at all.

Worse, list_running_workers() accepts any claim whose lock merely starts with _host_prefix(). Current main defines _claimer_id() as <host>:<pid> and _host_prefix() as only <host>:. So a profile gateway that never acquired dispatch authority can restart, scan every board, wait on workers claimed by the actual dispatcher, and eventually block_task() + SIGTERM those workers. Its own _draining flag does not pause the actual owner's watcher, either. That is a cross-process mutation-authority breach, not just an accounting bug.

Required fix: bind restart handling to exact worker authority, not host co-location. At minimum the regression needs two gateway/claimer identities on one host: the non-owner restart must neither treat the owner's worker as its own drain target nor mutate/signal it. The implementation should consume the existing dispatcher ownership/claim identity (or the durable adoption authority that #101911 introduces), rather than authorizing lifecycle mutation from _host_prefix().

3. worker_pid + _pid_alive() is not sufficient authority for a destructive signal, and one PID is not the whole scoped effect

The new force path reads a durable PID, checks only that some process currently occupies it, commits the task away from running, and then calls os.kill(pid, SIGTERM). There is no process-generation/start-time proof. A stale PID can be recycled between persistence and restart; in that case this path can block the Hermes task and signal an unrelated host process. The repository already has the stronger shape in tools/process_registry.ProcessSession (host_start_time, systemd_unit), and #101911 carries the corresponding Kanban design (worker_pid_started_at, worker_scope, cgroup truth).

There is also a settlement-order problem for process trees: current-main managed workers live in a transient systemd scope. Signalling one PID does not prove that the run's descendants are gone. This PR makes the durable task terminal-ish first and never verifies whole-effect teardown, so a worker can be recorded blocked while a descendant process from that run remains live and continues side effects.

Required fix: do not authorize a destructive signal from a bare PID. For workers that should survive, re-adopt instead of terminating. For workers that truly must stop, use generation-bound process identity and verified whole-scope/process-tree teardown before releasing the run into a state that can later be retried.

The four existing Copilot findings remain additive (cross-board scan rate, fail-open accounting, NULL-run handling, and the SIGTERM telemetry mismatch); I am not duplicating those here.

Interlocks / landing gate

  • #77184 is the historical after-turn-drain defect and remains useful provenance; this PR is a follow-up, not a replacement for that closed fix.
  • #101940 is merged current behavior, not historical noise: its Kanban scope contract has to be preserved.
  • #101911 is the direct competing/superseding carrier for Kanban worker restart ownership. Its prior work and authorship need to survive whichever implementation lands.
  • #78632 remains the canonical hermes_cli/kanban_db.py decomposition owner. This PR modifies that file at the re-export seam around line 4191, so it still touches an owner above the repository's 2K ceiling. Treat that as a landing-order/topology gate rather than growing the compatibility godfile again.

Before a clear/ready verdict I would want the ownership test above, a managed-systemd survival/re-adoption test, a recycled-PID negative test, a leaked-descendant/whole-scope settlement test, the existing scan-failure cases, and then a rebase to current main with CI/Docker/Nix green again at the new exact head. The present head is green, but it is not current-main proof.

There is strong incident work here. The important thing now is to keep that evidence and the useful ordering tests while landing them into the repository's one durable worker-lifetime model instead of creating a second one.

garadice added a commit to garadice/hermes-agent that referenced this pull request Sep 8, 2026
…vity, sync exit_kind contract

- DispatchResult.supervisor_restarts: new field, plumbed from
  detect_crashed_workers._last_supervisor_restart in _run_reclaim_phase —
  dispatch_once callers (CLI output, telemetry, on_kanban_dispatch_tick)
  can now see neutral gateway-restart releases.
- _TICK_ACTIVITY_FIELDS: count supervisor_restarts as tick activity so a
  restart releasing N pre-boot claims no longer reports outcome='idle'.
- plugins.py on_kanban_worker_exited contract: document the new
  'supervisor_restart' exit_kind value.
- _supervisor_restart_collateral docstring: prove only what durable state
  proves (unobserved-by-any-live-dispatcher) and name the residual
  one-shot-dispatcher ambiguity explicitly (NousResearch#83066, NousResearch#103961).
- Regression tests: tick-level red/green test for the plumb + whitelist.

Co-authored-by: crazyief <8566250+crazyief@users.noreply.github.com>
garadice added a commit to garadice/hermes-agent that referenced this pull request Sep 22, 2026
…vity, sync exit_kind contract

- DispatchResult.supervisor_restarts: new field, plumbed from
  detect_crashed_workers._last_supervisor_restart in _run_reclaim_phase —
  dispatch_once callers (CLI output, telemetry, on_kanban_dispatch_tick)
  can now see neutral gateway-restart releases.
- _TICK_ACTIVITY_FIELDS: count supervisor_restarts as tick activity so a
  restart releasing N pre-boot claims no longer reports outcome='idle'.
- plugins.py on_kanban_worker_exited contract: document the new
  'supervisor_restart' exit_kind value.
- _supervisor_restart_collateral docstring: prove only what durable state
  proves (unobserved-by-any-live-dispatcher) and name the residual
  one-shot-dispatcher ambiguity explicitly (NousResearch#83066, NousResearch#103961).
- Regression tests: tick-level red/green test for the plumb + whitelist.

Co-authored-by: crazyief <8566250+crazyief@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Oct 8, 2026
Managed-worker turns run in child processes, outside _running_agents, so
_active_work_count()/_drain_work_counts() saw none: with a managed turn
running, _drain_active_agents reported no work and timed_out=False, no Stop
was ever sent, and settle_gateway_runtime() awaited the authority task with
an unbounded gather. A stalled worker bypassed the configured bounded drain.

- run_runtime.managed_turn_count counts live managed workers across every
  served authority; it rides the chat slot of the drain (chat deadline), the
  active-work total, the restart wait and the post-interrupt grace loop, and
  appears in the draining status as kind=managed. A worker registers before
  its reservation, and a draining owner refuses that reservation, so nothing
  unbootstrapped can start work behind the count.
- the timed-out path (_interrupt_running_agents) sends each managed worker
  the same Stop a user's Stop sends (stop_managed_turns); the worker
  acknowledges or is terminated within STOP_ACK_SECONDS and the admission
  settles through its own fenced path (interrupted, or durable unknown).
- settle_gateway_runtime keeps the #121813 authority-task settlement wait
  but sends Stop first and bounds it (TURN_SETTLE_SECONDS); a task past the
  bound is cancelled, which leaves its admission for recovery, never settled.

Donor test pattern: #103961 test_active_work_count_includes_kanban_workers /
test_restart_wait_excludes_wedged_kanban_workers_and_interrupts_them, adapted
to canonical managed workers with real subprocess children.

Review: #106742 (comment)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants