Skip to content

worker_list reports zero workers while a worker is actively running, leading the coordinator to stop live work - #1932

Merged
ryanrborn merged 6 commits into
mainfrom
bugfix/1931-worker-list-reports-zero-workers-while
Sep 23, 2026
Merged

ryanrborn merged 6 commits into
mainfrom
bugfix/1931-worker-list-reports-zero-workers-while

Conversation

@ryanrborn

@ryanrborn ryanrborn commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Summary

worker_list (and arb worker list / arb prime) could report zero workers while a worker was genuinely alive and running, which once led the coordinator to worker_stop a live in-flight session. Two independent, compounding bugs caused this:

  1. Worker.list_children/0 silently dropped a live worker that missed its :snapshot probe. safe_snapshot/1 gave a worker only 500ms to answer before list_children/0 treated it exactly like a crashed child — a genuinely wedged/busy worker (e.g. draining a burst of mix test output through its mailbox) reads no differently from one that no longer exists. 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.

    Fixed by degrading instead of dropping: a worker that is confirmed alive? but doesn't answer within the (now 5s, matching state/1's effective default) probe window is still emitted, as a status: :unknown / meta.stale_probe: true entry sourced from its latest Arbiter.Workers.Run row — the same fallback worker_show_historical/2 already 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 to Task.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 its Run row is keyed on the plain task_id; the degrade path strips only the :-suffix (never a review-gate #-id, which genuinely is its own task_id) so a wedged subordinate resolves its real workspace_id instead of degrading to nil and being dropped by finding 2's filter — the same "live worker invisible" bug, just relocated to a narrower class of worker.

  2. The MCP worker_list tool 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 returns count: 0 — indistinguishable from "nothing is running anywhere." The response now always echoes the workspace_id it actually scoped to, so a zero count is legible instead of alarming. Confirmed operationally: this is exactly what happened live — worker_list resolved to the default workspace while the live workers were in a different one.

Also fixed along the way: a degraded entry with no matching Run row has a nil started_at, which crashed WorkerIndexLive's Enum.sort_by(&1.started_at, {:asc, DateTime}) (no nil clause) on every /workers refresh; nils now sort last. And list_children/0's reducer/latest_run/1 now handle an {:exit, _} from a probed worker or Ash.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 :role at all, so a wedged subordinate 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/task_id were already correct post-rebase — but a correctness gap in the same code path). Run.role already durably carries the value record_run_started/1 writes from meta[:role]; round-tripped it back through a fixed allowlist.

Merge note (round 4): the branch fell behind main again (#1980-#1984 landed, none touching this logic) and conflicted only on stale file:line citations in docs/review-coverage-and-guard-policy.md pointing into apps/arbiter/lib/arbiter/worker.ex. Merged origin/main, updated the citations to the post-merge line numbers; no functional code conflicts (worker.ex, tools/worker.ex etc. 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-nil task_id/workspace_id; a subordinate-registry-key worker (<task_id>:fixpass) degrades with the correct unsuffixed task_id, a non-nil workspace_id, and the correct :role; a worker stopped and restarted under the same task_id (mirrors worker_resume at the Worker level) is not dropped while briefly unresponsive.
  • apps/arbiter/test/arbiter/mcp/tools_test.exs — the resume scenario above surfaces through Tools.worker_list/2 itself (not just list_children/0); an agnostic coordinator's unscoped worker_list call names the workspace_id it 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 current main.
  • Re-verified after the round-4 merge: worker_test.exs + mcp/tools_test.exs + worker_shutdown_test.exs (396 tests, 0 failures); full mix precommit (compile clean, format clean, 1645+836 tests). The only failures are the pre-documented ARB_WORKSPACE=default worker-sandbox artifact in arbiter_cli (create_test.exs, release_deploy_test.exs, 2 tests) — confirmed pre-existing and unrelated to this change by re-running with ARB_* unset (66/66 pass).

References

bd-45tkhq. Closes #1931.

🤖 Generated with Claude Code

ryanrborn and others added 3 commits September 22, 2026 17:09
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
ryanrborn force-pushed the bugfix/1931-worker-list-reports-zero-workers-while branch from 07c39be to 1a6906d Compare September 22, 2026 21:41
ryanrborn and others added 3 commits September 22, 2026 17:56
…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
@ryanrborn
ryanrborn merged commit 857d7ec into main Sep 23, 2026
3 checks passed
@ryanrborn
ryanrborn deleted the bugfix/1931-worker-list-reports-zero-workers-while branch September 23, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

worker_list reports zero workers while a worker is actively running, leading the coordinator to stop live work

1 participant