Skip to content

fix(kanban): prevent terminal-task retries and duplicate writers - #78943

Open
reefmind wants to merge 6 commits into
NousResearch:mainfrom
reefmind:fix/kanban-process-retry-safety
Open

reefmind wants to merge 6 commits into
NousResearch:mainfrom
reefmind:fix/kanban-process-retry-safety

Conversation

@reefmind

@reefmind reefmind commented Aug 4, 2026 •

Copy link
Copy Markdown

Summary

  • make retry-exhaustion breaker decisions durable across recomputation, config changes, dispatcher restarts, and legacy-board migration until an explicit operator reset
  • fence timed-out, stale, crashed, and reclaimed workers by verified host/process identity and process group before any replacement claim can succeed
  • make task/review claims, reclaim, failure accounting, worker exit handling, terminal transitions, and fence release atomic and run-aware
  • prevent PID/PGID reuse, foreign-host PIDs, non-group-leading spawns, and stale fence readers from signalling or clearing unrelated workers
  • validate assignees and project/worktree resolution before dispatch, and clear lifecycle residue on terminal/archive paths

Incidents closed

  • a gave_up task could be auto-promoted and redispatched with contradictory notifications
  • an iteration-budget timeout could leave a live Claude child process group while a retry launched a second writer in the same workspace

Closes #71175.

Relationship to existing work

  • fix(kanban): hold claim on iteration-budget exhaustion (#71175) #71189 retains a claim on iteration exhaustion, which narrows the immediate respawn window but can still close current_run_id while the old worker continues. This PR instead makes every failure/finalization path attempt-bound and refuses replacement claims until the prior verified process group is gone.
  • fix(kanban): make worker termination and retries fail closed #69942 is a broader draft from an older base covering terminal tool barriers, environment refresh, notification semantics, hooks, and retry policy. This PR is the current-main, Kanban-DB-focused lifecycle/process fencing slice; it does not duplicate the unrelated terminal, environment, notifier, or hook changes.

Verification

  • focused exact-head verification: 98 passed, 1 skipped
  • implementation canonical Kanban runner: 338 passed, 0 failed
  • process retry safety suite: 43 passed
  • stress suites: property fuzzing, concurrency, mixed concurrency, and reclaim race — all invariants held
  • Ruff and git diff --check: clean
  • independent exact-head safety reviews: round 4 PASS, round 5 PASS, final round 6 PASS
  • independent probes used real two-process SQLite races, orphaned child process groups, PID/PGID identity checks, foreign-host evidence, legacy migration, and fence replacement CAS checks

A broader one-process Kanban selection retains the same pre-existing cross-file pollution failure set on origin/main; no branch-specific failures were added.

Safety / rollout

  • no live Kanban database migration was run
  • no gateway/service restart was performed
  • no production workload was used as a dispatcher test target
  • migration/backfill occurs through the normal schema initialization path after merge/deploy

@alt-glitch alt-glitch added type/bug Something isn't working comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation labels Aug 4, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Related: #71175 reports the live-worker retry race, while #69942 is a broader open fail-closed worker/retry repair. This PR takes a focused Kanban DB fencing and breaker approach.

@reefmind

reefmind commented Aug 4, 2026

Copy link
Copy Markdown
Author

CI is currently blocked at the fork-approval gate (action_required on run 30957385128), rather than reporting a code/test failure. Could a maintainer approve the workflow run when convenient? The pushed exact head is 2f7bef68731674356ab2e1068c979fc57d5f3929; local exact-head and independent review evidence is in the PR body.

@reefmind
reefmind force-pushed the fix/kanban-process-retry-safety branch 2 times, most recently from c2b6811 to a3359d8 Compare August 5, 2026 15:31
Brett Miller and others added 6 commits August 5, 2026 08:31
… before a task is claimable again

Two production incidents, one lifecycle contract: a task must not be handed
to a new worker while the previous decision about it is still binding.

A) t_27263082 recorded gave_up with effective_limit=2/limit_source=dispatcher
after two spawn failures, then a later recompute_ready promoted it and the
dispatcher retried it. The NousResearch#35072 guard re-resolved its OWN effective limit
(per-task max_retries → caller failure_limit → DEFAULT_FAILURE_LIMIT), so any
caller that passed a laxer limit — a restarted gateway falling back to the
default, tools/dashboard callers that don't thread kanban.failure_limit
through, or a raised config value — decided the task was still under budget.

