cmux NEXT: notify on a hand-off's done, failed and needs-you, once per turn

Merged
TE
7d ago
+1029-53

Leo's words: "Likewise, there's some stuff where we wanted to know what the state of affairs with cloud was. Like, we didn't with the ChatGPT work mode. We really, like, that, we want that to be over there. And then we also want to emulate that ChatGPT work mode pretty hard."

This PR points the notify step of cloud work mode at the hand-off store (#26578, backend/apps/api/src/handoffs/). It follows #26538, which added the app-level watcher for sandboxed Runs.

What changes

  • The watcher takes a source. SandboxRunFollower now waits on a Source: the Run API's /api/runs/:id/wait (Source.runs, the same behaviour #26538 landed) or the hand-off store's GET /v1/handoffs/:id/wait?after=<version>&timeoutMs= (Source.handoffs). Each wait passes the last version seen; the store holds a call up to 25 s.
  • Hand-offs count turns (decided in hq#4706). A hand-off lasts the whole session. done, failed and cancelled end a turn, not the hand-off, and a follow-up in the same chat opens the next turn on the same id.
    • The app posts once per (id, turn, status), for done, failed and needs-you. So turn 2's done posts even though turn 1's did, and a turn that stops for the person twice posts once.
    • Never for cancelled or progress (starting, running). Never while the person is looking at that chat.
    • The watcher keeps waiting after a turn ends. It stops when the hand-off's state is closed.
    • These are the fields of the lifecycle that landed in #26666 (turn, state, and a /wait that returns once the version passes after or the hand-off closes). An answer without state (the store before #26666) is read as closing at its first done, failed or cancel.
  • Reads go to the backend API (ownerAPIBaseURL, cloud-api-staging.cmux.dev in development), not cmux.com. CloudOwnerAPIClient gains a GET with the same header rule as its POSTs: only Authorization and x-cmux-team. The path's one varying part is a checked id (ho_ plus 20 hex digits), and the query holds integers and the store's own list cursor only.
  • HandoffNotices (the App) owns the hand-off watcher.
    • At each sign-in it reads the person's hand-offs (GET /v1/handoffs, page after page) and follows the open ones it doesn't know yet, starting from the version the list shows, so their current state is not news.
    • watch(handoff:session:host:) is the entry point for the composer's Cloud hand-off, which is not built yet.
    • Banner: the title is the chat's tab title, else the summary's first line, else "Cloud task". The body is Done, Failed or Needs you, plus the summary once a turn ends. Words come from Handoffs.xcstrings (landed in #26624). Posting goes through the existing DesktopNotifier under the agent source's settings, as Run banners do.
    • Click: reveals the tab showing the hand-off's sessionId, on this Mac or on any machine the app reaches. If no tab shows it, the click opens a workspace on the session's host when this app made the hand-off; otherwise it brings the app forward.
  • Exactly once across a relaunch. Each notice's key is saved with the hand-off after its banner is posted. A crash in between posts it again at the next launch rather than losing it, and the repeat replaces the first banner, since each hand-off has one banner id.

Stop conditions and cleanup

  • Each hand-off's poll task (in the follower's polls) ends when:

    • the hand-off is closed;
    • the person signs out, or another account signs in;
    • the store answers 403 or 404 (where the store isn't deployed, every read is a 404, so nothing is followed);
    • it has not changed for three days;
    • or the app quits (stop() from applicationWillTerminate).

    A failure backs off up to 60 s. A store that answers at once is asked again no sooner than every 2 s.

  • The account observer and the sign-in list read (HandoffNotices.account, .listing) are cancelled in stop(). A new sign-in replaces the list read.

No push

The store's /wait long-poll is the transport. This PR builds no push infrastructure, so a hand-off that changes while the app is closed notifies at the next launch.

Not covered

  • The composer's Cloud target (cloud work mode build item 4) doesn't exist yet, so no app code makes a hand-off. Until it does, this follows hand-offs made elsewhere (the list at sign-in).

Risk

Low to medium. Run notifications go through the same follower, now with a source. Their behaviour is unchanged, and SandboxRunNoticeTests still pass. Hand-off reads only happen when signed in with Cloud allowed, and the store answers 404 in production until it ships there.

Proof

  • HandoffNoticeTests (fleet package lane), with SandboxRunNoticeTests, CloudAPIClientRunTests and AgentPaneSandboxRunTests:
    • each turn's done and needs-you posts once, and the watcher outlives a turn;
    • a cancel and progress never post;
    • a failed turn posts once, and a closing turn end not yet told posts;
    • a store from before turns closes at its first end;
    • a stop already told doesn't post again after a relaunch;
    • a hand-off found in the list posts only what changes after;
    • one the store no longer shows is dropped without posting;
    • bodies of another shape are unreadable;
    • the banner's words;
    • only store ids reach the path.
  • Release compile through the fleet.

No capture: the app's own views don't change. The new surface is a macOS notification banner, posted when a hand-off changes on the server, and the capture tours run no hand-off, so they can't produce it. The tests pin the banner's words.

Review (Codex gpt-6-astra, high): SHIP at ec8fa58a927b906782fa4482c7e7203e20e0172b

Agent session: agent session

šŸ¤– Generated with Claude Code

Takes back #26538: the run-notice calls become pageChats.stopNotices() and pageChats.openNotice(banner:). They still stop and open #26538's sandboxed-Run notices, and now hand-off notices too.