Skip to content

atlas-agent-manager: the six ATL-212 children — connect once, free the process, keep the turn id - #241

Merged
ahammadnafiz merged 4 commits into
0.3.1from
ahammadnafiz86/atl-226-231-agent-manager-bug-fixes
Sep 6, 2026
Merged

atlas-agent-manager: the six ATL-212 children — connect once, free the process, keep the turn id#241
ahammadnafiz merged 4 commits into
0.3.1from
ahammadnafiz86/atl-226-231-agent-manager-bug-fixes

Conversation

@ahammadnafiz

Copy link
Copy Markdown
Collaborator

Fixes the six ATL-212 children filed against crates/atlas-agent-manager. Five are defects; the sixth is the test suite that could not have caught any of them.

The first four are one import mistake with four symptoms. Zed's AgentConnectionStore is Rc-based and confined to the GPUI main thread, where its check-then-insert is one uninterruptible borrow and its session state lives elsewhere entirely. The port kept the shape, swapped Rc for Arc and GPUI for multi-threaded tokio, and src/lib.rs recorded the result as "the mechanism is unchanged; only the runtime is". It was the other way round: the mechanism's correctness was the runtime. That sentence is now the crate doc's warning rather than its claim.

What changed

ATL-226 — two concurrent requests started two agent processes. request_connection took the entries lock to look, released it, started a connect, then took it again to insert. connect_to added a second check-then-act in front of that window and restart_connection a third. Measured at 11–30% of rounds under real concurrency.

Every entry is now created in one place, open_entry, which holds a single guard across the check and the insert. connect_to's pre-check is kept as a fast path and documented as one — two callers arriving together both fall through, and the locked path picks a winner. Calling AgentServer::connect under the guard is safe and says why: both implementations build a boxed future and perform no I/O synchronously, and neither can reach back into the manager.

ATL-227 — a version bump or uninstall left the old process running, and it outlived Atlas. Four sites evicted a connection; one cleared that agent's sessions. A session pins the connection Arc (SessionHandleAcpThreadArc<dyn AgentConnection>), so the other three left a child alive that nothing could reach — including AgentHost::shutdown, which iterates connections() and therefore only sees entries still in the map.

forget_sessions_for now runs at all four. Sessions are forgotten locally rather than closed on the agent first: a version bump and an uninstall both end with that process being dropped, and a session/close to a peer about to be killed buys nothing. AgentManager::shutdown() is new and sweeps both maps directly; AgentHost::shutdown calls it instead of driving eviction through connections().

ATL-228 — killing an agent mid-install still downloaded, spawned and handshaked it. drop_connection removed the entry and returned. Dropping the manager's own handle on the connect could never have stopped it, because a caller parked in connection() holds a clone of the same Shared and keeps polling it.

The connect now runs on a task of its own and the entry keeps its AbortHandle. Every eviction path aborts it, which drops the connect future and the child it had spawned. Waiters get LoadError::Other("the agent was stopped while it was connecting") rather than a connection to a process the user killed, so AgentHost::spawn no longer returns success for one.

ATL-229 — a superseded turn's late reply closed the turn that superseded it. begin_turn returns a turn id and RunningTurn carries one precisely for this; send dropped it, and end_turn/set_error cleared running_turn unconditionally. atlas-acp-thread gains end_turn_unless_superseded and set_error_unless_superseded.

The guard is "no other turn is running", not "this turn is running", and the difference is the whole of the cancel path. cancel() clears the running turn before the agent answers, so a stricter guard swallows the only Stopped a cancelled turn ever emits — and Stopped is what becomes TurnFinished, which is what flushes analytics, the transcript and the turn's token usage. The strict version was written first, caught in review, and both layers now have a test that fails under it. The unguarded end_turn is untouched: atlas-agent-delta and this crate use it as "announce Stopped" with no turn open, which ATL-218 finding 4 settled deliberately.

The same omission was one layer up: AgentHost::send announced a failed turn's error through the projector, which stamps whatever turn_seq is current — so the cancelled turn's error landed on the live one. It now checks the turn is still current first.