The trip is now recorded on the task (tasks.breaker_limit, mirroring the
gave_up payload) and its PRESENCE binds every promotion caller. Presence, not
a counter comparison: force_trip callers (systemic-error fingerprints, the
protocol-violation streak) record gave_up with failures below the limit, and
comparing let exactly those tasks promote themselves on the next tick. The
stamp rides the same status-guarded UPDATE as the blocked transition, so a
task that completes under the reaper is neither transitioned nor stamped.
Legacy rows are backfilled from the event log on migration, with the same
fallback at read time, keyed off a single list of reset events that mirrors
the code paths clearing the column. Deliberate fresh starts still clear it:
kanban_unblock, manual promote, operator reclaim, successful completion.
Tasks that never tripped keep NousResearch#35072's lenient-config recovery, and
worker-initiated sticky blocks (NousResearch#28712) are untouched.

B) t_80a3542a run 141 hit its budget while worker PID 98725 stayed alive,
reparented to PID 1 with live Claude children. The runtime cap signalled the
PID only, never confirmed the process group was gone, flipped the task to
ready anyway, and the dispatcher claimed run 152 and spawned PID 57286
against the same task and workspace.

Termination now targets the worker's process group (workers already get their
own session, so the group is the whole tree) with zombie-aware, group-wide
liveness. Every requeue path — runtime cap, TTL expiry, heartbeat staleness,
crash reaping, operator reclaim — reaches the same decision: if the worker
LEADER survived termination, hold the claim (it is throttled or wedged, and
the next tick retries); if only its children still hold the group, requeue
but record a durable worker_fence (pid, pgid, identity, run id, claim lock),
and claim_task rejects at the single choke point every spawn path goes
through until that group exits. The fence lives on the row, so it survives a
gateway restart.

A PID is a recycled number, so nothing destructive or binding rests on one
alone. Each worker's leader identity (start time) is captured at spawn:
group signals are sent only while that identity still verifies — no proof
means pid-only signalling, and a proven mismatch means our worker is already
gone — and a fence resolves the moment its identity changes. Only a positive
identity match earns an unbounded hold; every weaker signal (a live pid with
no identity token, a group whose leader we cannot verify) is evidence rather
than proof and expires after WORKER_FENCE_MAX_SECONDS rather than stranding
the task. A fence is also pinned to the host that recorded it: a local pid
number says nothing about a remote worker, and we would never see that
worker exit.

The fence is part of the claim CAS, not a separate look: the gate probes
the OS and so cannot run under the write lock, which leaves a window where a
reaper commits ready + a fresh fence while a claimer sits between gate and
CAS. Both claim gates (ready and review) now carry `worker_fence IS NULL` in
the compare-and-swap itself. For the same reason the crash reaper does its
liveness and process-group probing BEFORE opening its transaction, then
re-reads the row under the lock and acts only if status, pid and claim lock
are unchanged — a `ps` scan inside BEGIN IMMEDIATE stalls every other writer
on the board.

Clearing a fence is itself a compare-and-swap against the exact serialized
fence the caller evaluated, so a claim gate or reclaim that decided one
fence was releasable can never delete a newer one recorded while it probed.
In reclaim that CAS is bound into the same UPDATE as the release, so a
refusal writes nothing at all rather than leaving the task released with no
reclaimed event and an open run row still pointed at by current_run_id.
And a pgid is persisted only when the OS confirms the worker leads that
group (pgid == pid, which start_new_session guarantees for real workers;
a failed getpgid records nothing rather than assuming leadership): a worker that merely
joined a group would otherwise record the gateway's own, and signalling that
would take down the dispatcher and every sibling worker.

