Skip to content

fix(router): pin SPAWN subagents under their own id so they stay on one worker - #52

Merged
cquil11 merged 2 commits into
SemiAnalysisAI:masterfrom
ajcasagrande:ajc/agentx-spawn-pinning
Oct 15, 2026
Merged

cquil11 merged 2 commits into
SemiAnalysisAI:masterfrom
ajcasagrande:ajc/agentx-spawn-pinning

Conversation

@ajcasagrande

@ajcasagrande ajcasagrande commented Oct 14, 2026 •

Copy link
Copy Markdown

TL;DR

AgentX subagents (SPAWN children) bounced between workers whenever their parent had no sticky entry, and every worker they touched kept a copy of the session until the run ended. A memory leak that grows with run length and worker count. Subagents now pin to one worker by their own id.

Problem

SPAWN children routed only via the parent's sticky entry. That entry is often missing (parent already finished, gated on a join at replay start, or a rootless lane), so each subagent turn went to the least-loaded worker. In a live --scenario agentx run about half of all subagent turns had no parent entry, and 22 stale session copies leaked in 2 minutes. Each copy is 448 KB–4 MB (measured in a separate 15-minute run).

Fix

A SPAWN child pins under its own x_correlation_id on its first turn (on the parent's worker if that entry is live) and releases it on its own final turn, the same as roots.

  • Leaked copies: 22 → 0 on the same run.
  • Prompts were never affected: verified byte-identical across workers.
  • FORK children of a SPAWN child now also co-locate with it; FORK under roots is unchanged.
  • A subagent that stops early keeps its entry until run end, like roots do today.

Performance

send_credit routing cost, network send stubbed out (40k credits, 8 workers):

Workload master This PR
Roots only 766–796 ns 758–784 ns (noise)
Multi-turn subagents 902–1428 ns 786–837 ns

Subagent credits that hit their pinned worker cost ~25 ns more (entry create/pop), outweighed by no longer scanning for the least-loaded worker.

Follow-up

BranchOrchestrator never receives the router in production (PhaseRunner looks up a sticky_router attribute the router doesn't have), so FORK refcounting and early-stop release are dead code. Fixing that surfaces separate FORK refcount bugs; it'll be its own PR.

Testing

Unit, component-integration (DAG/agentic) and integration (DAG/AgentX) suites pass; the two unit failures also fail on master. Live AgentX leak check before/after.

…ne worker

In agentic replay, SPAWN subagent credits were routed only through the
parent's sticky entry. When the parent had no entry (it already sent its
final turn, it is gated on a join at replay start, or the lane is
rootless), every subagent turn was routed least-loaded and bounced across
workers. Each worker that served the subagent but not its final turn kept
a UserSession copy until process exit, leaking memory over long runs.

A SPAWN child now pins under its own x_correlation_id on its first turn,
on the parent's worker when the parent's entry is live, and releases the
entry on its own final turn exactly as roots do. A SPAWN child that stops
before its final turn keeps the entry until run end, as roots already do.
Multi-turn SPAWN children now count toward active_sessions, the first
least-loaded tie-break, like roots.

FORK children of a SPAWN child now route via that child's own entry and
co-locate with it (previously they routed least-loaded on every turn and
lost the inherited context). FORK routing under roots is unchanged.

Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
Also renames the SampledSession routing_key test, whose name claimed SPAWN children route to the parent's worker on every turn.

Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
@github-actions

Copy link
Copy Markdown

Try out this PR

Quick install:

pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@b5d7652b50aceb9e0c4ca77daf9ae8f472ac77fd

Recommended with virtual environment (using uv):

uv venv --python 3.12 && source .venv/bin/activate
uv pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@b5d7652b50aceb9e0c4ca77daf9ae8f472ac77fd

Last updated for commit: b5d7652 • Browse code

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

Approved following the PR52-only c6656 AgentX run: https://github.com/SemiAnalysisAI/InferenceX/actions/runs/38683542278/job/116253300866. The run completed with zero profiling errors and no child-worker migrations. Independent reconstruction from all 781,455 profiling records reproduced the core exported statistics and all 186,324,783 inter-chunk samples, including percentiles, within floating-point/export precision. Final metric and artifact coverage was retained.

@cquil11
cquil11 merged commit 9adff5e into SemiAnalysisAI:master Oct 15, 2026
8 checks passed
cquil11 pushed a commit that referenced this pull request Oct 16, 2026
Keep each SPAWN child on its initial worker until its final turn, avoiding stale session copies across workers.

(cherry picked from commit 9adff5e)
FrankD412 added a commit to ai-dynamo/aiperf that referenced this pull request Oct 16, 2026
SPAWN children routed only through their parent's sticky entry
(``routing_key = parent_correlation_id``) and were forbidden to mint one
of their own. Whenever the parent entry was missing, every child turn went
to the least-loaded worker: after the root's final turn (``has_forks`` is
computed from FORK branches only, so a final turn with SPAWN branches pops
the entry), when the root is gated on a join at replay start, and for
rootless replay lanes, where ``trajectory_source._snapshot_for`` samples a
trace after its root finished but while background ``::fa:`` chains are
still alive, so the children's ``parent_correlation_id`` names a root that
never sends a request and the entry never exists at all.

Each worker a child visited decoded and cached the full conversation in
``UserSessionManager`` and only the worker serving the final turn evicted,
so every other copy stayed until process exit. In a one-hour agentic
replay at concurrency 256 against the mock server, 300 of 3,998 sessions
(all SPAWN children) were served by more than one worker, 5,869 switches
in total, roughly 1,300 orphaned session copies; worker private heap hit a
20 GiB cgroup cap in about 18 minutes and three workers were OOM-killed.

A SPAWN child now pins under its own ``x_correlation_id`` from its first
turn: on the parent's worker when the parent's entry is live, otherwise
least-loaded, and releases the entry on its own final turn exactly as
roots do. Multi-turn SPAWN children therefore count toward
``active_sessions``. FORK children of a SPAWN child alias onto the child's
entry via ``register_child_routing`` and co-locate with it; FORK routing
under roots is unchanged. With the same fix applied to the vendored
0.12.0 router, the same run had 0 of 4,777 sessions on more than one
worker and 0 OOM kills.

Ported from SemiAnalysisAI#52 by Anthony Casagrande onto
main's alias-aware router.

Not addressed here: a SPAWN child that stops before its final turn keeps
its router entry until run end, as roots already do; and
``PhaseRunner`` still hands ``BranchOrchestrator`` ``sticky_router=None``
(it reads a ``sticky_router`` attribute off the router itself), so FORK
refcount release never runs in production. Both are pre-existing.

Signed-off-by: Frank Di Natale <3429989+FrankD412@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants