Skip to content

fix(swarm): release domain-pool agents after terminal tasks - #4247

Merged
ruvnet merged 2 commits into
ruvnet:mainfrom
Tong-bit-art:fix/domain-pool-release-terminal
Oct 17, 2026
Merged

ruvnet merged 2 commits into
ruvnet:mainfrom
Tong-bit-art:fix/domain-pool-release-terminal

Conversation

@Tong-bit-art

@Tong-bit-art Tong-bit-art commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem and change

UnifiedSwarmCoordinator.assignTaskToDomain() acquires its worker through AgentPool.acquire(), which moves that agent from available to busy. The terminal task paths only reset the shared AgentState:

  • handleTaskComplete() sets agent.status = 'idle' / currentTask = undefined
  • handleTaskFail()'s final-failure branch does the same
  • cancelTask() does the same

None of them call releaseAgentToDomain(). AgentPool.release() is the only operation that removes the id from busy and returns it to available; releaseAgentToDomain() is also the only code that drains domainTaskQueues. It had zero callers in src/ (git grep at 1882006a0; git log -S releaseAgentToDomain shows only the introducing commit 53cfd8a53).

Consequences in the documented API flow (README assignTaskToDomain() / executeParallel(), and QueenCoordinator.executeDelegation()):

  • each terminal domain task permanently consumes one pool slot;
  • once a domain pool reaches its fixed cap (core 5, security/integration/support 3, queen 1), pool.acquire() returns undefined and further tasks sit in domainTaskQueues;
  • nothing drains those queues, so getStatus() shows availableAgents: 0 / tasksQueued growing and executeParallel() waits until its task timeout.

Change: release the acquired domain agent after a non-terminal to terminal transition, on completion, terminal failure, and cancellation. The release also assigns the next queued task for that domain. Retries are deliberately unchanged: handleTaskFail() re-queues through assignTask(), which may hand the task back to the same agent, so releasing there would race that reassignment.

Two drain-path guards are part of the same change because making release() reachable exposes both failure modes:

  • AgentPool.release() normally calls checkScaling(), and a one-agent domain (queen, maxSize: 1) would scale to zero before the queued task can be dispatched; the next acquire() would then create a pool-only agent absent from coordinator state. release() accepts an optional { rescale: false }, used when an assignable task is waiting, so the freed slot is handed to that task first.
  • A cancelled or timed-out task can remain in domainTaskQueues. The drain now skips any entry whose status is no longer queued/created, so releasing a slot cannot resurrect a terminal task and send it to a worker.

Validation

  • 6 new public-path regressions in v3/@claude-flow/swarm/__tests__/domain-pool-agent-release.test.ts.
  • Against main 1882006a0: 6/6 fail (the busy set retains the completed/failed/cancelled agent; queued tasks stay queued; the one-agent queen case is assigned to a pool-only id).
  • Candidate on main: 6/6 pass; full @claude-flow/swarm suite 283/283, 19/19 files; tsc --noEmit 0 errors; git diff --check clean.

Relationship to #4244

#4244 (fix(swarm): keep coordinator cancellation terminal) touches the same handlers, but this change is independent on current main: it includes wasTerminal guards so a late/duplicate terminal report cannot free an agent that has since been reassigned. It does not fix the cancellation-retry race that #4244 addresses. If #4244 lands first, the guards here can be narrowed during rebase; if this lands first, #4244 can keep its terminal-state logic.

Scope / known limitations

Compatibility

  • No public method signatures or return shapes changed. AgentPool.release() gains an optional { rescale?: boolean } parameter; existing calls behave exactly as before.
  • releaseAgentToDomain() behavior is unchanged for callers; the new releaseDomainAgent() helper only calls it for agents the pool actually owns.
  • Adds one additive SwarmEventType: agent.domain_release_failed, emitted only when pool bookkeeping fails; task completion/failure still resolves.
  • No dependency, lockfile, or persistence-format changes.

Interaction with the domain-pool no-autoscale fix

If the separate fix(swarm): stop domain pools from creating unregistered agents change lands first, checkScaling() becomes a no-op for domain pools, so the { rescale: false } parameter added here is redundant. A rebase can drop that parameter and the corresponding agent-pool.ts/types.ts hunks; no conflict is expected.

assignTaskToDomain() acquires an AgentPool slot, but handleTaskComplete(), terminal handleTaskFail() and cancelTask() only reset AgentState. releaseAgentToDomain() had no callers, so each finished domain task permanently consumed a slot and domainTaskQueues never drained. Release the pool-owned agent on terminal transitions and dispatch the next queued task. Skip terminal queue entries and avoid scaling a one-agent domain before its queued task is dispatched.
…lict

unified-coordinator.ts conflicted in three spots: ruvnet#4244 (already on
main) replaced the "capture task.status in a wasTerminal var, mutate
unconditionally" pattern with an upfront `!isTaskSettled(task)` guard
that skips the whole handler body for an already-settled task (fixing
a double-processing bug the old pattern had: duplicate metric
increments and duplicate emitted events for a late/duplicate message).

This PR's `wasTerminal` captures were reading task.status at a point
where main's guard already guarantees non-terminal, so they were
always false/true trivially. Resolution: keep main's guard/fencing
behavior as-is, drop the now-redundant `wasTerminal` variable, and
make the `releaseDomainAgent` calls unconditional within the
already-guarded block — preserving this PR's domain-pool-release fix
without reintroducing the double-processing bug ruvnet#4244 fixed.

Verified: full @claude-flow/swarm test suite (26 files, 321 tests)
passes, including coordinator.cancellation.test.ts and
domain-pool-agent-release.test.ts from both lineages.

Co-Authored-By: RuFlo <ruv@ruv.net>
@ruvnet
ruvnet merged commit 1e23ceb into ruvnet:main Oct 17, 2026
110 checks passed
ruvnet added a commit to rudycelekli/agent-forge that referenced this pull request Oct 17, 2026
Textual conflict was only in agent-pool.ts's scale() (this PR's
reconcileReservations() call vs main's autoScale early-return from
ruvnet#4246) — resolved by running reconcileReservations() first, matching
the pattern every other public method in the file already follows.

A second, non-textual regression surfaced under test: this PR makes
AgentPool.getBusyAgents()/getAvailableAgents() self-heal their
busy/available sets from the live AgentState.status on every read
(reconcileReservations). unified-coordinator.ts's releaseDomainAgent()
(from ruvnet#4247, already on main) used getBusyAgents() to check whether
the pool actually owns an agent before releasing it — but by the time
that fire-and-forget release runs, the caller has already reset
agent.status to 'idle', so the reconciled getBusyAgents() always
reports the agent as already-available and the function returns early,
skipping the release and queue-drain entirely. Fixed by checking pool
membership via getAgent() instead, which isn't affected by the
busy/available reconciliation.

Verified: full @claude-flow/swarm test suite, 27 files / 332 tests,
all pass — including both PRs' own regression tests
(agent-pool.shared-reservations.test.ts,
domain-pool-agent-release.test.ts).

Co-Authored-By: RuFlo <ruv@ruv.net>
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.

2 participants