Operators keep explicit exits throughout. unblock and promote release a
fence outright; reclaim applies to a task fenced while sitting in ready and
re-probes it first — reading pid/pgid/identity from the fence payload, since
a requeued row deliberately carries none of them, and committing the
clear-or-keep decision in the same transaction as the release so no
dispatcher tick can claim through a half-applied reclaim. An operator
reclaim is therefore never more permissive than an ordinary claim: a live
orphan group stays fenced. Platforms without process groups or an identity
probe (Windows) fail closed: no group signalling, no group-based liveness,
pre-existing PID-only behaviour.

Also fixes a test that stubbed _pid_alive on a re-imported module: with an
earlier test evicting hermes_cli from sys.modules the stub missed, leaving
the real probe running against pytest's own pid.
The board is meant to stay continuously automated, but the worker guidance
told every coding worker to park its own card in `blocked` with a
`review-required:` reason and wait for a person to approve+unblock. That made
`blocked` the default terminal state for the most common task type, so an
unattended board stalled on the first code change.

Reviewable code now fans out as cards instead of stalling:

- The implementation worker creates a `ready` review card assigned to a real
  installed reviewer profile (normally `pr-reviewer`), carrying exact
  workspace / repo / base SHA / head SHA / changed files / tests /
  acceptance evidence, then completes its own card listing the review card in
  `created_cards`.
- Reviewer PASS completes the review card. Reviewer BLOCK creates a
  remediation card for the original implementation profile rather than
  blocking — a rejected review is normal, not a stuck board.
- The remediation worker fixes the findings and creates a fresh review card at
  the new exact head SHA; reviews bind to one SHA and are never re-opened.
- If no valid reviewer profile is installed, the worker surfaces
  `needs_input` instead of inventing an assignee. The rule that unknown
  assignees must be discovered and validated is unchanged.

`blocked` keeps its narrow meaning and is now stated explicitly: irretrievable
human input, a hard capability/access wall, or a truly transient failure after
retries. It is never a dispatcher fence, a status update, a review queue, an
ordinary dependency wait, or a substitute for child tasks — dependencies are
DAG edges via `parents=[...]`. No safety block or DB sticky-block semantics
are weakened; this is guidance text only.

Covered by prompt contract tests asserting the automated review DAG is present
and the old mandatory review-required block instruction is gone.
Failing closed on an unverifiable PID had been applied to the pid as well
as the process group. A running worker whose row carried no
worker_identity (legacy row, or one written before the token landed) was
therefore never signalled on Linux/macOS: the reclaim paths recorded a
fence and moved on. A fence with no identity token is weak evidence by
design and expires after WORKER_FENCE_MAX_SECONDS, so the worker aged out
of its own fence and the claim gate handed the task to a replacement —
the t_80a3542a duplicate-writer state the fence exists to prevent.

Narrow the fail-closed rule to what it was actually protecting. A
recorded identity that does NOT match a live PID still signals nothing
(the number was recycled; our worker is gone). No recorded identity at
all now signals the bare PID — the pre-existing behaviour, and the only
handle we ever had on that worker — bounded to SIGTERM then SIGKILL with
the existing wall-clock/probe caps. The process group still requires a
proven identity, so the blast radius never widens without proof.

Survival is judged on the same basis we signalled: with no identity,
leader_alive reads the bare PID, so a survivor is HELD by
_worker_survived_termination (claim retained, next tick retries the kill)
instead of falling through to a bounded fence.

reclaim_task is the one path that fences a live leader rather than
holding it, because the operator asked for the release. It now pins the
identity observed during termination, turning that hold on a recyclable
number into a provable one: unbounded while it matches, self-releasing as
worker_identity_changed once the PID is recycled.

Also corrects the _terminate_reclaimed_worker docstring, which described
the bare-PID fallback that the code no longer took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t in triage

`block_task` routed a repeated same-cause block (`block_recurrences >=
BLOCK_RECURRENCE_LIMIT`) to `status='triage'` and emitted
`block_loop_detected`. But triage is the auto-decomposer's *input queue*,
not a human hold: with the default `kanban.auto_decompose: true` the
dispatcher treats every triage row as a decompose/specify candidate. On the
live default board `t_bdf1001a` was escalated to triage at event 2955 / run
233, sat there ~30s, then moved to `todo` with no lifecycle event and would
have gone `ready` — the exact blocked-task resurrection this branch exists to
stop. `t_80a3542a` hit the same class.

The escalation now stays in `blocked` and the `block_loop_detected` event
makes that block sticky:

* `_has_sticky_block` treats `block_loop_detected` like `blocked`, so
  `recompute_ready`, claim, restart/migration and the dispatcher tick all
  leave it alone. An explicit `unblock_task` still resumes it (it emits
  `unblocked`, which clears the predicate).
* New `has_block_loop_escalation()` is defense in depth on the orchestration
  paths: `specify_triage_task`, `decompose_triage_task`, `decompose_task` and
  `list_triage_ids` all refuse a row carrying an escalation no matter which
  column it sits in. Ordinary triage cards keep auto-decomposing.
* `_migrate_block_loop_escalations_out_of_triage` lifts legacy rows already
  parked in triage (block_kind set + latest block-lifecycle event is
  `block_loop_detected`) into the sticky blocked state on init, so activating
  this build doesn't auto-decompose them. Narrow by construction: plain triage
  cards and already-unblocked rows are untouched.
* Notifier ping and docs/comments reworded off the old triage routing.

Regression: tests/hermes_cli/test_kanban_block_loop_sticky.py (7 tests) —
red before the fix on the real block -> unblock -> re-block -> dispatcher
path, green after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ns are stale

Review BLOCK on a0cfd412b: `_migrate_block_loop_escalations_out_of_triage`
ran *before* `_rebuild_drifted_tables`. The escalation migration decides
whether a row is still escalated by reading the newest block-lifecycle event
with `ORDER BY id DESC`, but on a pre-NousResearch#35096 board `task_events.id` is still
TEXT — so that ordering is lexicographic and `ev-9` outranks `ev-10`.

A legacy triage card whose real history ends `block_loop_detected (ev-9)`,
`unblocked (ev-10)` therefore read as still escalated: the migration flipped
it triage -> blocked, the rebuild then renumbered the ids to chronological
INTEGERs, `_has_sticky_block` read the true tail (`unblocked`) and returned
False, and the next `recompute_ready` promoted the card to `ready` for a
worker to claim — the same resurrection this branch exists to stop, reached
through a different door.

Fix is the ordering: rebuild the drifted tables first, then run the escalation
migration over correct ids. Genuinely-still-escalated legacy rows continue to
become sticky blocked; ordinary triage cards are still untouched.

Regression: test_migration_reads_event_order_after_text_id_drift_is_repaired
builds a realistic drifted TEXT-id board (>= 10 events per row, the hazard
pinned explicitly before init) and asserts the cleared card stays in triage
and never becomes ready/claimable. Red before this commit
(`assert 'blocked' == 'triage'`, with the migration logging "moved 2
escalation(s)" ahead of "rebuilding task_events"), green after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@reefmind
reefmind force-pushed the fix/kanban-process-retry-safety branch from a3359d8 to 3a5bcf3 Compare August 5, 2026 15:34

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

s

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 needs-decision Awaiting maintainer decision before any implementation 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.

[Bug]: iteration-budget exhaustion un-claims a LIVE goal-mode Kanban worker; the dispatcher then spawns a second writer onto the same worktree

3 participants