cmux NEXT Cloud: a chat moves from the relay to the direct link without a gap or a repeat

TE
1h ago
+1148-2

The relay-first chat path this builds on landed in #35829 (34847716), so its dependency hold is lifted.

Summary

Leo approved the Cloud connect design ("OBVIOUSLY"). Step 4 of it: "After the chat flows, the app sets up its direct link for terminal, sync and latency, without blocking. If that fails, everything keeps working through the relay."

This PR adds the piece that moves a chat from one carrier to another without dropping or repeating a message. That piece is AgentSessionCarrierSwitch (CmuxNextAgentPane). It is an AgentSessionRemoteClient that the relay carries first. A second client, the machine's direct link, takes over with move(to:) once it is up. The page sees no close at any point.

  • Records. The new client attaches from the newest seq already forwarded (after_seq; each attachment is a gap-free prefix) and pages to the end. Its records are held until the cutover, then flushed past that seq. The old client keeps the page live until then. Each seq reaches the page exactly once, in order.
  • Calls (prompts, cancel, permission answers, picks). The cutover waits until no call is in flight on the old client. A call made during the cutover waits and then goes over the new client. Each prompt goes over exactly one carrier.
  • Failure.
    • A move that fails keeps the relay and closes the direct client. That covers an attach that is refused, calls that don't finish within 10 s, and an end before the cutover.
    • If the direct link ends after a move, the chat goes back to the relay from the newest seq.
    • The page gets .closed only when the relay itself is gone, as it does today.

Nothing calls this yet. The relay client comes from the relay-first chat path. Wiring it in (a background direct connect, then move(to:)) is the next PR.

Testing

AgentSessionCarrierSwitchTests (Swift Testing, fake carriers):

  • A move after every seq k from 0 to 12, with relay pushes racing the direct attach and the direct log paged 2 at a time: the page gets 1...12 once each, in order, and no close.
  • A prompt in flight during a move finishes on the relay, and a prompt made during the cutover goes over the direct link once.
  • Calls that don't finish within the drain limit keep the chat on the relay.
  • A direct link that never comes up leaves the chat on the relay with no error.
  • A direct link that drops after the move goes back to the relay from the newest seq, with no gap and no close.
  • An end on the relay before any move closes the page's attachment.

Observed / Not run

  • Observed: scripts/cmux-next/prepush-gate.py reports no new violations.
  • Codex review at f2fab226 (gpt-6.1-sol) came back BLOCK with four findings, all fixed at c720068f, each with a regression test:
    • F1: a later _acpmux/watch re-attach no longer narrows what a move replays.
    • F2: records the page already read forward through events are not replayed by a move.
    • F3: no move starts while the chat is falling back to the relay.
    • F4: isReadOnly follows the client that carries the chat.
  • Codex re-review at c720068f (gpt-6-astra) came back BLOCK with one finding, fixed at c74aa8f3. The switch read an answer's lastSeq, which is the log's head, as the seq the page holds through. It now uses acpmux's pageThrough. When that is missing, it follows acpmux's own rule (server/requests.rs). Tests: aShortForwardAttachCoversOnlyItsOwnRecords and coveredFollowsAcpmuxsPageThroughRule.
  • Codex re-review at c74aa8f3 (gpt-6.1-sol): BLOCK with two findings, both fixed. First, a refused re-attach no longer leaves the live attachment silent. Second, an _acpmux/watch answer no longer moves the cursor.
  • Codex re-review at 54f1272e (gpt-6.1-sol): BLOCK with two findings, both fixed at 6dddfc8a.
    • AgentSessionAttachClient installs the new handler before it sends and keeps it if the daemon refuses. So a refused re-attach now keeps both generations live until the next attach or cutover.
    • A fall-back to the relay now drains calls still running on the direct client, up to the drain limit, before it releases held calls.
    • Tests: aRefusedReattachKeepsTheLiveAttachment runs once with each handler kept. aFallBackWaitsForDirectCallsInFlight checks the drain.
  • Codex review at 6dddfc8a (gpt-6-astra) passed: SHIP, with one follow-up, fixed at 07abc503. The wire's session-list read is now marked on its page (AgentSessionPage.listing, never sent to the daemon), not inferred from its values. Test: anAttachShapedLikeAWatchIsStillThePagesOwn.
  • Codex delta review 6dddfc8a..07abc503 (gpt-6-astra): SHIP at 07abc5034372bc8995555ea95202d6f15406cb0a, with no findings.
  • Fleet run at 07abc503 (step 7f464b92, fleet Mac, CMUX_SWIFT_SANITIZE=thread):
    • every AgentSessionCarrierSwitchTests test passed;
    • RemoteAcpmuxWireAnswerTests, RemoteAcpmuxObserverTests and RemoteAcpmuxContentTests still pass;
    • there were no ThreadSanitizer reports.
  • Not run: the F1-F4 tests against f2fab226. They need RemoteAcpmuxWire.watchPage, which this change adds. The F2 test is built so that, without the fix, the move attaches from seq 0 and repeats seqs 1-3.
  • Not run: a live app run. Nothing is wired yet.

Changelog

none

Part of manaflow-ai/cmuxterm-hq#7086.

🤖 Generated with Claude Code
Agent session: agent session