Skip to content

Commit 675b40c

Browse files
committed
fix: stabilize runtime reload hydration on slow runners
1 parent 65be6f7 commit 675b40c

2 files changed

Lines changed: 59 additions & 16 deletions

File tree

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ const CONTROLLER_RECOVERY_INTERVAL_MS = 1_000;
6969
const HIDDEN_CONTROLLER_RECOVERY_INTERVAL_MS = 5_000;
7070
const TAKEOVER_POLL_INTERVAL_MS = 2_000;
7171
const HIDDEN_TAKEOVER_POLL_INTERVAL_MS = 5_000;
72+
// Reloads can briefly land on a stale bootstrap snapshot before runtime replay settles,
73+
// especially on slower CI runners. Reattaching ready tabs a few times closes that gap.
74+
const READY_TAB_RUNTIME_RECOVERY_DELAYS_MS = [0, 1_000, 3_000, 7_000] as const;
7275

7376
type WorkbenchRuntimeCoordinatorProps = {
7477
appSettings: AppSettings;
@@ -465,18 +468,35 @@ export const WorkbenchRuntimeCoordinator = ({
465468
);
466469

467470
useEffect(() => {
468-
if (!attachedWorkspaceFingerprint) {
471+
if (!attachedWorkspaceFingerprint || !isOnline) {
469472
return;
470473
}
471474

472-
const workspaceIds = state.tabs
473-
.filter((tab) => tab.status === "ready")
474-
.map((tab) => tab.id);
475+
let cancelled = false;
476+
const recoverReadyTabs = () => {
477+
if (cancelled) {
478+
return;
479+
}
480+
const workspaceIds = stateRef.current.tabs
481+
.filter((tab) => tab.status === "ready")
482+
.map((tab) => tab.id);
483+
void Promise.all(workspaceIds.map(async (workspaceId) => {
484+
await reattachWorkspaceRuntime(workspaceId);
485+
}));
486+
};
475487

476-
void Promise.all(workspaceIds.map(async (workspaceId) => {
477-
await reattachWorkspaceRuntime(workspaceId);
478-
}));
479-
}, [attachedWorkspaceFingerprint, reattachWorkspaceRuntime]);
488+
recoverReadyTabs();
489+
const timers = READY_TAB_RUNTIME_RECOVERY_DELAYS_MS
490+
.filter((delayMs) => delayMs > 0)
491+
.map((delayMs) => window.setTimeout(recoverReadyTabs, delayMs));
492+
493+
return () => {
494+
cancelled = true;
495+
timers.forEach((timer) => {
496+
window.clearTimeout(timer);
497+
});
498+
};
499+
}, [attachedWorkspaceFingerprint, isOnline, reattachWorkspaceRuntime]);
480500

481501
const controllerHeartbeatFingerprint = useMemo(
482502
() => state.tabs

tests/e2e/transport.spec.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -938,15 +938,15 @@ test.describe('workspace transport baseline', () => {
938938
await expect(page.getByTestId('workspace-agent-recovery-action')).toHaveText('Resume agent');
939939
await page.getByTestId('workspace-agent-recovery-action').click();
940940

941-
await waitForWsEvent(
941+
await waitForLifecycleReplayEntry(
942942
page,
943-
'agent://lifecycle',
944-
(payload) =>
945-
payload.workspace_id === workspace.workspaceId
946-
&& payload.session_id === String(session.id)
947-
&& payload.kind === 'tool_started'
948-
&& typeof payload.data === 'string'
949-
&& payload.data.includes(resumeClaudeSessionId),
943+
workspace.workspaceId,
944+
ids,
945+
(event) =>
946+
event.session_id === String(session.id)
947+
&& event.kind === 'tool_started'
948+
&& typeof event.data === 'string'
949+
&& event.data.includes(resumeClaudeSessionId),
950950
TRANSPORT_EVENT_TIMEOUT_MS,
951951
);
952952

@@ -1702,6 +1702,29 @@ async function waitForLifecycleReplayEvent(
17021702
.toBe(true);
17031703
}
17041704

1705+
async function waitForLifecycleReplayEntry(
1706+
page: Page,
1707+
workspaceId: string,
1708+
ids: { deviceId: string; clientId: string },
1709+
predicate: (event: { session_id: string; kind: string; data?: string }) => boolean,
1710+
timeoutMs = 10000,
1711+
) {
1712+
await expect
1713+
.poll(async () => {
1714+
const runtime = await invokeRpc<{
1715+
lifecycle_events?: Array<{ session_id: string; kind: string; data?: string }>;
1716+
}>(page, 'workspace_runtime_attach', {
1717+
workspaceId,
1718+
deviceId: ids.deviceId,
1719+
clientId: ids.clientId,
1720+
});
1721+
return runtime.lifecycle_events?.some(predicate) ?? false;
1722+
}, {
1723+
timeout: timeoutMs,
1724+
})
1725+
.toBe(true);
1726+
}
1727+
17051728
async function countWsEvents(
17061729
page: Page,
17071730
eventName: string,

0 commit comments

Comments
 (0)