authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 20:36:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 20:36:45-07:00
log5c715c6941184da09bf11f6651d303d284bd54da
tree21fb2f0ba5567450de8f93676188f8dd19ff80f3
parent8b81a8880f56daf475136e9d89794bb2a33526d0
signature Signed by PGP key B5690EEEBB952194

fix(server): keep preview browser connected after operation timeouts (#17693)


4 files changed, 47 insertions(+), 31 deletions(-)

apps/server/src/mcp/McpHttpServer.test.ts+2-2
......@@ -234,14 +234,14 @@ it.effect.each([
234234 ).pipe(Effect.provide(layerTest)),
235235);
236236
237it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () =>
237it.effect("tells the agent how to recover when no preview host can run the snapshot", () =>
238238 Effect.gen(function* () {
239239 const snapshot = yield* callSnapshot({});
240240
241241 expect(snapshot.isError).toBe(true);
242242 const [text] = snapshot.content;
243243 expect(text?.type === "text" ? text.text : "").toContain(
244 "use a headless browser from the shell",
244 "Retry preview_status, then call preview_open",
245245 );
246246 expect(snapshot.structuredContent).toMatchObject({
247247 error: { _tag: "PreviewAutomationNoAvailableHostError" },
apps/server/src/mcp/PreviewAutomationBroker.test.ts+34-24
......@@ -507,7 +507,6 @@ it.effect.each([
507507 threadId: scope.thread.threadId,
508508 });
509509 expect(error.cause).toBe(remoteError);
510 expect(error.message).toContain("remains on the desktop");
511510 expect(error.message).not.toContain("remote recording details");
512511 }),
513512 ),
......@@ -1352,7 +1351,7 @@ it.effect("discards buffered actions before completing an evicted host stream",
13521351 })
13531352 .pipe(Effect.flip, Effect.forkScoped);
13541353 yield* Deferred.await(actionRouted);
1355 yield* TestClock.adjust(1_000);
1354 yield* TestClock.adjust(2_000);
13561355 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
13571356 expect(yield* Fiber.join(buffered)).toMatchObject({
13581357 _tag: "PreviewAutomationClientDisconnectedError",
......@@ -1414,7 +1413,7 @@ it.effect("rejects a routed action when its generation is evicted before deliver
14141413 Effect.forkScoped,
14151414 );
14161415 yield* Deferred.await(actionRouted);
1417 yield* TestClock.adjust(1_000);
1416 yield* TestClock.adjust(2_000);
14181417 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
14191418 expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true);
14201419
......@@ -1428,30 +1427,41 @@ it.effect("rejects a routed action when its generation is evicted before deliver
14281427 ),
14291428);
14301429
1431it.effect("keeps a host that responds with an operation timeout", () =>
1430it.effect("keeps a host whose operation timeout arrives just after the budget", () =>
14321431 Effect.scoped(
14331432 Effect.gen(function* () {
14341433 const broker = yield* makeBroker;
1435 const connected = yield* Deferred.make<void>();
1436 const events = yield* broker.connect(makeHost());
1437 yield* Stream.runForEach(events, (event) => {
1438 if (event.type === "connected") return Deferred.succeed(connected, undefined);
1439 return broker.respond({
1440 clientId: "client-1",
1441 connectionId: event.connectionId,
1442 requestId: event.request.requestId,
1443 ...(event.request.operation === "waitFor"
1444 ? {
1445 ok: false,
1446 error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" },
1447 }
1448 : { ok: true, result: "responsive" }),
1449 });
1450 }).pipe(Effect.forkScoped);
1451 yield* Deferred.await(connected);
1452 expect(
1453 yield* broker.invoke<void>({ scope, operation: "waitFor", input: {} }).pipe(Effect.flip),
1454 ).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
1434 const received = yield* Deferred.make<void>();
1435 const requests = requestsFrom(yield* broker.connect(makeHost()));
1436 yield* Stream.runForEach(requests, (request) =>
1437 Effect.gen(function* () {
1438 if (request.operation === "waitFor") {
1439 yield* Deferred.succeed(received, undefined);
1440 // The browser starts its own timer after delivery, so its timeout lands late.
1441 yield* Effect.sleep(request.timeoutMs + 50);
1442 }
1443 yield* broker.respond({
1444 clientId: "client-1",
1445 connectionId: request.connectionId,
1446 requestId: request.requestId,
1447 ...(request.operation === "waitFor"
1448 ? {
1449 ok: false,
1450 error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" },
1451 }
1452 : { ok: true, result: "responsive" }),
1453 });
1454 }),
1455 ).pipe(Effect.forkScoped);
1456 const timedOut = yield* broker
1457 .invoke<void>({ scope, operation: "waitFor", input: {}, timeoutMs: 1_000 })
1458 .pipe(Effect.flip, Effect.forkScoped);
1459 yield* Deferred.await(received);
1460 yield* TestClock.adjust(1_050);
1461 expect(yield* Fiber.join(timedOut)).toMatchObject({
1462 _tag: "PreviewAutomationTimeoutError",
1463 remoteTag: "PreviewAutomationTimeoutError",
1464 });
14551465 expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive");
14561466 }),
14571467 ),
apps/server/src/mcp/PreviewAutomationBroker.ts+7-1
......@@ -41,6 +41,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
4141
4242import * as McpInvocationContext from "./McpInvocationContext.ts";
4343
44const HOST_RESPONSE_GRACE_MS = 1_000;
45
4446export interface PreviewAutomationInvokeInput {
4547 /** Preview tabs belong to a thread, so only thread callers reach the broker. */
4648 readonly scope: McpInvocationContext.McpThreadInvocationScope;
......@@ -651,7 +653,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
651653 }
652654 return yield* new PreviewAutomationRequestQueueClosedError(requestContext);
653655 }
654 const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs));
656 // The browser starts its operation timer after delivery. Allow its timeout
657 // response to arrive before treating the entire host as unresponsive.
658 const responseTimeoutMs =
659 input.updateCurrentTab === false ? timeoutMs : timeoutMs + HOST_RESPONSE_GRACE_MS;
660 const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(responseTimeoutMs));
655661 return yield* Option.match(result, {
656662 onNone: () =>
657663 Effect.gen(function* () {
packages/contracts/src/previewAutomation.ts+4-4
......@@ -925,7 +925,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr
925925 },
926926) {
927927 override get message(): string {
928 return `No preview automation host is available for ${this.operation} in environment ${this.environmentId}. Preview tools run in a T3 Code desktop app that is open and connected to this environment; a headless server has no browser of its own. Do not retry. To check a page, use a headless browser from the shell, such as Playwright, or curl, or ask the user to open this thread in the T3 Code desktop app.`;
928 return `No preview automation host is available for ${this.operation} in environment ${this.environmentId}. The server-owned browser may be starting or reconnecting. Retry preview_status, then call preview_open if no tab is available. If it remains unavailable, report the browser connection failure.`;
929929 }
930930}
931931
......@@ -1129,7 +1129,7 @@ export class PreviewAutomationRecordingTransferError extends Schema.TaggedError<
11291129 },
11301130) {
11311131 override get message(): string {
1132 return "Preview recording could not be saved to the agent environment. The saved copy remains on the desktop.";
1132 return "Preview recording could not be saved to the agent environment.";
11331133 }
11341134}
11351135
......@@ -1147,7 +1147,7 @@ export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedError<
11471147 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
11481148) {
11491149 override get message(): string {
1150 return "The recording exceeds 50 MiB. The saved copy remains on the desktop.";
1150 return "The recording exceeds 50 MiB. Make a shorter recording.";
11511151 }
11521152}
11531153
......@@ -1156,7 +1156,7 @@ export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.Tagge
11561156 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
11571157) {
11581158 override get message(): string {
1159 return "The recording transfer deadline expired. The saved copy remains on the desktop.";
1159 return "The recording transfer deadline expired.";
11601160 }
11611161}
11621162