ATL-230, all three findings.

  1. The sessions map was keyed by the agent-chosen session id alone, so two connected agents minting the same id meant the second registration silently evicted the first. Now keyed by (Agent, SessionId). The id-only lookups still work, and refuse to answer when two agents share an id rather than routing a user's message into another agent's conversation — wrong in a way the user can see beats wrong invisibly. The host's own map and the frontend's SessionKey are still id-keyed; that is a separate change and is not in here.
  2. An agent uninstalled between server_for and start_connection fell through to the native delegate and reported no command resolver for agent \x`, a description of Atlas's plumbing. It now says `x` is not installed`.
  3. ResumeMode::Replayed restates the advertised capability rather than observing the replay. Deriving it from the loaded thread's entries was tried and backed out: for every external agent the replay frames are still queued on the connection's dispatch task when session/load answers, so an empty thread means "not drained yet" as often as it means "replayed nothing" — the check would have told users their history was gone on conversations that had it. The finding stands, with the reasoning recorded at the call site and pinned by a test. The fix belongs in the connection, which is the only layer that knows a frame arrived: count the session/updates between the load request and its response, or flush the dispatch queue before open_or_create_session returns.

Separately, close_session on an id two agents share used to report success having closed nothing; it is an error now. An id that names nothing is still Ok, which is the idempotent case a tab closing twice needs.

ATL-231 — the suite. 17 tests to 45. The join test called request_connection twice sequentially and commented it as concurrency; that comment now says what it tests and points at the real one. New: tests/support with a connect the test parks mid-flight and connections that report their own destruction, tests/invariants.rs with 22 tests covering each finding above, and tests/handshake.rs, which drives a real python ACP agent as a child process through the manager end to end — the handshake path had no coverage anywhere in the repo, and a fake connection cannot fail to die.

Every fix was checked by reverting it and watching a test fail — including the retain in drop_connection that was already correct, and the three tests that review showed could not fail and were rewritten until they could.

start_paused was considered and not adopted: every test that races anything needs the multi-thread flavour, and start_paused is current-thread only.

Verification

  • cargo test in crates/atlas-agent-manager: 45 passed, was 17
  • cargo test in crates/atlas-acp-thread: 72 passed, was 68
  • cargo clippy --all-targets -- -D warnings clean on both, which is the gate both crates opt into
  • cargo build --workspace and the src-tauri suite green

Not in here

ATL-230 finding 1's other half (the host's own session map and the frontend's SessionKey, which are still id-keyed), ATL-230 finding 3's connection-level replay signal, and the atlas-agent-delta compounding noted in ATL-229 where a superseded turn's terminal is stamped with the current turn_seq.

src-tauri's commands::skills tests fail on a developer machine that has real skills installed — they read the live skills directory, so one of them counts 138 where it expects 1. Pre-existing, unrelated to this change, and green on a clean CI runner.

`begin_turn` has returned a turn id since the port and `RunningTurn`
carries one, precisely so a turn reporting back late can tell whether it
is still the one running. Nothing checked it. `end_turn` and `set_error`
cleared `running_turn` unconditionally, and the id sat behind
`#[allow(dead_code)]`.

That matters because `begin_turn` supersedes deliberately: a send that
overlaps another cancels the first with `InterruptedByFollowUp` and sends
`session/cancel`. The cancelled turn's `prompt` future still returns —
and when it did, it closed the turn that had superseded it. The session
left the generating state, or was marked as having failed, while the live
turn was still streaming.

Adds `end_turn_if_current` and `set_error_if_current`. Both no-op unless
the id still matches the running turn, and both answer whether they
closed anything, so a caller can tell "my turn ended" from "my turn had
already been replaced".

The unguarded `end_turn` is untouched. `atlas-agent-delta`'s tests and
this crate's own callers use it as "announce Stopped" with no turn open,
which ATL-218 finding 4 settled deliberately and its doc comment records.
This is an addition, not a replacement, and a test now pins that too.

ATL-229.
…the process

Four of the five defects filed against this crate are one import mistake
with four faces. Zed's `AgentConnectionStore` is `Rc`-based and cannot
leave the GPUI main thread, so its check-then-insert is one
uninterruptible borrow, and its session state lives somewhere else
entirely. The port kept the shape, swapped `Rc` for `Arc` and GPUI for
multi-threaded tokio, and recorded the result in `lib.rs` as "the
mechanism is unchanged; only the runtime is". It is the other way round:
the mechanism's correctness WAS the runtime. That sentence is now the
crate doc's warning rather than its claim.

