Skip to content

Commit 133c380

Browse files
committed
Fix runtime reattach and websocket resilience
1 parent 82ebe88 commit 133c380

16 files changed

Lines changed: 805 additions & 151 deletions

apps/web/src/features/agents/AgentWorkspaceFeature.tsx

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ import { AgentStreamTerminal, type XtermBaseHandle } from "../../components/term
77
import { isHiddenDraftPlaceholder, sessionCompletionRatio, sessionHeaderTag, sessionTone } from "../../shared/utils/session";
88
import { stripAnsi } from "../../shared/utils/ansi";
99

10+
const PANE_PREVIEW_LINE_LIMIT = 16;
11+
const PANE_PREVIEW_CHAR_LIMIT = 1_200;
12+
13+
const buildPanePreview = (stream: string) => {
14+
const normalized = stripAnsi(stream).replaceAll("\r", "");
15+
if (!normalized.trim()) {
16+
return "";
17+
}
18+
const tail = normalized.split("\n").slice(-PANE_PREVIEW_LINE_LIMIT).join("\n").trimStart();
19+
if (tail.length <= PANE_PREVIEW_CHAR_LIMIT) {
20+
return tail;
21+
}
22+
return tail.slice(-PANE_PREVIEW_CHAR_LIMIT).trimStart();
23+
};
24+
1025
type AgentWorkspaceFeatureProps = {
1126
visible: boolean;
1227
locale: Locale;
@@ -95,6 +110,8 @@ export const AgentWorkspaceFeature = ({
95110
const statusTone = sessionTone(session.status);
96111
const headerTag = sessionHeaderTag(session.status, locale);
97112
const showDraftPromptInput = isHiddenDraftPlaceholder(session);
113+
const showLiveTerminal = isPaneActive;
114+
const preview = showLiveTerminal ? "" : buildPanePreview(session.stream);
98115

99116
return (
100117
<section
@@ -191,21 +208,31 @@ export const AgentWorkspaceFeature = ({
191208
</div>
192209
</div>
193210
) : (
194-
<AgentStreamTerminal
195-
ref={(handle) => setAgentTerminalRef(node.id, handle)}
196-
streamId={session.id}
197-
stream={session.stream}
198-
toneKey={isPaneActive ? "active" : "inactive"}
199-
theme={theme}
200-
fontSize={terminalFontSize}
201-
compatibilityMode={terminalCompatibilityMode}
202-
mode="interactive"
203-
autoFocus={isPaneActive}
204-
onData={(data) => {
205-
onAgentTerminalData(node.id, data);
206-
}}
207-
onSize={(size) => onAgentTerminalSize(node.id, activeTab.id, session.id, size)}
208-
/>
211+
showLiveTerminal ? (
212+
<AgentStreamTerminal
213+
ref={(handle) => setAgentTerminalRef(node.id, handle)}
214+
streamId={session.id}
215+
stream={session.stream}
216+
toneKey={isPaneActive ? "active" : "inactive"}
217+
theme={theme}
218+
fontSize={terminalFontSize}
219+
compatibilityMode={terminalCompatibilityMode}
220+
mode="interactive"
221+
autoFocus={isPaneActive}
222+
onData={(data) => {
223+
onAgentTerminalData(node.id, data);
224+
}}
225+
onSize={(size) => onAgentTerminalSize(node.id, activeTab.id, session.id, size)}
226+
/>
227+
) : (
228+
<div className={`agent-pane-preview ${preview ? "" : "empty"}`}>
229+
{preview ? (
230+
<pre>{preview}</pre>
231+
) : (
232+
<div className="terminal-empty">{t("noAgentOutputYet")}</div>
233+
)}
234+
</div>
235+
)
209236
)}
210237
</div>
211238
</section>

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

Lines changed: 101 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
} from "../../services/http/session.service.ts";
1313
import {
1414
activateWorkspace as activateWorkspaceRequest,
15-
attachWorkspaceRuntime,
1615
heartbeatWorkspaceController,
1716
releaseWorkspaceControllerKeepalive,
1817
requestWorkspaceTakeover,
@@ -49,6 +48,7 @@ import {
4948
getOrCreateClientId as getWorkspaceClientId,
5049
getOrCreateDeviceId as getWorkspaceDeviceId,
5150
} from "../workspace/workspace-controller";
51+
import { attachWorkspaceRuntimeWithRetry } from "../workspace/runtime-attach";
5252
import { createWorkspaceSessionActions } from "../workspace/session-actions";
5353
import { useWorkspaceTransportSync } from "../workspace/workspace-sync-hooks";
5454
import {
@@ -61,6 +61,13 @@ const withServiceFallback = async <T,>(
6161
fallback: T,
6262
): Promise<T> => withFallback(operation, fallback);
6363

64+
const CONTROLLER_HEARTBEAT_INTERVAL_MS = 10_000;
65+
const HIDDEN_CONTROLLER_HEARTBEAT_INTERVAL_MS = 20_000;
66+
const CONTROLLER_RECOVERY_INTERVAL_MS = 1_000;
67+
const HIDDEN_CONTROLLER_RECOVERY_INTERVAL_MS = 5_000;
68+
const TAKEOVER_POLL_INTERVAL_MS = 2_000;
69+
const HIDDEN_TAKEOVER_POLL_INTERVAL_MS = 5_000;
70+
6471
type WorkbenchRuntimeCoordinatorProps = {
6572
appSettings: AppSettings;
6673
locale: Locale;
@@ -73,6 +80,9 @@ export const WorkbenchRuntimeCoordinator = ({
7380
const navigate = useNavigate();
7481
const [state, setState] = useRelaxState(workbenchState);
7582
const stateRef = useRef(state);
83+
const [isOnline, setIsOnline] = useState(() => (
84+
typeof navigator === "undefined" ? true : navigator.onLine !== false
85+
));
7686
const [isWindowFocused, setIsWindowFocused] = useState(() => (
7787
typeof document === "undefined" ? true : document.hasFocus()
7888
));
@@ -104,6 +114,9 @@ export const WorkbenchRuntimeCoordinator = ({
104114
exited: boolean;
105115
}>());
106116
const agentStartupTokenRef = useRef(0);
117+
const runtimeAttachInflightRef = useRef(new Map<string, Promise<void>>());
118+
const heartbeatInflightRef = useRef(new Set<string>());
119+
const takeoverPollingInflightRef = useRef(new Set<string>());
107120
const t = useMemo(() => createTranslator(locale), [locale]);
108121
const deviceId = useMemo(() => getWorkspaceDeviceId(), []);
109122
const clientId = useMemo(() => getWorkspaceClientId(), []);
@@ -144,7 +157,6 @@ export const WorkbenchRuntimeCoordinator = ({
144157
setIsDocumentVisible(document.visibilityState === "visible");
145158
};
146159

147-
syncVisibility();
148160
window.addEventListener("focus", syncVisibility);
149161
window.addEventListener("blur", syncVisibility);
150162
document.addEventListener("visibilitychange", syncVisibility);
@@ -155,6 +167,24 @@ export const WorkbenchRuntimeCoordinator = ({
155167
};
156168
}, []);
157169

170+
useEffect(() => {
171+
if (typeof window === "undefined") {
172+
return;
173+
}
174+
175+
const syncOnlineState = () => {
176+
setIsOnline(typeof navigator === "undefined" ? true : navigator.onLine !== false);
177+
};
178+
179+
syncOnlineState();
180+
window.addEventListener("online", syncOnlineState);
181+
window.addEventListener("offline", syncOnlineState);
182+
return () => {
183+
window.removeEventListener("online", syncOnlineState);
184+
window.removeEventListener("offline", syncOnlineState);
185+
};
186+
}, []);
187+
158188
useEffect(() => {
159189
if (typeof window === "undefined") {
160190
return;
@@ -352,7 +382,6 @@ export const WorkbenchRuntimeCoordinator = ({
352382

353383
const {
354384
markSessionIdle,
355-
refreshTabFromBackend,
356385
settleSessionAfterExit,
357386
} = createWorkspaceSessionActions({
358387
appSettings,
@@ -365,13 +394,46 @@ export const WorkbenchRuntimeCoordinator = ({
365394
onCompletionReminder,
366395
});
367396

397+
const reattachWorkspaceRuntime = useCallback(async (workspaceId: string) => {
398+
const inflight = runtimeAttachInflightRef.current.get(workspaceId);
399+
if (inflight) {
400+
await inflight;
401+
return;
402+
}
403+
404+
const task = (async () => {
405+
const runtimeSnapshot = await attachWorkspaceRuntimeWithRetry(
406+
workspaceId,
407+
deviceId,
408+
clientId,
409+
withServiceFallback,
410+
);
411+
if (!runtimeSnapshot) {
412+
return;
413+
}
414+
updateState((current) => applyWorkspaceRuntimeSnapshot(
415+
current,
416+
runtimeSnapshot,
417+
locale,
418+
appSettings,
419+
deviceId,
420+
clientId,
421+
));
422+
})().finally(() => {
423+
runtimeAttachInflightRef.current.delete(workspaceId);
424+
});
425+
426+
runtimeAttachInflightRef.current.set(workspaceId, task);
427+
await task;
428+
}, [appSettings, clientId, deviceId, locale, updateState]);
429+
368430
useWorkspaceTransportSync({
369431
agentRuntimeRefs,
370432
bootstrapReady: state.tabs.length > 0,
371433
clientId,
372434
deviceId,
373-
refreshTabFromBackend,
374435
markSessionIdle,
436+
reattachWorkspaceRuntime,
375437
settleSessionAfterExit,
376438
syncSessionPatch,
377439
stateRef,
@@ -392,31 +454,14 @@ export const WorkbenchRuntimeCoordinator = ({
392454
return;
393455
}
394456

395-
let cancelled = false;
396457
const workspaceIds = state.tabs
397458
.filter((tab) => tab.status === "ready")
398459
.map((tab) => tab.id);
399460

400461
void Promise.all(workspaceIds.map(async (workspaceId) => {
401-
const runtimeSnapshot = await withServiceFallback(
402-
() => attachWorkspaceRuntime(workspaceId, deviceId, clientId),
403-
null,
404-
);
405-
if (!runtimeSnapshot || cancelled) return;
406-
updateState((current) => applyWorkspaceRuntimeSnapshot(
407-
current,
408-
runtimeSnapshot,
409-
locale,
410-
appSettings,
411-
deviceId,
412-
clientId,
413-
));
462+
await reattachWorkspaceRuntime(workspaceId);
414463
}));
415-
416-
return () => {
417-
cancelled = true;
418-
};
419-
}, [appSettings, attachedWorkspaceFingerprint, clientId, deviceId, locale, updateState]);
464+
}, [attachedWorkspaceFingerprint, reattachWorkspaceRuntime]);
420465

421466
const controllerHeartbeatFingerprint = useMemo(
422467
() => state.tabs
@@ -427,7 +472,7 @@ export const WorkbenchRuntimeCoordinator = ({
427472
);
428473

429474
useEffect(() => {
430-
if (!controllerHeartbeatFingerprint) {
475+
if (!controllerHeartbeatFingerprint || !isOnline) {
431476
return;
432477
}
433478

@@ -437,6 +482,10 @@ export const WorkbenchRuntimeCoordinator = ({
437482

438483
const sendHeartbeat = () => {
439484
controllerWorkspaceIds().forEach((workspaceId) => {
485+
if (heartbeatInflightRef.current.has(workspaceId)) {
486+
return;
487+
}
488+
heartbeatInflightRef.current.add(workspaceId);
440489
void heartbeatWorkspaceController(workspaceId, deviceId, clientId)
441490
.then((controller) => {
442491
updateState((current) => applyWorkspaceControllerEvent(current, {
@@ -446,16 +495,22 @@ export const WorkbenchRuntimeCoordinator = ({
446495
})
447496
.catch(() => {
448497
// The WS controller stream or next attach will converge the UI.
498+
})
499+
.finally(() => {
500+
heartbeatInflightRef.current.delete(workspaceId);
449501
});
450502
});
451503
};
452504

453505
sendHeartbeat();
454-
const timer = window.setInterval(sendHeartbeat, 10000);
506+
const timer = window.setInterval(
507+
sendHeartbeat,
508+
isDocumentVisible ? CONTROLLER_HEARTBEAT_INTERVAL_MS : HIDDEN_CONTROLLER_HEARTBEAT_INTERVAL_MS,
509+
);
455510
return () => {
456511
window.clearInterval(timer);
457512
};
458-
}, [clientId, controllerHeartbeatFingerprint, deviceId, updateState]);
513+
}, [clientId, controllerHeartbeatFingerprint, deviceId, isDocumentVisible, isOnline, updateState]);
459514

460515
const controllerRecoveryFingerprint = useMemo(
461516
() => state.tabs
@@ -472,11 +527,10 @@ export const WorkbenchRuntimeCoordinator = ({
472527
);
473528

474529
useEffect(() => {
475-
if (!controllerRecoveryFingerprint) {
530+
if (!controllerRecoveryFingerprint || !isOnline) {
476531
return;
477532
}
478533

479-
let cancelled = false;
480534
const recoverableWorkspaceIds = () => stateRef.current.tabs
481535
.filter((tab) =>
482536
tab.status === "ready"
@@ -489,30 +543,20 @@ export const WorkbenchRuntimeCoordinator = ({
489543

490544
const recoverControllers = () => {
491545
void Promise.all(recoverableWorkspaceIds().map(async (workspaceId) => {
492-
const runtimeSnapshot = await withServiceFallback(
493-
() => attachWorkspaceRuntime(workspaceId, deviceId, clientId),
494-
null,
495-
);
496-
if (!runtimeSnapshot || cancelled) return;
497-
updateState((current) => applyWorkspaceRuntimeSnapshot(
498-
current,
499-
runtimeSnapshot,
500-
locale,
501-
appSettings,
502-
deviceId,
503-
clientId,
504-
));
546+
await reattachWorkspaceRuntime(workspaceId);
505547
}));
506548
};
507549

508550
recoverControllers();
509-
const timer = window.setInterval(recoverControllers, 1000);
551+
const timer = window.setInterval(
552+
recoverControllers,
553+
isDocumentVisible ? CONTROLLER_RECOVERY_INTERVAL_MS : HIDDEN_CONTROLLER_RECOVERY_INTERVAL_MS,
554+
);
510555

511556
return () => {
512-
cancelled = true;
513557
window.clearInterval(timer);
514558
};
515-
}, [appSettings, clientId, controllerRecoveryFingerprint, deviceId, locale, updateState]);
559+
}, [controllerRecoveryFingerprint, isDocumentVisible, isOnline, reattachWorkspaceRuntime]);
516560

517561
const takeoverPollingFingerprint = useMemo(
518562
() => state.tabs
@@ -528,7 +572,7 @@ export const WorkbenchRuntimeCoordinator = ({
528572
);
529573

530574
useEffect(() => {
531-
if (!takeoverPollingFingerprint) {
575+
if (!takeoverPollingFingerprint || !isOnline) {
532576
return;
533577
}
534578

@@ -543,6 +587,10 @@ export const WorkbenchRuntimeCoordinator = ({
543587

544588
const pollTakeover = () => {
545589
takeoverWorkspaceIds().forEach((workspaceId) => {
590+
if (takeoverPollingInflightRef.current.has(workspaceId)) {
591+
return;
592+
}
593+
takeoverPollingInflightRef.current.add(workspaceId);
546594
void requestWorkspaceTakeover(workspaceId, deviceId, clientId)
547595
.then((controller) => {
548596
updateState((current) => applyWorkspaceControllerEvent(current, {
@@ -552,16 +600,22 @@ export const WorkbenchRuntimeCoordinator = ({
552600
})
553601
.catch(() => {
554602
// Keep waiting; the controller stream is the primary source of truth.
603+
})
604+
.finally(() => {
605+
takeoverPollingInflightRef.current.delete(workspaceId);
555606
});
556607
});
557608
};
558609

559610
pollTakeover();
560-
const timer = window.setInterval(pollTakeover, 2000);
611+
const timer = window.setInterval(
612+
pollTakeover,
613+
isDocumentVisible ? TAKEOVER_POLL_INTERVAL_MS : HIDDEN_TAKEOVER_POLL_INTERVAL_MS,
614+
);
561615
return () => {
562616
window.clearInterval(timer);
563617
};
564-
}, [clientId, deviceId, takeoverPollingFingerprint, updateState]);
618+
}, [clientId, deviceId, isDocumentVisible, isOnline, takeoverPollingFingerprint, updateState]);
565619

566620
const settingsSyncFingerprint = useMemo(() => state.tabs.map((tab) => [
567621
tab.id,

0 commit comments

Comments
 (0)