Skip to content

Commit c96a6c5

Browse files
committed
fix: stabilize transport session workflows
1 parent ed23b6c commit c96a6c5

3 files changed

Lines changed: 95 additions & 19 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ type AgentWorkspaceFeatureProps = {
3333
onCloseAgentPane: (paneId: string, sessionId: string) => void;
3434
onDraftPaneModeChange: (paneId: string, mode: "new" | "restore") => void;
3535
onRestoreDraftSession: (paneId: string, sessionId: string) => void;
36-
onSubmitDraftPrompt: (paneId: string) => void;
36+
onSubmitDraftPrompt: (paneId: string, value?: string) => void;
3737
onDraftPromptChange: (paneId: string, value: string) => void;
3838
setDraftPromptInputRef: (paneId: string, element: HTMLInputElement | null) => void;
3939
setAgentTerminalRef: (paneId: string, handle: XtermBaseHandle | null) => void;
@@ -63,7 +63,7 @@ type AgentPaneLeafProps = {
6363
onCloseAgentPane: (paneId: string, sessionId: string) => void;
6464
onDraftPaneModeChange: (paneId: string, mode: "new" | "restore") => void;
6565
onRestoreDraftSession: (paneId: string, sessionId: string) => void;
66-
onSubmitDraftPrompt: (paneId: string) => void;
66+
onSubmitDraftPrompt: (paneId: string, value?: string) => void;
6767
onDraftPromptChange: (paneId: string, value: string) => void;
6868
setDraftPromptInputRef: (paneId: string, element: HTMLInputElement | null) => void;
6969
setAgentTerminalRef: (paneId: string, handle: XtermBaseHandle | null) => void;
@@ -146,7 +146,9 @@ const AgentPaneLeaf = memo(({
146146

147147
const handleSubmitDraftPrompt = useCallback((event: FormEvent<HTMLFormElement>) => {
148148
event.preventDefault();
149-
onSubmitDraftPrompt(paneId);
149+
const field = event.currentTarget.elements.namedItem("draftPrompt");
150+
const value = field instanceof HTMLInputElement ? field.value : undefined;
151+
onSubmitDraftPrompt(paneId, value);
150152
}, [onSubmitDraftPrompt, paneId]);
151153

152154
const handleDraftPromptRef = useCallback((element: HTMLInputElement | null) => {
@@ -248,6 +250,7 @@ const AgentPaneLeaf = memo(({
248250
<div className="agent-compose">
249251
<input
250252
ref={handleDraftPromptRef}
253+
name="draftPrompt"
251254
className="agent-compose-field agent-draft-launcher-field"
252255
value={draftPromptValue}
253256
onChange={(event) => onDraftPromptChange(paneId, event.target.value)}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2224,10 +2224,11 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
22242224
await nextQueue;
22252225
};
22262226

2227-
const onSubmitDraftPrompt = async (paneId: string) => {
2227+
const onSubmitDraftPrompt = async (paneId: string, submittedValue?: string) => {
22282228
if (!guardWorkspaceMutation("agent_input")) return;
22292229
const content = (
2230-
draftPromptInputRefs.current.get(paneId)?.value
2230+
submittedValue
2231+
?? draftPromptInputRefs.current.get(paneId)?.value
22312232
?? draftPromptInputs[paneId]
22322233
?? ""
22332234
).trim();

tests/e2e/transport.spec.ts

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ test.describe('workspace transport baseline', () => {
397397
const page = await context.newPage();
398398
const ids = { deviceId: 'device-runtime-reconnect', clientId: 'client-runtime-reconnect' };
399399
const replayClaudeSessionId = `claude-reconnect-${Date.now()}`;
400+
const workspacePath = await createExternalTempWorkspace('coder-studio-transport-reconnect-');
401+
let workspace: WorkspaceHandle | null = null;
400402

401403
try {
402404
await prepareTransportPage(page);
@@ -405,7 +407,7 @@ test.describe('workspace transport baseline', () => {
405407
agentCommand: `node ${AGENT_CLAUDE_LIFECYCLE_SCRIPT} --running-delay-ms 150 --stopped-delay-ms 3000 ${replayClaudeSessionId}`,
406408
});
407409
await seedWorkspaceControllerIds(page, ids);
408-
const workspace = await openWorkspace(page, ids);
410+
workspace = await openWorkspace(page, ids, workspacePath);
409411
await waitForBackendSocket(page);
410412
const controller = await currentWorkspaceController(page, workspace.workspaceId, ids);
411413
const session = await invokeRpc<{ id: number }>(page, 'create_session', {
@@ -485,7 +487,11 @@ test.describe('workspace transport baseline', () => {
485487
timeout: 4000,
486488
}).toBe('idle');
487489
} finally {
488-
await context.close();
490+
if (workspace && !page.isClosed()) {
491+
await closeWorkspaceBestEffort(page, workspace.workspaceId, ids);
492+
}
493+
await Promise.allSettled([context.close()]);
494+
await removeExternalWorkspace(workspacePath);
489495
}
490496
});
491497

@@ -495,6 +501,8 @@ test.describe('workspace transport baseline', () => {
495501
const page = await context.newPage();
496502
const ids = { deviceId: 'device-lifecycle', clientId: 'client-lifecycle' };
497503
const replayClaudeSessionId = `claude-replay-${Date.now()}`;
504+
const workspacePath = await createExternalTempWorkspace('coder-studio-transport-lifecycle-');
505+
let workspace: WorkspaceHandle | null = null;
498506

499507
try {
500508
await prepareTransportPage(page);
@@ -503,7 +511,7 @@ test.describe('workspace transport baseline', () => {
503511
agentCommand: `node ${AGENT_CLAUDE_LIFECYCLE_SCRIPT} --running-delay-ms ${AGENT_CLAUDE_REPLAY_DELAY_MS} ${replayClaudeSessionId}`,
504512
});
505513
await seedWorkspaceControllerIds(page, ids);
506-
const workspace = await openWorkspace(page, ids);
514+
workspace = await openWorkspace(page, ids, workspacePath);
507515
await waitForBackendSocket(page);
508516
const controller = await currentWorkspaceController(page, workspace.workspaceId, ids);
509517
const session = await invokeRpc<{ id: number }>(page, 'create_session', {
@@ -581,7 +589,11 @@ test.describe('workspace transport baseline', () => {
581589
page.locator(`.agent-pane-card[data-session-id="${session.id}"]`).first().getAttribute('data-session-status')
582590
).toBe('idle');
583591
} finally {
584-
await context.close();
592+
if (workspace && !page.isClosed()) {
593+
await closeWorkspaceBestEffort(page, workspace.workspaceId, ids);
594+
}
595+
await Promise.allSettled([context.close()]);
596+
await removeExternalWorkspace(workspacePath);
585597
}
586598
});
587599

@@ -593,6 +605,8 @@ test.describe('workspace transport baseline', () => {
593605
const observer = await observerContext.newPage();
594606
const controllerIds = { deviceId: 'device-a', clientId: 'client-a' };
595607
const observerIds = { deviceId: 'device-b', clientId: 'client-b' };
608+
const workspacePath = await createExternalTempWorkspace('coder-studio-transport-takeover-follow-');
609+
let workspace: WorkspaceHandle | null = null;
596610

597611
try {
598612
await prepareTransportPage(controller);
@@ -601,7 +615,7 @@ test.describe('workspace transport baseline', () => {
601615
await installTransportProbe(observer);
602616
await seedWorkspaceControllerIds(controller, controllerIds);
603617
await seedWorkspaceControllerIds(observer, observerIds);
604-
const workspace = await openWorkspace(controller, controllerIds);
618+
workspace = await openWorkspace(controller, controllerIds, workspacePath);
605619
await waitForBackendSocket(controller);
606620

607621
await observer.goto(`/workspace/${workspace.workspaceId}`);
@@ -629,10 +643,14 @@ test.describe('workspace transport baseline', () => {
629643
timeout: 15000,
630644
});
631645
} finally {
646+
if (workspace && !observer.isClosed()) {
647+
await closeWorkspaceBestEffort(observer, workspace.workspaceId, observerIds);
648+
}
632649
await Promise.allSettled([
633650
observerContext.close(),
634651
controllerContext.close(),
635652
]);
653+
await removeExternalWorkspace(workspacePath);
636654
}
637655
});
638656

@@ -644,6 +662,8 @@ test.describe('workspace transport baseline', () => {
644662
const observer = await observerContext.newPage();
645663
const controllerIds = { deviceId: 'device-click-a', clientId: 'client-click-a' };
646664
const observerIds = { deviceId: 'device-click-b', clientId: 'client-click-b' };
665+
const workspacePath = await createExternalTempWorkspace('coder-studio-transport-takeover-request-');
666+
let workspace: WorkspaceHandle | null = null;
647667

648668
try {
649669
await prepareTransportPage(controller);
@@ -652,7 +672,7 @@ test.describe('workspace transport baseline', () => {
652672
await installTransportProbe(observer);
653673
await seedWorkspaceControllerIds(controller, controllerIds);
654674
await seedWorkspaceControllerIds(observer, observerIds);
655-
const workspace = await openWorkspace(controller, controllerIds);
675+
workspace = await openWorkspace(controller, controllerIds, workspacePath);
656676
await waitForBackendSocket(controller);
657677

658678
await observer.goto(`/workspace/${workspace.workspaceId}`);
@@ -674,10 +694,14 @@ test.describe('workspace transport baseline', () => {
674694
timeout: 15000,
675695
});
676696
} finally {
697+
if (workspace && !observer.isClosed()) {
698+
await closeWorkspaceBestEffort(observer, workspace.workspaceId, observerIds);
699+
}
677700
await Promise.allSettled([
678701
observerContext.close(),
679702
controllerContext.close(),
680703
]);
704+
await removeExternalWorkspace(workspacePath);
681705
}
682706
});
683707

@@ -689,6 +713,8 @@ test.describe('workspace transport baseline', () => {
689713
const observer = await observerContext.newPage();
690714
const controllerIds = { deviceId: 'device-click-fail-a', clientId: 'client-click-fail-a' };
691715
const observerIds = { deviceId: 'device-click-fail-b', clientId: 'client-click-fail-b' };
716+
const workspacePath = await createExternalTempWorkspace('coder-studio-transport-takeover-error-');
717+
let workspace: WorkspaceHandle | null = null;
692718

693719
try {
694720
await prepareTransportPage(controller);
@@ -708,7 +734,7 @@ test.describe('workspace transport baseline', () => {
708734
});
709735
await seedWorkspaceControllerIds(controller, controllerIds);
710736
await seedWorkspaceControllerIds(observer, observerIds);
711-
const workspace = await openWorkspace(controller, controllerIds);
737+
workspace = await openWorkspace(controller, controllerIds, workspacePath);
712738
await waitForBackendSocket(controller);
713739

714740
await observer.goto(`/workspace/${workspace.workspaceId}`);
@@ -728,22 +754,27 @@ test.describe('workspace transport baseline', () => {
728754
'This workspace is following the current controller.',
729755
);
730756
} finally {
757+
if (workspace && !controller.isClosed()) {
758+
await closeWorkspaceBestEffort(controller, workspace.workspaceId, controllerIds);
759+
}
731760
await Promise.allSettled([
732761
observerContext.close(),
733762
controllerContext.close(),
734763
]);
764+
await removeExternalWorkspace(workspacePath);
735765
}
736766
});
737767

738768
test('first draft prompt becomes the session title', async ({ browser }) => {
739769
const context = await browser.newContext();
740770
const page = await context.newPage();
741771
const titleWorkspacePath = await createExternalTempWorkspace('coder-studio-transport-title-');
772+
let workspace: WorkspaceHandle | null = null;
742773

743774
try {
744775
await prepareTransportPage(page);
745776
await installTransportProbe(page);
746-
await openWorkspace(page, DEFAULT_TRANSPORT_IDS, titleWorkspacePath);
777+
workspace = await openWorkspace(page, DEFAULT_TRANSPORT_IDS, titleWorkspacePath);
747778
await waitForBackendSocket(page);
748779
await seedAppSettings(page, {
749780
agentCommand: `node ${AGENT_STDIN_ECHO_SCRIPT}`,
@@ -760,8 +791,11 @@ test.describe('workspace transport baseline', () => {
760791
timeout: 10000,
761792
});
762793
} finally {
763-
await context.close();
764-
await fs.rm(titleWorkspacePath, { recursive: true, force: true });
794+
if (workspace && !page.isClosed()) {
795+
await closeWorkspaceBestEffort(page, workspace.workspaceId, DEFAULT_TRANSPORT_IDS);
796+
}
797+
await Promise.allSettled([context.close()]);
798+
await removeExternalWorkspace(titleWorkspacePath);
765799
}
766800
});
767801

@@ -772,6 +806,8 @@ test.describe('workspace transport baseline', () => {
772806
const reopened = await reopenedContext.newPage();
773807
const controllerIds = { deviceId: 'device-reopen', clientId: 'client-a' };
774808
const reopenedIds = { deviceId: 'device-reopen', clientId: 'client-b' };
809+
const workspacePath = await createExternalTempWorkspace('coder-studio-transport-same-device-');
810+
let workspace: WorkspaceHandle | null = null;
775811

776812
try {
777813
await prepareTransportPage(controller);
@@ -781,7 +817,7 @@ test.describe('workspace transport baseline', () => {
781817
await seedWorkspaceControllerIds(controller, controllerIds);
782818
await seedWorkspaceControllerIds(reopened, reopenedIds);
783819

784-
const workspace = await openWorkspace(controller, controllerIds);
820+
workspace = await openWorkspace(controller, controllerIds, workspacePath);
785821
await waitForBackendSocket(controller);
786822

787823
await reopened.goto(`/workspace/${workspace.workspaceId}`);
@@ -820,10 +856,14 @@ test.describe('workspace transport baseline', () => {
820856
expect(runtime.controller.controller_device_id).toBe(reopenedIds.deviceId);
821857
expect(runtime.controller.controller_client_id).toBe(reopenedIds.clientId);
822858
} finally {
859+
if (workspace && !reopened.isClosed()) {
860+
await closeWorkspaceBestEffort(reopened, workspace.workspaceId, reopenedIds);
861+
}
823862
await Promise.allSettled([
824863
reopenedContext.close(),
825864
controllerContext.close(),
826865
]);
866+
await removeExternalWorkspace(workspacePath);
827867
}
828868
});
829869

@@ -834,6 +874,7 @@ test.describe('workspace transport baseline', () => {
834874
const ids = { deviceId: 'device-recovery', clientId: 'client-recovery' };
835875
const resumeClaudeSessionId = `claude-e2e-resume-${Date.now()}`;
836876
const workspacePath = await createExternalTempWorkspace('coder-studio-transport-recovery-');
877+
let workspace: WorkspaceHandle | null = null;
837878

838879
try {
839880
await prepareTransportPage(page);
@@ -842,7 +883,7 @@ test.describe('workspace transport baseline', () => {
842883
agentCommand: `node ${AGENT_CLAUDE_LIFECYCLE_SCRIPT} --running-delay-ms ${AGENT_CLAUDE_RECOVERY_DELAY_MS}`,
843884
});
844885
await seedWorkspaceControllerIds(page, ids);
845-
const workspace = await openWorkspace(page, ids, workspacePath);
886+
workspace = await openWorkspace(page, ids, workspacePath);
846887
await waitForBackendSocket(page);
847888
const controller = await currentWorkspaceController(page, workspace.workspaceId, ids);
848889
const session = await invokeRpc<{ id: number }>(page, 'create_session', {
@@ -919,8 +960,11 @@ test.describe('workspace transport baseline', () => {
919960
message: `resumed session status after reload: ${resumedStatus ?? 'null'}`,
920961
}).toBe(true);
921962
} finally {
922-
await context.close();
923-
await fs.rm(workspacePath, { recursive: true, force: true });
963+
if (workspace && !page.isClosed()) {
964+
await closeWorkspaceBestEffort(page, workspace.workspaceId, ids);
965+
}
966+
await Promise.allSettled([context.close()]);
967+
await removeExternalWorkspace(workspacePath);
924968
}
925969
});
926970
});
@@ -1435,6 +1479,34 @@ async function closeAllOpenWorkspaces(page: Page) {
14351479
}
14361480
}
14371481

1482+
async function closeWorkspaceBestEffort(
1483+
page: Page,
1484+
workspaceId: string,
1485+
ids: { deviceId: string; clientId: string } = DEFAULT_TRANSPORT_IDS,
1486+
) {
1487+
try {
1488+
const controller = await currentWorkspaceController(page, workspaceId, ids);
1489+
await invokeRpc(page, 'close_workspace', controller);
1490+
} catch {
1491+
// Best-effort cleanup for flaky transport cases.
1492+
}
1493+
}
1494+
1495+
async function removeExternalWorkspace(workspacePath: string) {
1496+
for (let attempt = 0; attempt < 5; attempt += 1) {
1497+
try {
1498+
await fs.rm(workspacePath, { recursive: true, force: true });
1499+
return;
1500+
} catch (error) {
1501+
const code = (error as NodeJS.ErrnoException).code;
1502+
if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === 4) {
1503+
throw error;
1504+
}
1505+
await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)));
1506+
}
1507+
}
1508+
}
1509+
14381510
async function waitForPollCycle(counts: PollCounts) {
14391511
await expect
14401512
.poll(() => {

0 commit comments

Comments
 (0)