**Two concurrent requests started two agent processes** (ATL-226).
`request_connection` took the entries lock to look, released it, started
a connect, then took it again to insert. `connect_to` put a second
check-then-act in front of that window; `restart_connection` opened a
third by removing and re-requesting. Measured at 11-30% of rounds under
real concurrency, and invisible to the suite because its join test called
`request_connection` twice sequentially on one thread.

Every entry is now created in `open_entry`, which holds one guard across
the check and the insert. `connect_to`'s pre-check stays as a fast path
and is documented as one: two callers arriving together both fall
through, and the locked path picks the winner. Calling `AgentServer::
connect` under the guard is safe, and the comment says why — both
implementations build a boxed future and do no I/O synchronously, and
neither can reach back into the manager.

**A version bump or uninstall left the old process running, and it
outlived Atlas** (ATL-227). Four sites evicted a connection; only
`drop_connection` also cleared that agent's sessions. A session pins the
connection Arc — `SessionHandle` holds the thread, the thread holds the
connection — so the other three left a child alive with nothing able to
reach it, including `AgentHost::shutdown`, which can only tear down what
the entries map still knows about.

`forget_sessions_for` now runs at all four. Sessions are forgotten
locally rather than closed on the agent first: a version bump and an
uninstall both end with that process being dropped, and a `session/close`
to a peer about to be killed buys nothing. `shutdown()` is new and sweeps
both maps directly, so an attempt still in flight — invisible to
`connections()` — is torn down too.

**Killing an agent while it installed still downloaded, spawned and
handshaked it** (ATL-228). `drop_connection` removed the entry and
returned. Dropping the manager's own handle could never have stopped the
work: a caller parked in `connection()` holds a clone of the same
`Shared` and keeps polling it, so for a registry-archive agent the
download ran to completion and the process started, seconds to minutes
after the user cancelled.

The connect now runs on a task of its own and the entry keeps its
`AbortHandle`. Every eviction path aborts it, which drops the connect
future and the child it had spawned. Waiters get a `LoadError` naming
what happened rather than a connection to a process the user killed, so
`AgentHost::spawn` stops returning success for one.

**A superseded turn's late reply closed the turn that superseded it**
(ATL-229). `send` discarded `begin_turn`'s returned id; it now keeps it
and closes the turn through the guarded pair added in the previous
commit.

**The three ATL-230 findings.**

The sessions map was keyed by the agent-chosen session id alone, so two
connected agents minting the same id meant the second registration
silently evicted the first — and `send`, `cancel` and `close_session`
all look up by id. It is now keyed by `(Agent, SessionId)`. The id-only
lookups still work and refuse to answer when two agents share an id,
rather than routing a user's message into another agent's conversation:
wrong in a way the user can see beats wrong invisibly. The host's own map
and the frontend's `SessionKey` are still id-keyed; that is a separate
change.

