From 37bc30209b34bc787ab4a0deb4561355f9c054af Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:07:06 -0700 Subject: [PATCH] fix(session): recover the live stream after a drop or backgrounding Two ways an open chat could sit on a frozen shimmer while the server was happily broadcasting the reply, both healed by leaving and re-entering the session. The mid-turn reconnect in consume() only restored the snapshot's pending cards and threw the snapshot's live_message away, so every token the agent produced while the socket was down was lost from the open view. Adopt the rebuilt turn the way consumeReattach does, and rebind the consumer's live turn so later frames land on it. It deliberately does not set liveTurnFromReattach: that flag hides the persisted assistant turns after the last user prompt, and on the send path those are the previous turn's finished reply, since turns is never refetched mid-turn. iOS suspends the app on lock or app switch, killing the socket, and the reconnect backoff is a Task.sleep that cannot progress while suspended, so the budget was spent on attempts that never reached the server and nothing re-attached on return. Latch the background transition and restart recovery with a fresh budget against the same ACP connection, which outlives the WebSocket. --- .../SessionDetail/SessionDetailView.swift | 28 ++++++++ .../SessionDetailViewModel.swift | 71 ++++++++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/CodegiOS/Features/SessionDetail/SessionDetailView.swift b/CodegiOS/Features/SessionDetail/SessionDetailView.swift index c0b3d29..f932ff6 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailView.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailView.swift @@ -20,10 +20,15 @@ struct SessionDetailView: View { @State private var model: SessionDetailViewModel @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase @State private var showRename = false @State private var renameText = "" @State private var showDetails = false @State private var showDeleteConfirm = false + /// Latched when the app is suspended, so returning to the foreground can + /// re-attach a stream iOS killed while we were away (see the `scenePhase` + /// handler below). + @State private var wasBackgrounded = false init(server: ServerProfile, client: CodegClient, conversationID: Int, onOpenSession: ((NewSessionRequest) -> Void)? = nil) { @@ -131,6 +136,29 @@ struct SessionDetailView: View { } .task { await model.load() } .onDisappear { model.teardown() } + // iOS suspends the app on screen lock / app switch, which kills the event + // socket while a reply is still streaming. Nothing else re-attaches, so the + // transcript would sit on a frozen shimmer until the session is reopened. + // + // Only a real background round-trip counts: a trip through `.inactive` + // alone (control center, the app-switcher peek, an alert) never suspends + // us, and reconnecting there would churn a perfectly healthy socket. The + // latch is why this doesn't just compare against the previous phase — + // returning to the foreground can report `.background` → `.inactive` → + // `.active`, so the phase we come back from is not reliably `.background`. + .onChange(of: scenePhase) { _, phase in + switch phase { + case .background: + wasBackgrounded = true + case .active: + if wasBackgrounded { + wasBackgrounded = false + model.resumeStreamAfterForeground() + } + default: + break + } + } // Haptics — the app's marquee "felt" moments, all keyed off existing // @Observable state. Vocabulary: success = a reply completed, error = it // failed, warning = the agent needs you (a permission / question card diff --git a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift index 1a23eb3..d101eb6 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift @@ -932,6 +932,11 @@ final class SessionDetailViewModel { /// consumer (its `generation` no longer current) ignores its terminal frames /// so it can't end a turn that a newer stream now owns. private func consume(stream: EventStream, connectionID conn: String, live: LiveTurn, generation: Int) async { + // The turn this consumer feeds. A mid-turn reconnect REPLACES it with the + // turn rebuilt from the fresh attach snapshot (see `.snapshot` below), so + // every frame after that point lands on the turn the transcript is showing + // rather than on an orphaned placeholder. + var live = live for await frame in stream.frames { if Task.isCancelled { return } let isCurrent = generation == streamGeneration @@ -955,7 +960,34 @@ final class SessionDetailViewModel { // with no way to approve. Mirrors `consumeReattach` + the web client. // Skipped during the INITIAL attach handshake (readyContinuation set) // — that snapshot is the pre-prompt state and carries no live card. - if isCurrent, readyContinuation == nil, isTurnActive { restorePending(from: snap) } + if isCurrent, readyContinuation == nil, isTurnActive { + // The agent kept replying while the socket was down and those + // events reached no subscriber. The snapshot's `live_message` is + // the COMPLETE in-flight reply, so adopt it wholesale instead of + // keeping the turn that stopped at the drop — otherwise whatever + // was produced during the outage stays missing from the open + // screen until the user leaves and re-enters the session (the + // reply is there on re-entry, which is exactly the tell). + // + // Deliberately does NOT set `liveTurnFromReattach` — the one + // thing `consumeReattach` does that must not be copied here. That + // flag drops the persisted assistant turns trailing the last user + // prompt, and on the send path those are the PREVIOUS turn's + // finished reply: `turns` is never refetched mid-turn, so it + // holds no partial copy of THIS reply to double-render. + if let rebuilt = buildLiveTurn(from: snap) { + live = rebuilt + liveTurn = rebuilt + // Recovery parked the status line on "connecting"; the reply + // is streaming again, so say so (without stomping a tool run). + if case .running = sendState {} else { sendState = .thinking } + // Follow the new content only for a reader who is still + // pinned — a socket blip must not yank someone who scrolled + // up, unlike a fresh open or the user's own send. + requestScrollToBottom() + } + restorePending(from: snap) + } if isCurrent { resumeReady(throwing: nil) } case .replay: streamReconnects = 0 @@ -981,7 +1013,12 @@ final class SessionDetailViewModel { // re-attach silently, matching the web client. guard isTurnActive else { return } if reason == "connection_gone" { - Task { [weak self] in await self?.reconcileOrFail(live: live, reason: reason) } + // Bind before the closure: `live` is a `var` now (a reconnect can + // adopt the snapshot's turn) and a `Task` may only capture an + // immutable local. `consumeReattach` gets this for free from its + // `guard let live`. + let current = live + Task { [weak self] in await self?.reconcileOrFail(live: current, reason: reason) } } else { reconnectStream(into: live, connectionID: conn, reason: reason) } @@ -1432,6 +1469,36 @@ final class SessionDetailViewModel { // MARK: - Stream recovery (transient drops) + /// Restart stream recovery after the app returns to the foreground. iOS + /// suspends the process on screen lock / app switch, which kills the event + /// socket, and the silent-reconnect backoff is a `Task.sleep` that makes no + /// progress while suspended — so a turn that was streaming comes back to a dead + /// socket having quietly spent its reconnect budget on attempts that never + /// reached the server. The reply then sits frozen mid-stream indefinitely with + /// no error, and only leaving and re-entering the session recovers it. + /// + /// Re-attaching targets the SAME server-side ACP connection (it outlives the + /// WebSocket) and the fresh attach snapshot carries everything the agent + /// produced while we were away, so nothing is lost by reconnecting here. + /// + /// No-op unless a turn is actually streaming: an idle screen has nothing to + /// recover, and a session opened cold is already covered by `reattachIfLive`. + func resumeStreamAfterForeground() { + // A send still shaking hands owns its own recovery (its ready timeout fails + // it cleanly). Closing that socket here would cancel the handshake and + // strand a turn whose prompt the server never received. + guard readyContinuation == nil else { return } + guard isInFlight, isTurnActive, let live = liveTurn, let conn = connectionID else { return } + // The attempts burned while suspended were never real attempts — give + // recovery its full allowance back, or a long stretch in the background + // leaves nothing left to reconnect with. + streamReconnects = 0 + // `liveTurnFromReattach` records which consumer owns this turn: it is true + // only when `consumeReattach` built it from a snapshot. Routing back to the + // same one keeps the transcript's in-flight suppression consistent. + reconnectStream(into: live, connectionID: conn, reason: nil, reattach: liveTurnFromReattach) + } + /// Recover a dropped event socket mid-turn by re-opening it and re-attaching /// to the SAME server-side ACP connection — which outlives the WebSocket. /// Mirrors the web client, whose socket auto-reconnects and re-subscribes its