atlas-agent-manager: the six ATL-212 children — connect once, free the process, keep the turn id - #241
Merged
ahammadnafiz merged 4 commits intoSep 6, 2026
Conversation
`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.
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.
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
AgentConnectionStoreisRc-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, swappedRcforArcand GPUI for multi-threaded tokio, andsrc/lib.rsrecorded 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_connectiontook theentrieslock to look, released it, started a connect, then took it again to insert.connect_toadded a second check-then-act in front of that window andrestart_connectiona 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. CallingAgentServer::connectunder 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(SessionHandle→AcpThread→Arc<dyn AgentConnection>), so the other three left a child alive that nothing could reach — includingAgentHost::shutdown, which iteratesconnections()and therefore only sees entries still in the map.forget_sessions_fornow 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 asession/closeto a peer about to be killed buys nothing.AgentManager::shutdown()is new and sweeps both maps directly;AgentHost::shutdowncalls it instead of driving eviction throughconnections().ATL-228 — killing an agent mid-install still downloaded, spawned and handshaked it.
drop_connectionremoved the entry and returned. Dropping the manager's own handle on the connect could never have stopped it, because a caller parked inconnection()holds a clone of the sameSharedand 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 getLoadError::Other("the agent was stopped while it was connecting")rather than a connection to a process the user killed, soAgentHost::spawnno longer returns success for one.ATL-229 — a superseded turn's late reply closed the turn that superseded it.
begin_turnreturns a turn id andRunningTurncarries one precisely for this;senddropped it, andend_turn/set_errorclearedrunning_turnunconditionally.atlas-acp-threadgainsend_turn_unless_supersededandset_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 onlyStoppeda cancelled turn ever emits — andStoppedis what becomesTurnFinished, 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 unguardedend_turnis untouched:atlas-agent-deltaand 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::sendannounced a failed turn's error through the projector, which stamps whateverturn_seqis 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.
(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'sSessionKeyare still id-keyed; that is a separate change and is not in here.server_forandstart_connectionfell through to the native delegate and reportedno command resolver for agent \x`, a description of Atlas's plumbing. It now says`x` is not installed`.ResumeMode::Replayedrestates 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 whensession/loadanswers, 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 thesession/updates between the load request and its response, or flush the dispatch queue beforeopen_or_create_sessionreturns.Separately,
close_sessionon an id two agents share used to report success having closed nothing; it is an error now. An id that names nothing is stillOk, which is the idempotent case a tab closing twice needs.ATL-231 — the suite. 17 tests to 45. The join test called
request_connectiontwice sequentially and commented it as concurrency; that comment now says what it tests and points at the real one. New:tests/supportwith a connect the test parks mid-flight and connections that report their own destruction,tests/invariants.rswith 22 tests covering each finding above, andtests/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_connectionthat was already correct, and the three tests that review showed could not fail and were rewritten until they could.start_pausedwas considered and not adopted: every test that races anything needs the multi-thread flavour, andstart_pausedis current-thread only.Verification
cargo testincrates/atlas-agent-manager: 45 passed, was 17cargo testincrates/atlas-acp-thread: 72 passed, was 68cargo clippy --all-targets -- -D warningsclean on both, which is the gate both crates opt intocargo build --workspaceand thesrc-taurisuite greenNot 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 theatlas-agent-deltacompounding noted in ATL-229 where a superseded turn's terminal is stamped with the currentturn_seq.src-tauri'scommands::skillstests 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.