An agent uninstalled between `server_for` and `start_connection` fell
through to the native delegate, so the user was told `no command resolver
for agent \`x\`` — a description of Atlas's plumbing — instead of
``\`x\` is not installed``, which is what `server_for` would have said.

`ResumeMode::Replayed` is documented as an observation and was a
restatement of the advertised capability. It is now derived from whether
the loaded thread has any entries, so an agent that advertises
`loadSession` and replays nothing gets the existing "can't replay past
messages" notice instead of a blank conversation with no explanation.

**The suite** (ATL-231): 17 tests to 43. `tests/support` adds a connect
the test parks mid-flight and connections that report their own
destruction — the two properties every finding above needed and no
existing fake had. `tests/invariants.rs` covers each of them, including
200 rounds of genuine four-way concurrency. `tests/handshake.rs` drives a
real python ACP agent as a child process through the manager end to end;
the handshake path had no coverage anywhere in the repo, and a fake
connection cannot fail to die. The old join test keeps its assertion and
loses its misleading comment.

`start_paused` was considered and not adopted: every test that races
anything needs the multi-thread flavour, and `start_paused` is
current-thread only.

ATL-226, ATL-227, ATL-228, ATL-229, ATL-230, ATL-231.
…urn's failure

Two changes, both the host's half of a defect fixed one layer down.

`shutdown` drove eviction through `manager.connections()`, which yields
only entries that reached `Connected`. An agent still connecting is
invisible to that list, so if the app exited during that window the child
it was about to spawn outlived Atlas — which is exactly what the comment
above this function says it exists to prevent, `process::exit` skipping
every `Drop` that would otherwise clean up. It now calls the manager's
own `shutdown()`, which sweeps both maps and cancels attempts in flight.

`send` announced a failed turn's error through the projector, which
stamps whatever `turn_seq` the session is on. A turn superseded by a
later send reports back late, so the cancelled turn's error landed on the
live one and the chat showed a failure for a turn that was still
streaming. It now checks the turn is still current first — the same
omission as `AcpThread`'s turn guard, one layer up, and the reason
fixing only the thread would have left the symptom in place.

ATL-227, ATL-228, ATL-229.
…cy resume check

Two fixes from review of the three commits before this, and both were
regressions those commits introduced rather than gaps they left.

**The turn guard was too strict, and it broke Stop.** It asked "is this
turn the one running", which is false in the case the guard was never
about: `cancel()` clears `running_turn` before the agent has answered, so
by the time the prompt returns `StopReason::Cancelled` there is no
running turn at all. `end_turn` never ran, `Stopped` was never emitted,
and `Stopped` is the only thing that produces `SessionDelta::
TurnFinished` — so a cancelled turn flushed no analytics, wrote no
transcript, never ran the memory nudge, and left its token usage to be
counted against the NEXT turn's footer. The UI still went idle, on
`StatusChanged`, which is what made it look fine.

The guard is now "unless a later turn has taken over": no running turn
means announce, a DIFFERENT turn running means stay quiet. That is the
only case ATL-229 was ever about, and it leaves the cancel path exactly
as it was. Renamed to `end_turn_unless_superseded` /
`set_error_unless_superseded` so the name states the guard. Both layers
now have a test that fails under the strict form.

**The `ResumeMode` downgrade was racy, and is backed out.** Deriving
`Replayed` from `thread.entries().is_empty()` assumed the replay had been
applied by the time `session/load` answered. For every external agent it
has not: `handle_session_notification` goes through the connection's
ordered dispatch queue, drained on its own task, while the load response
resolves on the RPC path with no barrier between them. So an empty thread
meant "not drained yet" as often as it meant "the agent replayed
nothing", and the check would have told users their history was gone on
conversations that had it — a worse failure, on a far more common path,
than the silent blank thread it was meant to fix.

ATL-230 finding 3 stands, with the reasoning recorded at the call site
and pinned by a test: the signal belongs to the connection, which is the
only layer that knows a frame arrived. Counting `session/update`s between
the load request and its response, or flushing the dispatch queue before
`open_or_create_session` returns, would do it. The frontend's own
empty-thread fallback covers this meanwhile.

**Also from the same review.**

`close_session` on an id two agents share returned `Ok(())` having closed
nothing. It is an error now — the caller asked for something to happen.
An id that names nothing is still `Ok`, which is the idempotent case a
tab closing twice needs.

Three tests were rewritten because they could not fail. The restart race
asserted `attempts <= 2`, which is arithmetic; it now parks the connect
and asserts exactly one attempt, and fails against the pre-fix request
path. The mid-connect kill in `tests/handshake.rs` called
`drop_connection` before the child had spawned, so its assertion sat
inside an `if let` that was never entered — the fake agent now parks after
writing its pid, so the kill always lands on a live process. Both were
verified by reverting the fix and watching them fail.

The manager's test harness keeps each session's event stream now, so a
test can assert on what the thread announced rather than inferring it
from the thread's final state. That is what makes the cancelled-turn test
able to see the missing `Stopped` at all.

ATL-229, ATL-230, ATL-231.
@ahammadnafiz
ahammadnafiz merged commit e83bfe6 into 0.3.1 Sep 6, 2026
23 checks passed
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.

1 participant