Skip to content

Commit a5871a5

Browse files
authored
🤖 fix: replay SSH startup status across workspace switches (#3133)
## Summary Persist SSH/Coder startup status in the backend session replay path so switching away from a preparing workspace and back no longer drops the startup barrier detail. ## Background Pre-stream startup breadcrumbs such as runtime checks and workspace initialization progress were only tracked in renderer state. When the active workspace subscription switched away and later replayed that workspace, the aggregator reset could wipe the visible startup detail before a fresh runtime-status event arrived. ## Implementation - retain the latest PREPARING `runtime-status` event in `AgentSession` and replay it before `caught-up` - keep replayed PREPARING lifecycle state visible in `WorkspaceStore` before transcript hydration completes - immediately apply replayed `runtime-status` events during pre-`caught-up` buffering so reconnect UIs preserve the same startup detail text - add reconnect coverage in both the backend replay tests and `WorkspaceStore` switching tests - reduce nearby duplication by sharing lifecycle/runtime-status equality checks in `AgentSession` and using stream event type guards in `WorkspaceStore` ## Validation - `bun test src/node/services/agentSession.preStreamError.test.ts` - `bun test src/browser/stores/WorkspaceStore.test.ts` - `make static-check` ## Risks Low to moderate. The change is scoped to PREPARING/reconnect replay state, but it touches the logic that decides which startup/terminal stream status is shown while a workspace is hydrating. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$12.90`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=12.90 -->
1 parent 2c615f8 commit a5871a5

4 files changed

Lines changed: 222 additions & 25 deletions

File tree

src/browser/stores/WorkspaceStore.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,6 +1558,98 @@ describe("WorkspaceStore", () => {
15581558
expect(store.getWorkspaceState(workspaceId).isStreamStarting).toBe(false);
15591559
});
15601560

1561+
it("replays runtime-status before caught-up when switching back to a preparing workspace", async () => {
1562+
const workspaceId = "stream-starting-runtime-status-replay";
1563+
const otherWorkspaceId = "stream-starting-runtime-status-other";
1564+
const startupDetail = "Checking workspace runtime...";
1565+
let subscriptionCount = 0;
1566+
let releaseSecondCaughtUp: (() => void) | undefined;
1567+
1568+
mockOnChat.mockImplementation(async function* (
1569+
input?: { workspaceId: string; mode?: unknown },
1570+
options?: { signal?: AbortSignal }
1571+
): AsyncGenerator<WorkspaceChatMessage, void, unknown> {
1572+
if (input?.workspaceId !== workspaceId) {
1573+
await waitForAbortSignal(options?.signal);
1574+
return;
1575+
}
1576+
1577+
subscriptionCount += 1;
1578+
1579+
if (subscriptionCount === 1) {
1580+
yield { type: "caught-up" };
1581+
await Promise.resolve();
1582+
yield {
1583+
type: "stream-lifecycle",
1584+
workspaceId,
1585+
phase: "preparing",
1586+
hadAnyOutput: false,
1587+
};
1588+
await Promise.resolve();
1589+
yield {
1590+
type: "runtime-status",
1591+
workspaceId,
1592+
phase: "starting",
1593+
runtimeType: "ssh",
1594+
detail: startupDetail,
1595+
};
1596+
await waitForAbortSignal(options?.signal);
1597+
return;
1598+
}
1599+
1600+
yield {
1601+
type: "stream-lifecycle",
1602+
workspaceId,
1603+
phase: "preparing",
1604+
hadAnyOutput: false,
1605+
};
1606+
await Promise.resolve();
1607+
yield {
1608+
type: "runtime-status",
1609+
workspaceId,
1610+
phase: "starting",
1611+
runtimeType: "ssh",
1612+
detail: startupDetail,
1613+
};
1614+
await new Promise<void>((resolve) => {
1615+
releaseSecondCaughtUp = resolve;
1616+
});
1617+
yield { type: "caught-up", replay: "full" };
1618+
await waitForAbortSignal(options?.signal);
1619+
});
1620+
1621+
createAndAddWorkspace(store, workspaceId);
1622+
1623+
const sawInitialStartup = await waitUntil(() => {
1624+
const state = store.getWorkspaceState(workspaceId);
1625+
return state.isStreamStarting && state.runtimeStatus?.detail === startupDetail;
1626+
});
1627+
expect(sawInitialStartup).toBe(true);
1628+
1629+
createAndAddWorkspace(store, otherWorkspaceId);
1630+
store.setActiveWorkspaceId(workspaceId);
1631+
1632+
const replayedStartupBeforeCaughtUp = await waitUntil(() => {
1633+
const state = store.getWorkspaceState(workspaceId);
1634+
return (
1635+
subscriptionCount >= 2 &&
1636+
state.isStreamStarting &&
1637+
state.runtimeStatus?.detail === startupDetail
1638+
);
1639+
});
1640+
expect(replayedStartupBeforeCaughtUp).toBe(true);
1641+
1642+
releaseSecondCaughtUp?.();
1643+
1644+
const stayedVisibleAfterCaughtUp = await waitUntil(() => {
1645+
const state = store.getWorkspaceState(workspaceId);
1646+
return (
1647+
!state.loading && state.isStreamStarting && state.runtimeStatus?.detail === startupDetail
1648+
);
1649+
});
1650+
expect(stayedVisibleAfterCaughtUp).toBe(true);
1651+
});
1652+
15611653
it("active workspace still shows starting during legitimate startup gap", async () => {
15621654
const workspaceId = "stream-starting-active-workspace";
15631655

src/browser/stores/WorkspaceStore.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
2828
import { useCallback, useSyncExternalStore } from "react";
2929
import {
3030
isCaughtUpMessage,
31+
isStreamAbort,
3132
isStreamError,
33+
isStreamLifecycle,
3234
isDeleteMessage,
3335
isBashOutputEvent,
3436
isTaskCreatedEvent,
3537
isMuxMessage,
3638
isQueuedMessageChanged,
3739
isRestoreToInput,
40+
isRuntimeStatus,
3841
} from "@/common/orpc/types";
3942
import {
4043
type StreamAbortEvent,
@@ -1551,17 +1554,20 @@ export class WorkspaceStore {
15511554
: (activity?.lastThinkingLevel ?? aggregator.getCurrentThinkingLevel() ?? null);
15521555
const hasAuthoritativeStreamLifecycle =
15531556
streamLifecycle !== null && streamLifecycle.phase !== "idle";
1557+
const hasReplayPreparingLifecycle =
1558+
isActiveWorkspace && !transient.caughtUp && streamLifecycle?.phase === "preparing";
15541559
const aggregatorRecency = aggregator.getRecencyTimestamp();
15551560
const recencyTimestamp =
15561561
aggregatorRecency === null
15571562
? (activity?.recency ?? null)
15581563
: Math.max(aggregatorRecency, activity?.recency ?? aggregatorRecency);
15591564
// Treat the backend lifecycle as authoritative, but keep any optimistic
15601565
// pre-stream "starting" state scoped to the active, caught-up workspace.
1561-
// Inactive or replaying workspaces should derive status from authoritative
1562-
// activity instead of a sticky local pending-start timestamp.
1566+
// Reconnect replay is the one exception: if the backend has already re-emitted
1567+
// a PREPARING lifecycle snapshot, keep showing startup instead of briefly
1568+
// hiding the barrier until caught-up lands.
15631569
const isStreamStarting =
1564-
useAggregatorState &&
1570+
(useAggregatorState || hasReplayPreparingLifecycle) &&
15651571
(streamLifecycle?.phase === "preparing" ||
15661572
(!hasAuthoritativeStreamLifecycle && pendingStreamStartTime !== null)) &&
15671573
!canInterrupt;
@@ -3566,7 +3572,7 @@ export class WorkspaceStore {
35663572
}
35673573

35683574
if (!transient.caughtUp && this.isBufferedEvent(data)) {
3569-
if ("type" in data && (data.type === "stream-lifecycle" || data.type === "stream-abort")) {
3575+
if (isStreamLifecycle(data) || isStreamAbort(data) || isRuntimeStatus(data)) {
35703576
applyWorkspaceChatEventToAggregator(aggregator, data, { allowSideEffects: false });
35713577
this.states.bump(workspaceId);
35723578
}

src/node/services/agentSession.preStreamError.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,55 @@ describe("AgentSession pre-stream errors", () => {
298298
expect(replayInit).toHaveBeenCalledWith(workspaceId);
299299
});
300300

301+
it("replays preparing runtime-status before caught-up so reconnects keep startup details", async () => {
302+
const workspaceId = "ws-replay-runtime-status";
303+
const { session, cleanup, replayInit, aiEmitter } =
304+
await createReplaySessionHarness(workspaceId);
305+
historyCleanup = cleanup;
306+
307+
const privateSession = session as unknown as {
308+
setTurnPhase(next: "idle" | "preparing" | "streaming" | "completing"): void;
309+
};
310+
311+
privateSession.setTurnPhase("preparing");
312+
aiEmitter.emit("runtime-status", {
313+
type: "runtime-status",
314+
workspaceId,
315+
phase: "starting",
316+
runtimeType: "ssh",
317+
source: "runtime",
318+
detail: "Checking workspace runtime...",
319+
});
320+
321+
const replayedEvents: WorkspaceChatMessage[] = [];
322+
await session.replayHistory(({ message }) => replayedEvents.push(message));
323+
324+
const replayedLifecycleIndex = replayedEvents.findIndex(
325+
(event) => event.type === "stream-lifecycle"
326+
);
327+
const replayedRuntimeStatusIndex = replayedEvents.findIndex(
328+
(event) => event.type === "runtime-status"
329+
);
330+
const caughtUpIndex = replayedEvents.findIndex((event) => event.type === "caught-up");
331+
const replayedRuntimeStatus = replayedEvents.find(
332+
(event): event is Extract<WorkspaceChatMessage, { type: "runtime-status" }> =>
333+
event.type === "runtime-status"
334+
);
335+
336+
expect(replayedRuntimeStatus).toEqual({
337+
type: "runtime-status",
338+
workspaceId,
339+
phase: "starting",
340+
runtimeType: "ssh",
341+
source: "runtime",
342+
detail: "Checking workspace runtime...",
343+
});
344+
expect(replayedLifecycleIndex).toBeGreaterThanOrEqual(0);
345+
expect(replayedRuntimeStatusIndex).toBeGreaterThan(replayedLifecycleIndex);
346+
expect(caughtUpIndex).toBeGreaterThan(replayedRuntimeStatusIndex);
347+
expect(replayInit).toHaveBeenCalledWith(workspaceId);
348+
});
349+
301350
it("schedules auto-retry when runtime startup fails before stream events", async () => {
302351
const workspaceId = "ws-runtime-start-failed";
303352

src/node/services/agentSession.ts

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/r
7676
import { MessageQueue } from "./messageQueue";
7777
import {
7878
copyStreamLifecycleSnapshot,
79+
type RuntimeStatusEvent,
7980
type StreamAbortReason,
8081
type StreamEndEvent,
8182
type StreamLifecycleSnapshot,
@@ -398,6 +399,16 @@ export class AgentSession {
398399
*/
399400
private terminalStreamError: StreamErrorMessage | null = null;
400401

402+
/**
403+
* Latest pre-stream runtime-status breadcrumb for the in-flight PREPARING turn.
404+
*
405+
* This used to live only in the renderer, which meant switching away from and back to an
406+
* SSH/Coder workspace could drop the startup detail text until a brand-new event arrived.
407+
* Keeping the latest breadcrumb in the session lets replay restore the same status UI that
408+
* live subscribers saw.
409+
*/
410+
private preparingRuntimeStatus: RuntimeStatusEvent | null = null;
411+
401412
/** Last lifecycle snapshot emitted to live subscribers (used for change detection only). */
402413
private lastEmittedStreamLifecycle: StreamLifecycleSnapshot | null = null;
403414

@@ -609,19 +620,38 @@ export class AgentSession {
609620
return this.terminalStreamLifecycle ?? { phase: "idle", hadAnyOutput: false };
610621
}
611622

623+
private hasSameStreamLifecycle(
624+
left: StreamLifecycleSnapshot | null,
625+
right: StreamLifecycleSnapshot
626+
): boolean {
627+
return (
628+
left !== null &&
629+
left.phase === right.phase &&
630+
left.hadAnyOutput === right.hadAnyOutput &&
631+
(left.abortReason ?? null) === (right.abortReason ?? null)
632+
);
633+
}
634+
635+
private hasSameRuntimeStatus(
636+
left: RuntimeStatusEvent | null,
637+
right: RuntimeStatusEvent
638+
): boolean {
639+
return (
640+
left !== null &&
641+
left.phase === right.phase &&
642+
left.runtimeType === right.runtimeType &&
643+
(left.source ?? null) === (right.source ?? null) &&
644+
(left.detail ?? null) === (right.detail ?? null)
645+
);
646+
}
647+
612648
private emitStreamLifecycleIfChanged(): void {
613649
if (this.disposed) {
614650
return;
615651
}
616652

617653
const snapshot = this.getCurrentStreamLifecycleSnapshot();
618-
const lastSnapshot = this.lastEmittedStreamLifecycle;
619-
if (
620-
lastSnapshot &&
621-
lastSnapshot.phase === snapshot.phase &&
622-
lastSnapshot.hadAnyOutput === snapshot.hadAnyOutput &&
623-
(lastSnapshot.abortReason ?? null) === (snapshot.abortReason ?? null)
624-
) {
654+
if (this.hasSameStreamLifecycle(this.lastEmittedStreamLifecycle, snapshot)) {
625655
return;
626656
}
627657

@@ -653,6 +683,19 @@ export class AgentSession {
653683
});
654684
}
655685

686+
private updatePreparingRuntimeStatus(status: RuntimeStatusEvent): void {
687+
if (status.phase === "ready" || status.phase === "error") {
688+
this.clearPreparingRuntimeStatus();
689+
return;
690+
}
691+
692+
this.preparingRuntimeStatus = status;
693+
}
694+
695+
private clearPreparingRuntimeStatus(): void {
696+
this.preparingRuntimeStatus = null;
697+
}
698+
656699
private emitRetryEvent(event: RetryStatusEvent): void {
657700
if (this.disposed) {
658701
return;
@@ -1550,6 +1593,7 @@ export class AgentSession {
15501593

15511594
let replayedTerminalStreamError = false;
15521595
let replayedStreamLifecycle: StreamLifecycleSnapshot | null = null;
1596+
let replayedRuntimeStatus: RuntimeStatusEvent | null = null;
15531597
const emitReplayStatusMessage = (message: WorkspaceChatMessage): void => {
15541598
listener({ workspaceId: this.workspaceId, message });
15551599
};
@@ -1563,21 +1607,20 @@ export class AgentSession {
15631607
}
15641608

15651609
const lifecycle = this.getCurrentStreamLifecycleSnapshot();
1566-
if (
1567-
replayedStreamLifecycle &&
1568-
replayedStreamLifecycle.phase === lifecycle.phase &&
1569-
replayedStreamLifecycle.hadAnyOutput === lifecycle.hadAnyOutput &&
1570-
(replayedStreamLifecycle.abortReason ?? null) === (lifecycle.abortReason ?? null)
1571-
) {
1572-
return;
1610+
if (!this.hasSameStreamLifecycle(replayedStreamLifecycle, lifecycle)) {
1611+
replayedStreamLifecycle = copyStreamLifecycleSnapshot(lifecycle);
1612+
emitReplayStatusMessage({
1613+
type: "stream-lifecycle",
1614+
workspaceId: this.workspaceId,
1615+
...lifecycle,
1616+
});
15731617
}
15741618

1575-
replayedStreamLifecycle = copyStreamLifecycleSnapshot(lifecycle);
1576-
emitReplayStatusMessage({
1577-
type: "stream-lifecycle",
1578-
workspaceId: this.workspaceId,
1579-
...lifecycle,
1580-
});
1619+
const runtimeStatus = this.preparingRuntimeStatus;
1620+
if (runtimeStatus && !this.hasSameRuntimeStatus(replayedRuntimeStatus, runtimeStatus)) {
1621+
replayedRuntimeStatus = { ...runtimeStatus };
1622+
emitReplayStatusMessage(runtimeStatus);
1623+
}
15811624
};
15821625

15831626
let emittedReplayStreamEvents = false;
@@ -3902,6 +3945,7 @@ export class AgentSession {
39023945

39033946
const preStreamAbortReason = "abortReason" in payload ? payload.abortReason : undefined;
39043947
if (this.turnPhase === TurnPhase.PREPARING) {
3948+
this.clearPreparingRuntimeStatus();
39053949
this.setTerminalStreamLifecycle("interrupted", {
39063950
abortReason: preStreamAbortReason,
39073951
hadAnyOutput: false,
@@ -3946,7 +3990,12 @@ export class AgentSession {
39463990
this.emitChatEvent(payload);
39473991
this.setTurnPhase(TurnPhase.IDLE);
39483992
});
3949-
forward("runtime-status", (payload) => this.emitChatEvent(payload));
3993+
forward("runtime-status", (payload) => {
3994+
if (payload.type === "runtime-status") {
3995+
this.updatePreparingRuntimeStatus(payload);
3996+
}
3997+
this.emitChatEvent(payload);
3998+
});
39503999

39514000
forward("stream-end", async (payload) => {
39524001
if (payload.type !== "stream-end") {
@@ -4151,6 +4200,7 @@ export class AgentSession {
41514200

41524201
private setTurnPhase(next: TurnPhase): void {
41534202
this.turnPhase = next;
4203+
this.clearPreparingRuntimeStatus();
41544204

41554205
if (next !== TurnPhase.IDLE) {
41564206
this.terminalStreamLifecycle = null;

0 commit comments

Comments
 (0)