Skip to content

Commit db1c0c9

Browse files
committed
fix: recover observer runtimes after reload
1 parent a76444b commit db1c0c9

4 files changed

Lines changed: 51 additions & 10 deletions

File tree

apps/web/src/features/app/WorkbenchRuntimeCoordinator.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
collectControlledWorkspaceReleasePayloads,
4949
getOrCreateClientId as getWorkspaceClientId,
5050
getOrCreateDeviceId as getWorkspaceDeviceId,
51+
shouldRecoverWorkspaceController,
5152
} from "../workspace/workspace-controller";
5253
import { attachWorkspaceRuntimeWithRetry } from "../workspace/runtime-attach";
5354
import { createWorkspaceSessionActions } from "../workspace/session-actions";
@@ -509,10 +510,7 @@ export const WorkbenchRuntimeCoordinator = ({
509510
() => state.tabs
510511
.filter((tab) =>
511512
tab.status === "ready"
512-
&& tab.controller.role === "observer"
513-
&& !tab.controller.controllerDeviceId
514-
&& !tab.controller.controllerClientId
515-
&& !tab.controller.takeoverPending,
513+
&& shouldRecoverWorkspaceController(tab.controller),
516514
)
517515
.map((tab) => tab.id)
518516
.join("|"),
@@ -527,14 +525,13 @@ export const WorkbenchRuntimeCoordinator = ({
527525
const recoverableWorkspaceIds = () => stateRef.current.tabs
528526
.filter((tab) =>
529527
tab.status === "ready"
530-
&& tab.controller.role === "observer"
531-
&& !tab.controller.controllerDeviceId
532-
&& !tab.controller.controllerClientId
533-
&& !tab.controller.takeoverPending,
528+
&& shouldRecoverWorkspaceController(tab.controller),
534529
)
535530
.map((tab) => tab.id);
536531

537532
const recoverControllers = () => {
533+
// Observer tabs can miss an initial runtime attach/controller event during reloads.
534+
// Reattaching until the tab converges keeps recovery/replay flows stable across slower environments.
538535
void Promise.all(recoverableWorkspaceIds().map(async (workspaceId) => {
539536
await reattachWorkspaceRuntime(workspaceId);
540537
}));

apps/web/src/features/workspace/WorkspaceScreen.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,8 +1700,33 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
17001700
};
17011701

17021702
const onRecoverActiveSession = async () => {
1703-
const currentTab = stateRef.current.tabs.find((tab) => tab.id === activeTab.id) ?? activeTab;
1704-
const session = currentTab.sessions.find((item) => item.id === activePaneSession.id) ?? activePaneSession;
1703+
let currentTab = stateRef.current.tabs.find((tab) => tab.id === activeTab.id) ?? activeTab;
1704+
let session = currentTab.sessions.find((item) => item.id === activePaneSession.id) ?? activePaneSession;
1705+
const runtimeSnapshot = await attachWorkspaceRuntimeWithRetry(
1706+
currentTab.id,
1707+
deviceId,
1708+
clientId,
1709+
withServiceFallback,
1710+
);
1711+
if (runtimeSnapshot) {
1712+
let nextTab: Tab | null = null;
1713+
updateState((current) => {
1714+
const next = applyWorkspaceRuntimeSnapshot(
1715+
current,
1716+
runtimeSnapshot,
1717+
locale,
1718+
appSettings,
1719+
deviceId,
1720+
clientId,
1721+
);
1722+
nextTab = next.tabs.find((tab) => tab.id === currentTab.id) ?? null;
1723+
return next;
1724+
});
1725+
if (nextTab) {
1726+
currentTab = nextTab;
1727+
session = nextTab.sessions.find((item) => item.id === session.id) ?? session;
1728+
}
1729+
}
17051730
const recoveryAction = resolveAgentRecoveryAction(currentTab.controller, session);
17061731
if (!recoveryAction) return;
17071732
setAgentRecoveryBusy({

apps/web/src/features/workspace/workspace-controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,7 @@ export const canMutateWorkspace = (
163163
controller: WorkspaceControllerState | null | undefined,
164164
_action: WorkspaceMutationAction,
165165
) => controller?.role === "controller";
166+
167+
export const shouldRecoverWorkspaceController = (
168+
controller: WorkspaceControllerState | null | undefined,
169+
) => controller?.role === "observer" && !controller.takeoverPending;

tests/workspace-runtime-controller.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createWorkspaceControllerRpcPayload,
88
createWorkspaceControllerState,
99
createWorkspaceControllerStateFromLease,
10+
shouldRecoverWorkspaceController,
1011
} from "../apps/web/src/features/workspace/workspace-controller.ts";
1112
import {
1213
applyWorkspaceControllerEvent,
@@ -127,6 +128,20 @@ test("same device with a different client stays observer", () => {
127128
assert.equal(controller.role, "observer");
128129
});
129130

131+
test("observer controllers remain recoverable while takeover is not pending", () => {
132+
const controller = createWorkspaceControllerState({
133+
role: "observer",
134+
deviceId: "device-a",
135+
clientId: "client-b",
136+
controllerDeviceId: "device-a",
137+
controllerClientId: "client-a",
138+
fencingToken: 1,
139+
takeoverPending: false,
140+
});
141+
142+
assert.equal(shouldRecoverWorkspaceController(controller), true);
143+
});
144+
130145
test("controller mutation payload carries device, client, and fencing token", () => {
131146
const controller = createWorkspaceControllerState({
132147
role: "controller",

0 commit comments

Comments
 (0)