[kanban] archive_task: kill the worker process instead of leaving it orphaned - #42858
[kanban] archive_task: kill the worker process instead of leaving it orphaned#42858PINKIIILQWQ wants to merge 1 commit into
Conversation
archive_task() was a pure DB operation — it cleared worker_pid, claim_lock, and status from the tasks row but never sent SIGTERM to the actual OS process. A running worker stayed alive until it next called kanban_complete/kanban_block and discovered it was archived, burning API quota and compute resources. Fix: snapshot pid+claim_lock before the write_txn clears them, then call _terminate_reclaimed_worker() — same function reclaim_task uses — which sends SIGTERM, waits 5s, then SIGKILL if still alive. The termination metadata is included in the 'archived' event so operators can see what happened. Order matches reclaim_task: terminate first, then DB update. For non-running / non-local tasks, _terminate_reclaimed_worker returns immediately as a no-op. Closes NousResearch#33774 reprise: the scratch-workspace side was fixed in fc8afd5, but the orphaned-process side was never addressed.
2049784 to
a93fb37
Compare
tonydwb
left a comment
There was a problem hiding this comment.
Code Review: Approved. Good bug fix - archive_task now kills the worker process instead of leaving it orphaned. Clean implementation.
release_stale_claims and detect_stale_running call _terminate_reclaimed_worker and then release the task claim unconditionally, even when the termination did not actually kill the worker. _terminate_reclaimed_worker already reports this via its "terminated" flag, but the callers ignore it. When a worker is parked in uninterruptible (D) state — for example throttled by a cgroup memory.high limit — a pending SIGTERM/SIGKILL cannot be delivered until the throttle lifts, so the kill is a no-op. The dispatcher then frees the claim and spawns a fresh worker beside the still-alive one. Repeated every dispatch tick this accumulates duplicate workers without bound, deepening the memory pressure that caused the throttle in the first place — a self-reinforcing runaway. Fix: gate both automatic reclaim paths on _worker_survived_termination(). When we attempted to kill our own host-local worker and it is still alive, defer the reclaim (_defer_reclaim_for_live_worker extends the claim a short grace and emits a reclaim_deferred event) instead of releasing. This guarantees at most one live worker per task and is self-correcting: not spawning a duplicate is what relieves the pressure so the pending signal lands and the worker dies, and the next tick reclaims cleanly. Non-host-local claims and the operator-driven reclaim_task() path keep their existing force-release behaviour. Related: #41448 (concurrent dispatchers amplify this by doubling reclaim frequency); #42858 (kill the worker rather than orphan it on archive). Tests: defer-when-worker-survives, reclaim-when-killed, release-when-not-host-local, and the detect_stale_running path.
release_stale_claims and detect_stale_running call _terminate_reclaimed_worker and then release the task claim unconditionally, even when the termination did not actually kill the worker. _terminate_reclaimed_worker already reports this via its "terminated" flag, but the callers ignore it. When a worker is parked in uninterruptible (D) state — for example throttled by a cgroup memory.high limit — a pending SIGTERM/SIGKILL cannot be delivered until the throttle lifts, so the kill is a no-op. The dispatcher then frees the claim and spawns a fresh worker beside the still-alive one. Repeated every dispatch tick this accumulates duplicate workers without bound, deepening the memory pressure that caused the throttle in the first place — a self-reinforcing runaway. Fix: gate both automatic reclaim paths on _worker_survived_termination(). When we attempted to kill our own host-local worker and it is still alive, defer the reclaim (_defer_reclaim_for_live_worker extends the claim a short grace and emits a reclaim_deferred event) instead of releasing. This guarantees at most one live worker per task and is self-correcting: not spawning a duplicate is what relieves the pressure so the pending signal lands and the worker dies, and the next tick reclaims cleanly. Non-host-local claims and the operator-driven reclaim_task() path keep their existing force-release behaviour. Related: #41448 (concurrent dispatchers amplify this by doubling reclaim frequency); #42858 (kill the worker rather than orphan it on archive). Tests: defer-when-worker-survives, reclaim-when-killed, release-when-not-host-local, and the detect_stale_running path.
release_stale_claims and detect_stale_running call _terminate_reclaimed_worker and then release the task claim unconditionally, even when the termination did not actually kill the worker. _terminate_reclaimed_worker already reports this via its "terminated" flag, but the callers ignore it. When a worker is parked in uninterruptible (D) state — for example throttled by a cgroup memory.high limit — a pending SIGTERM/SIGKILL cannot be delivered until the throttle lifts, so the kill is a no-op. The dispatcher then frees the claim and spawns a fresh worker beside the still-alive one. Repeated every dispatch tick this accumulates duplicate workers without bound, deepening the memory pressure that caused the throttle in the first place — a self-reinforcing runaway. Fix: gate both automatic reclaim paths on _worker_survived_termination(). When we attempted to kill our own host-local worker and it is still alive, defer the reclaim (_defer_reclaim_for_live_worker extends the claim a short grace and emits a reclaim_deferred event) instead of releasing. This guarantees at most one live worker per task and is self-correcting: not spawning a duplicate is what relieves the pressure so the pending signal lands and the worker dies, and the next tick reclaims cleanly. Non-host-local claims and the operator-driven reclaim_task() path keep their existing force-release behaviour. Related: NousResearch#41448 (concurrent dispatchers amplify this by doubling reclaim frequency); NousResearch#42858 (kill the worker rather than orphan it on archive). Tests: defer-when-worker-survives, reclaim-when-killed, release-when-not-host-local, and the detect_stale_running path.
release_stale_claims and detect_stale_running call _terminate_reclaimed_worker and then release the task claim unconditionally, even when the termination did not actually kill the worker. _terminate_reclaimed_worker already reports this via its "terminated" flag, but the callers ignore it. When a worker is parked in uninterruptible (D) state — for example throttled by a cgroup memory.high limit — a pending SIGTERM/SIGKILL cannot be delivered until the throttle lifts, so the kill is a no-op. The dispatcher then frees the claim and spawns a fresh worker beside the still-alive one. Repeated every dispatch tick this accumulates duplicate workers without bound, deepening the memory pressure that caused the throttle in the first place — a self-reinforcing runaway. Fix: gate both automatic reclaim paths on _worker_survived_termination(). When we attempted to kill our own host-local worker and it is still alive, defer the reclaim (_defer_reclaim_for_live_worker extends the claim a short grace and emits a reclaim_deferred event) instead of releasing. This guarantees at most one live worker per task and is self-correcting: not spawning a duplicate is what relieves the pressure so the pending signal lands and the worker dies, and the next tick reclaims cleanly. Non-host-local claims and the operator-driven reclaim_task() path keep their existing force-release behaviour. Related: NousResearch#41448 (concurrent dispatchers amplify this by doubling reclaim frequency); NousResearch#42858 (kill the worker rather than orphan it on archive). Tests: defer-when-worker-survives, reclaim-when-killed, release-when-not-host-local, and the detect_stale_running path.
release_stale_claims and detect_stale_running call _terminate_reclaimed_worker and then release the task claim unconditionally, even when the termination did not actually kill the worker. _terminate_reclaimed_worker already reports this via its "terminated" flag, but the callers ignore it. When a worker is parked in uninterruptible (D) state — for example throttled by a cgroup memory.high limit — a pending SIGTERM/SIGKILL cannot be delivered until the throttle lifts, so the kill is a no-op. The dispatcher then frees the claim and spawns a fresh worker beside the still-alive one. Repeated every dispatch tick this accumulates duplicate workers without bound, deepening the memory pressure that caused the throttle in the first place — a self-reinforcing runaway. Fix: gate both automatic reclaim paths on _worker_survived_termination(). When we attempted to kill our own host-local worker and it is still alive, defer the reclaim (_defer_reclaim_for_live_worker extends the claim a short grace and emits a reclaim_deferred event) instead of releasing. This guarantees at most one live worker per task and is self-correcting: not spawning a duplicate is what relieves the pressure so the pending signal lands and the worker dies, and the next tick reclaims cleanly. Non-host-local claims and the operator-driven reclaim_task() path keep their existing force-release behaviour. Related: NousResearch#41448 (concurrent dispatchers amplify this by doubling reclaim frequency); NousResearch#42858 (kill the worker rather than orphan it on archive). Tests: defer-when-worker-survives, reclaim-when-killed, release-when-not-host-local, and the detect_stale_running path.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for addressing a real cleanup gap: current archive_task() clears worker_pid without invoking the existing termination helper (hermes_cli/kanban_db.py:5431-5449).
Problems
- The new call at
hermes_cli/kanban_db.py:4617occurs after an unlocked snapshot but before the archive UPDATE. A concurrent caller can win the archive transition while this caller still signals the previously read PID, even though its UPDATE later affects zero rows. Please make the snapshot/termination contingent on this invocation winning the archive transition. - The PR changes only
hermes_cli/kanban_db.py; it adds no regression coverage. Existing archive-run coverage (tests/hermes_cli/test_kanban_core_functionality.py:1952) checks closure of the run, not worker termination or archived-event metadata.
Suggested changes
- Add a monkeypatched termination test for a local PID/claim and assert the archived event payload; cover a non-local claim as a no-op.
Automated hermes-sweeper review.
| ).fetchone() | ||
| prev_pid = row["worker_pid"] if row else None | ||
| prev_lock = row["claim_lock"] if row else None | ||
| # Terminate the worker process first (same order as reclaim_task). |
There was a problem hiding this comment.
This signal is sent before the archive UPDATE establishes that this invocation won the transition. Two concurrent archive requests can both read the old PID; the loser can still terminate it even when its conditional UPDATE returns zero rows. Please serialize the snapshot with the successful archive transition so termination is only performed for the state change this call owns.
release_stale_claims and detect_stale_running call _terminate_reclaimed_worker and then release the task claim unconditionally, even when the termination did not actually kill the worker. _terminate_reclaimed_worker already reports this via its "terminated" flag, but the callers ignore it. When a worker is parked in uninterruptible (D) state — for example throttled by a cgroup memory.high limit — a pending SIGTERM/SIGKILL cannot be delivered until the throttle lifts, so the kill is a no-op. The dispatcher then frees the claim and spawns a fresh worker beside the still-alive one. Repeated every dispatch tick this accumulates duplicate workers without bound, deepening the memory pressure that caused the throttle in the first place — a self-reinforcing runaway. Fix: gate both automatic reclaim paths on _worker_survived_termination(). When we attempted to kill our own host-local worker and it is still alive, defer the reclaim (_defer_reclaim_for_live_worker extends the claim a short grace and emits a reclaim_deferred event) instead of releasing. This guarantees at most one live worker per task and is self-correcting: not spawning a duplicate is what relieves the pressure so the pending signal lands and the worker dies, and the next tick reclaims cleanly. Non-host-local claims and the operator-driven reclaim_task() path keep their existing force-release behaviour. Related: NousResearch#41448 (concurrent dispatchers amplify this by doubling reclaim frequency); NousResearch#42858 (kill the worker rather than orphan it on archive). Tests: defer-when-worker-survives, reclaim-when-killed, release-when-not-host-local, and the detect_stale_running path.
release_stale_claims and detect_stale_running call _terminate_reclaimed_worker and then release the task claim unconditionally, even when the termination did not actually kill the worker. _terminate_reclaimed_worker already reports this via its "terminated" flag, but the callers ignore it. When a worker is parked in uninterruptible (D) state — for example throttled by a cgroup memory.high limit — a pending SIGTERM/SIGKILL cannot be delivered until the throttle lifts, so the kill is a no-op. The dispatcher then frees the claim and spawns a fresh worker beside the still-alive one. Repeated every dispatch tick this accumulates duplicate workers without bound, deepening the memory pressure that caused the throttle in the first place — a self-reinforcing runaway. Fix: gate both automatic reclaim paths on _worker_survived_termination(). When we attempted to kill our own host-local worker and it is still alive, defer the reclaim (_defer_reclaim_for_live_worker extends the claim a short grace and emits a reclaim_deferred event) instead of releasing. This guarantees at most one live worker per task and is self-correcting: not spawning a duplicate is what relieves the pressure so the pending signal lands and the worker dies, and the next tick reclaims cleanly. Non-host-local claims and the operator-driven reclaim_task() path keep their existing force-release behaviour. Related: NousResearch#41448 (concurrent dispatchers amplify this by doubling reclaim frequency); NousResearch#42858 (kill the worker rather than orphan it on archive). Tests: defer-when-worker-survives, reclaim-when-killed, release-when-not-host-local, and the detect_stale_running path.
Problem
archive_task()is a pure-DB operation — itUPDATE status='archived', worker_pid=NULLbut never sendsSIGTERMto the OS process. The worker stays alive as an orphan until it next callskanban_complete/kanban_blockand discovers its task was archived, burning API quota and compute.Root cause:
archive_task()was written as a counterpart todelete_archived_task()— a data-layer operation that only touches rows. The kill responsibility was never added.Compare with
reclaim_task()(L3273) which correctly snapshotsworker_pid+claim_lockbefore the DB update and calls_terminate_reclaimed_worker()— sendingSIGTERM→ 5s wait →SIGKILLif needed.Fix
18 lines added to
archive_task(), following the same order asreclaim_task:worker_pid+claim_lockvia SELECT before the write_txn clears them_terminate_reclaimed_worker(pid, lock)— same function, same SIGTERM→SIGKILL sequenceprev_pid, terminated, sigkill, host_local) in the 'archived' event payloadNon-running / non-local tasks are safe no-ops (
_terminate_reclaimed_workerreturns immediately when pid is None or claim_lock doesn't match the local host prefix).Risk
Low. Pattern is identical to
reclaim_task(production-stable). No schema change, no API change, no test breakage.Related