worker_list reports zero workers while a worker is actively running, leading the coordinator to stop live work - #1932
Merged
ryanrborn merged 6 commits intoSep 23, 2026
Conversation
Two independent bugs both let an alive worker vanish from an unfiltered `worker_list`: 1. `list_children/0`'s `safe_snapshot/1` gave a worker only 500ms to answer `:snapshot` before treating it the same as a crashed child. A worker draining a burst of subprocess output (e.g. verbose `mix test` lines) can miss that window without being dead or even unusually slow — `Worker.state/1` (what `worker_show`/`worker_runs` use) has no such tight budget, which is why those correctly reported the worker as running at the same instant `worker_list` reported none. Raised the timeout to match `state/1`'s effective default. 2. The MCP `worker_list` tool silently resolves an unscoped call to a guessed default workspace (the caller's bound workspace, else the installation default). If a worker is genuinely running in a *different* workspace, that guess returns `count: 0` — indistinguishable from "nothing is running". The response now always echoes the `workspace_id` it scoped to. Reproduced both with real `Worker` GenServers (one freshly started, one stopped-then-restarted under the same task_id to mirror worker_resume) and `:sys.suspend/2` to simulate a live-but-slow-to-reply process. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ping them (bd-45tkhq) Raising the :snapshot probe timeout to 5000ms only narrowed the window a live worker could miss it in; it didn't remove the conflation of "didn't answer in time" with "does not exist" that caused the original incident. A worker still alive when the probe times out is now degraded to status: :unknown / meta.stale_probe: true, sourced from its registry key and latest Arbiter.Workers.Run row (the same fallback worker_show already uses for an exited worker), rather than dropped from the list. Also probe children concurrently via Task.async_stream instead of serially, so N live workers cost roughly one probe timeout in the worst case instead of N — the LiveView/board/dispatch callers that poll list_children/0 no longer pay a linear tax for a generous per-worker budget. Adds coverage for both: a permanently unresponsive worker surfacing through Worker.list_children/0 with the degraded shape, and a resumed worker (stop + restart under the same task_id) suspended past the old 500ms budget still appearing in Tools.worker_list/2 — the MCP-level layer the acceptance criterion names. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ort-crash, and Task.async_stream exits (bd-45tkhq)
Round-2 review findings:
- degraded_snapshot/2 used the registry key verbatim as task_id, so a
merge-queue subordinate pass (registered as `<task_id>:fixpass` /
`:conflict`) never matched its own Run row (keyed on the plain
task_id) and degraded to workspace_id: nil — invisible to
Tools.worker_list/2's workspace filter, the same bug relocated to a
narrower class of worker. Strip only the `:`-suffix before the run
lookup, keeping registry_key and task_id distinct in the emitted
snapshot.
- WorkerIndexLive.refresh/1 sorted on started_at with
{:asc, DateTime}, which has no nil clause; a degraded entry with no
matching Run row now has a nil started_at and crash-looped the
/workers page on every :worker_lifecycle event. Sort nils last
instead.
- list_children/0's Task.async_stream reducer and latest_run/1 had no
{:exit, _} handling; an exit escaping a probed worker or Ash.read!
turned the whole list into a FunctionClauseError instead of
dropping just that entry.
Also re-anchors docs/review-coverage-and-guard-policy.md's C1-C4
citations, which round 1's line insertions in worker.ex staled.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ryanrborn
force-pushed
the
bugfix/1931-worker-list-reports-zero-workers-while
branch
from
September 22, 2026 21:41
07c39be to
1a6906d
Compare
…st teardown (bd-45tkhq) Rebasing onto main pulled in #1969 (bd-aw2cyt), which added `Arbiter.Worker.Phase` — a subordinate pass (fix pass, conflict resolver, review-gate reviewer/implementer) is classified by its top-level `:role`, matched against a fixed atom set. A degraded_snapshot/2 entry didn't carry :role at all, so a wedged subordinate worker fell through to author_phase/2 and was misclassified as the task's own primary worker instead of e.g. :fixing_ci — not a visibility bug (workspace_id and task_id are still correct, so it isn't dropped from worker_list), but a correctness gap in the same code path this ticket is about. Run.role already durably carries the same value (record_run_started/1 writes to_string_or_nil(role_from_meta(...))); round-trip it back to the fixed atom set via an allowlist rather than String.to_existing_atom/1 on a DB value. Also fixed the worker_index_live_test.exs case this round's degrade-path fix added: it left two Worker GenServers (one :sys.suspended) running past the test with no on_exit teardown, which could get killed while holding the shared sandbox connection (bd-5scl0c). Use Arbiter.ProcessTeardown.stop_child/3, which quiesces before terminating, instead of a bare :sys.resume/2. mix format --check-formatted and mix credo --strict both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…st-reports-zero-workers-while # Conflicts: # docs/review-coverage-and-guard-policy.md
…st-reports-zero-workers-while
ryanrborn
deleted the
bugfix/1931-worker-list-reports-zero-workers-while
branch
September 23, 2026 17:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
worker_list(andarb worker list/arb prime) could report zero workers while a worker was genuinely alive and running, which once led the coordinator toworker_stopa live in-flight session. Two independent, compounding bugs caused this:Worker.list_children/0silently dropped a live worker that missed its:snapshotprobe.safe_snapshot/1gave a worker only 500ms to answer beforelist_children/0treated it exactly like a crashed child — a genuinely wedged/busy worker (e.g. draining a burst ofmix testoutput through its mailbox) reads no differently from one that no longer exists.Worker.state/1— whatworker_show/worker_runsuse — has no such tight budget, which is why those correctly reported the worker asrunningat the same instantworker_listreported none.Fixed by degrading instead of dropping: a worker that is confirmed
alive?but doesn't answer within the (now 5s, matchingstate/1's effective default) probe window is still emitted, as astatus: :unknown/meta.stale_probe: trueentry sourced from its latestArbiter.Workers.Runrow — the same fallbackworker_show_historical/2already uses for a worker that has actually exited. This mirrors the pattern already established for the concurrent-start guard (active_sibling/2). The per-child probe also moved from serial toTask.async_stream(bounded concurrency) so raising the timeout doesn't multiply wall-clock cost across every live worker on every LiveView refresh / dispatch check.A merge-queue subordinate pass (
<task_id>:fixpass/:conflict) registers under a suffixed registry key while itsRunrow is keyed on the plaintask_id; the degrade path strips only the:-suffix (never a review-gate#-id, which genuinely is its owntask_id) so a wedged subordinate resolves its realworkspace_idinstead of degrading toniland being dropped by finding 2's filter — the same "live worker invisible" bug, just relocated to a narrower class of worker.The MCP
worker_listtool silently resolves an unscoped call to a guessed workspace (the caller's bound workspace, else the installation default) when the caller names none. If a worker is genuinely running in a different workspace than the guess, the call returnscount: 0— indistinguishable from "nothing is running anywhere." The response now always echoes theworkspace_idit actually scoped to, so a zero count is legible instead of alarming. Confirmed operationally: this is exactly what happened live —worker_listresolved to thedefaultworkspace while the live workers were in a different one.Also fixed along the way: a degraded entry with no matching
Runrow has anilstarted_at, which crashedWorkerIndexLive'sEnum.sort_by(&1.started_at, {:asc, DateTime})(no nil clause) on every/workersrefresh; nils now sort last. Andlist_children/0's reducer/latest_run/1now handle an{:exit, _}from a probed worker orAsh.read!without turning the whole list into a crash.Root cause for the reported incident is (1): a resume-specific bug alone couldn't explain the first zero-result, which was against a freshly-dispatched worker that had never been resumed — the mailbox-timeout race applies equally to any live worker, dispatched or resumed. (2) is a related, independently reachable defect the acceptance criteria also called out.
Rebase note (round 3): landing this required rebasing onto #1969 (bd-aw2cyt), which added
Arbiter.Worker.Phase— a subordinate pass is classified by its top-level:role, matched against a fixed atom set. A degraded entry didn't carry:roleat all, so a wedged subordinate fell through toauthor_phase/2and was misclassified as the task's own primary worker instead of e.g.:fixing_ci(not a visibility bug —workspace_id/task_idwere already correct post-rebase — but a correctness gap in the same code path).Run.rolealready durably carries the valuerecord_run_started/1writes frommeta[:role]; round-tripped it back through a fixed allowlist.Merge note (round 4): the branch fell behind
mainagain (#1980-#1984 landed, none touching this logic) and conflicted only on stalefile:linecitations indocs/review-coverage-and-guard-policy.mdpointing intoapps/arbiter/lib/arbiter/worker.ex. Mergedorigin/main, updated the citations to the post-merge line numbers; no functional code conflicts (worker.ex,tools/worker.exetc. auto-merged cleanly).Test plan
apps/arbiter/test/arbiter/worker_test.exs— a live worker suspended (:sys.suspend/2) past the old 500ms budget is degraded (not dropped) with a non-niltask_id/workspace_id; a subordinate-registry-key worker (<task_id>:fixpass) degrades with the correct unsuffixedtask_id, a non-nilworkspace_id, and the correct:role; a worker stopped and restarted under the sametask_id(mirrorsworker_resumeat theWorkerlevel) is not dropped while briefly unresponsive.apps/arbiter/test/arbiter/mcp/tools_test.exs— the resume scenario above surfaces throughTools.worker_list/2itself (not justlist_children/0); an agnostic coordinator's unscopedworker_listcall names theworkspace_idit scoped to even when a worker is alive in a different workspace.mix precommit(compile --warnings-as-errors, format, full umbrella test suite),mix format --check-formatted,mix credo --strict— all green after rebasing onto currentmain.worker_test.exs+mcp/tools_test.exs+worker_shutdown_test.exs(396 tests, 0 failures); fullmix precommit(compile clean, format clean, 1645+836 tests). The only failures are the pre-documentedARB_WORKSPACE=defaultworker-sandbox artifact inarbiter_cli(create_test.exs,release_deploy_test.exs, 2 tests) — confirmed pre-existing and unrelated to this change by re-running withARB_*unset (66/66 pass).References
bd-45tkhq. Closes #1931.
🤖 Generated with Claude Code