Skip to content

fix(kanban): classify zero-turn clean exits as route failures and reap before classifying - #109468

Open
haumanto wants to merge 15 commits into
NousResearch:mainfrom
haumanto:pr/route-failure-classifier-20260913b
Open

haumanto wants to merge 15 commits into
NousResearch:mainfrom
haumanto:pr/route-failure-classifier-20260913b

Conversation

@haumanto

Copy link
Copy Markdown

What breaks: a worker that exits rc=0 having made no tool call at all, the shape a routing or credential failure takes before the model ever runs, is booked as a protocol violation and counted against the protocol-violation streak. With the common per-task max_retries=1 that single misclassification blocks the card.

Repro: a card whose worker dies with zero heartbeats is closed as crashed with pid <n> not alive and counted against _PROTOCOL_VIOLATION_FAILURE_LIMIT.

Fix, three parts: _classify_dead_worker takes the closing run's last heartbeat and elapsed time and classifies a clean exit with no heartbeat past the crash grace as route_failure; _reclaim_dead_workers reaps the zombie before classifying so a child exiting between the two calls is classified from its real exit status; route failures do not consume the protocol-violation streak and carry their own budget of two trailing failures before the breaker trips.

Adjacent work: #107886 classifies 429/quota exits as capacity_failure in the same function. Ours is the zero-turn rc=0 case; the labels stay distinct and the ordering between the two branches is explicit.

Tests: five invariant tests, each failing on the base commit, including the ordering classification, streak non-consumption, and the breaker payload.

…p before classifying

A worker that exits rc=0 with zero heartbeats (routing/credential failure
before the model ever ran) was classified as a protocol violation and
counted against the protocol-violation streak. It never made a single
MCP tool call, so it never had the chance to do or skip the work.

_classify_dead_worker now takes the last-heartbeat timestamp and elapsed
time; a clean exit with no heartbeat ever recorded (after the crash
grace window) is booked as a route_failure event with its own bounded
streak (_ROUTE_FAILURE_FAILURE_LIMIT=2), independent of the
protocol-violation budget, and is skipped (not broken) by the
protocol-violation streak walk.

_reclaim_dead_workers now reaps zombies before reading the running-task
rows, and reads each run's last_heartbeat_at before _end_run overwrites
it, so a child that exits between two reaper passes is classified from
its real exit status instead of falling into the unknown/'pid not
alive' registry-miss branch. Both clean-exit branches (route_failure and
protocol_violation) now stamp reap_status=observed so the two exit-status
races are countable; the registry-miss branch stamps reap_status=registry_miss.

Tests: test_route_failure_does_not_consume_protocol_violation_streak,
test_two_trailing_route_failures_trip_the_breaker,
test_reap_ordering_classifies_real_exit_status_not_unknown,
test_route_failure_respects_crash_grace_window,
test_reap_status_recorded_on_both_clean_exit_branches.

(cherry picked from commit 507d7beff1562a86d73f5e13407621656629e98e)
…tests

Replace the three hardcoded "hermes01:mock" literals in
test_route_failure_respects_crash_grace_window and
test_reap_status_recorded_on_both_clean_exit_branches with the same
runtime host-prefix derivation the sibling tests in this file already
use: kb._claimer_id().split(":", 1)[0]. No assertion or production
code changes.
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/cron Cron scheduler and job management labels Sep 12, 2026

@gaoanze888 gaoanze888 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.

The reap-before-classify ordering and separation from the protocol-violation streak are sensible, but the exact head 87f00c0d5820342710523b43a138b6b295ec28bc currently fails a required cross-platform test gate.

test_reap_ordering_classifies_real_exit_status_not_unknown unconditionally executes POSIX true and reads Linux-only /proc/<pid>/stat without a Linux-only marker. On macOS the focused run is 4 passed, 1 failed, because /proc is absent and the asserted zombie state remains None. Mark this test Linux-only under the repository convention, or replace the /proc dependency with a portable synchronization/exit-status fixture. git diff --check also reports a new blank line at EOF in the same test file.

Two contract gaps should be clarified and tested:

  • Route failures use a fixed limit of two rather than the task's max_retries; therefore a task configured with max_retries=1 still gets two route attempts. If this independent budget is intentional, document the override explicitly and test it.
  • Current tests inject a wait status; add a lightweight real worker/CLI path showing the key premise that a routing/credential failure exits rc=0 without a turn-start heartbeat.

Please also update the English/Chinese Kanban event documentation for the new public route_failure event and the fact that route failures do not reset/consume the protocol-violation streak. Ruff is clean.

…trailing blank line

Review on PR NousResearch#109468 at 87f00c0 flagged two cross-platform gate
failures:

- test_reap_ordering_classifies_real_exit_status_not_unknown spawns a
  real child and reads Linux-only /proc/<pid>/stat to observe its
  zombie state, unconditionally. On macOS /proc is absent and the test
  fails (4 passed, 1 failed in the focused run). Mark it with the
  repository's existing pytest.mark.linux_only convention (see
  pyproject.toml markers and its many uses under tests/hermes_cli/).
- git diff --check flagged a new blank line at EOF in the same file.

git diff --check 1c671be HEAD -- tests/hermes_cli/test_kanban_core_functionality.py
is now clean (was: 'new blank line at EOF').
Review on PR NousResearch#109468 raised two contract gaps:

- Route failures use a fixed limit of two (_ROUTE_FAILURE_FAILURE_LIMIT)
  rather than the task's max_retries, so a task configured with
  max_retries=1 still gets two route attempts. This is DELIBERATE: a
  route failure means the worker never reached the model at all, so a
  task's retry budget for its own agent work has no bearing on how many
  times to re-attempt routing/credentials before surfacing a
  persistently unreachable route. Documented explicitly in the code
  comment above _ROUTE_FAILURE_FAILURE_LIMIT and pinned by
  test_route_failure_budget_ignores_task_max_retries (a task with
  max_retries=1 still gets exactly two route-failure attempts before
  the breaker trips). Chose 'document + test' over 'read max_retries'
  because the two budgets model different things (agent-work retries
  vs. routing/credential retries) and coupling them would be the
  larger, riskier change the review did not ask for.
- Existing route-failure tests inject a wait status via
  _record_worker_exit rather than exercising a real process exit.
  Added test_route_failure_classified_from_real_worker_exit: spawns a
  genuine subprocess (sys.executable -c 'pass', portable — no POSIX-only
  command), lets the REAL reap_worker_zombies()/os.waitpid path reap it
  with no wait-status injection or monkeypatching, and asserts
  detect_crashed_workers classifies it as a route_failure. Needs no
  Linux-only marker: the process is fully reaped (not a lingering
  zombie) before the liveness check runs, so no platform-specific
  zombie-state probe is required.
@haumanto

Copy link
Copy Markdown
Author

All four points are answered in three new commits on the branch: c73c55f860c, 05f17c2fd1a, ec6f603fff.

  1. Linux-only marker. test_reap_ordering_classifies_real_exit_status_not_unknown now carries @pytest.mark.linux_only, matching the repository's own convention (I checked where the marker is registered and which existing tests use it before applying it). A macOS run is not possible in this environment, so that half of the fix rests on the marker rather than on an observed skip.
  2. Blank line at EOF. The test file now ends with a single newline; git diff --check is clean.
  3. Route-failure budget. Kept the fixed budget of two and made the override explicit in the source: a task's max_retries governs its own agent work, which says nothing about how many times to route, so a task configured with max_retries=1 still gets exactly two route attempts. test_route_failure_budget_ignores_task_max_retries pins that, with the reasoning in its docstring.
  4. Real exit path. Added test_route_failure_classified_from_real_worker_exit, which spawns a genuine process instead of injecting a wait status and drives the module's own reap/classify path, so the rc=0-without-heartbeat premise is exercised end to end. No CLI harness was needed for it.

Each new test fails when its corresponding fix is reverted, and the focused selection is green (scripts/run_tests.sh tests/hermes_cli/test_kanban_core_functionality.py -k "route_failure or reap_ordering": 7 passed).

Thanks for the careful review. The Windows probe and process-identity points you raised on the respawn-guard PR are being handled in that branch separately.

# Conflicts:
#	hermes_cli/kanban_db_dispatch.py
Task specified merging be67e1d, but upstream main advanced further to
8e11666 (248 commits ahead of our prior merge base 416a817) by the
time this task ran; merged the actual current origin/main tip instead of a
stale target, per the task's own known-limitations note that a moved main
makes the merge stale on landing.

Resolved conflict in hermes_cli/kanban_db_dispatch.py::_run_reclaim_phase:
kept both our comment (reap_worker_zombies moved into _reclaim_dead_workers)
and upstream's new reap_terminal_workers() call plus its result assignment.
…sifier-20260913b

# Conflicts:
#	hermes_cli/kanban_db_dispatch.py
#	tests/hermes_cli/test_kanban_core_functionality.py
…sifier-20260913b

# Conflicts:
#	hermes_cli/kanban_db_dispatch.py
@haumanto

haumanto commented Sep 21, 2026 •

Copy link
Copy Markdown
Author

Docs ask is addressed in 0423fbd5d4281dd840e848e1a57c161554d3d5b4 (appended commit, no rebase): the English kanban reference and its Chinese translation now document the route-failure classification, its own budget of two route attempts independent of the task's max_retries, the gave_up payload fields route_failures and route_failure_limit, and that a route failure neither consumes nor resets the protocol-violation streak.

One accuracy note so the docs match the code: the marker is not a new event name in task_events. It is the run-metadata key route_failure: true carried by the crashed payload (exit_signal: "heartbeat_null", reap_status: "observed"), so the reference describes it on that row instead of adding a route_failure row that no event emits.

@haumanto

Copy link
Copy Markdown
Author

Correction to my previous comment, and thank you for the phrasing that was right all along.

A route failure does emit its own task_events kind. _classify_dead_worker_exit builds the dead-worker record with event_kind="route_failure" and _append_event inserts that kind verbatim, so the run produces a route_failure event and no crashed event; its run outcome is still crashed. My earlier claim that no event of that name exists was wrong, and the docs had the marker attached to the wrong row.

Both the English reference and its Chinese translation are corrected in 76093f7a0f0a4909427f8a76d45b38c0226c4cab: a route_failure row now carries the payload the code actually emits (pid, claimer, exit_code, exit_signal, elapsed, route_failure, reap_status), the route_failure? / exit_signal? keys are gone from the crashed row, and the gave_up row keeps route_failures and route_failure_limit. The budget of two, its independence from the task's max_retries, and the fact that these runs neither consume nor reset the protocol-violation streak are unchanged.

…sifier-20260913b

# Conflicts:
#	tests/hermes_cli/test_kanban_core_functionality.py
The retired linux_only marker is rejected at collection on current main
(tests/_fixtures/platform_gating.py). Local refresh of PR NousResearch#109468.

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 P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants