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([...@@ -234,14 +234,14 @@ it.effect.each([
234 ).pipe(Effect.provide(layerTest)),234 ).pipe(Effect.provide(layerTest)),
235);235);
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", () =>
238 Effect.gen(function* () {238 Effect.gen(function* () {
239 const snapshot = yield* callSnapshot({});239 const snapshot = yield* callSnapshot({});
240240
241 expect(snapshot.isError).toBe(true);241 expect(snapshot.isError).toBe(true);
242 const [text] = snapshot.content;242 const [text] = snapshot.content;
243 expect(text?.type === "text" ? text.text : "").toContain(243 expect(text?.type === "text" ? text.text : "").toContain(
244 "use a headless browser from the shell",244 "Retry preview_status, then call preview_open",
245 );245 );
246 expect(snapshot.structuredContent).toMatchObject({246 expect(snapshot.structuredContent).toMatchObject({
247 error: { _tag: "PreviewAutomationNoAvailableHostError" },247 error: { _tag: "PreviewAutomationNoAvailableHostError" },
apps/server/src/mcp/PreviewAutomationBroker.test.ts+34-24
...@@ -507,7 +507,6 @@ it.effect.each([...@@ -507,7 +507,6 @@ it.effect.each([
507 threadId: scope.thread.threadId,507 threadId: scope.thread.threadId,
508 });508 });
509 expect(error.cause).toBe(remoteError);509 expect(error.cause).toBe(remoteError);
510 expect(error.message).toContain("remains on the desktop");
511 expect(error.message).not.toContain("remote recording details");510 expect(error.message).not.toContain("remote recording details");
512 }),511 }),
513 ),512 ),
...@@ -1352,7 +1351,7 @@ it.effect("discards buffered actions before completing an evicted host stream",...@@ -1352,7 +1351,7 @@ it.effect("discards buffered actions before completing an evicted host stream",
1352 })1351 })
1353 .pipe(Effect.flip, Effect.forkScoped);1352 .pipe(Effect.flip, Effect.forkScoped);
1354 yield* Deferred.await(actionRouted);1353 yield* Deferred.await(actionRouted);
1355 yield* TestClock.adjust(1_000);1354 yield* TestClock.adjust(2_000);
1356 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });1355 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
1357 expect(yield* Fiber.join(buffered)).toMatchObject({1356 expect(yield* Fiber.join(buffered)).toMatchObject({
1358 _tag: "PreviewAutomationClientDisconnectedError",1357 _tag: "PreviewAutomationClientDisconnectedError",
...@@ -1414,7 +1413,7 @@ it.effect("rejects a routed action when its generation is evicted before deliver...@@ -1414,7 +1413,7 @@ it.effect("rejects a routed action when its generation is evicted before deliver
1414 Effect.forkScoped,1413 Effect.forkScoped,
1415 );1414 );
1416 yield* Deferred.await(actionRouted);1415 yield* Deferred.await(actionRouted);
1417 yield* TestClock.adjust(1_000);1416 yield* TestClock.adjust(2_000);
1418 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });1417 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
1419 expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true);1418 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...@@ -1428,30 +1427,41 @@ it.effect("rejects a routed action when its generation is evicted before deliver
1428 ),1427 ),
1429);1428);
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", () =>
1432 Effect.scoped(1431 Effect.scoped(
1433 Effect.gen(function* () {1432 Effect.gen(function* () {
1434 const broker = yield* makeBroker;1433 const broker = yield* makeBroker;
1435 const connected = yield* Deferred.make<void>();1434 const received = yield* Deferred.make<void>();
1436 const events = yield* broker.connect(makeHost());1435 const requests = requestsFrom(yield* broker.connect(makeHost()));
1437 yield* Stream.runForEach(events, (event) => {1436 yield* Stream.runForEach(requests, (request) =>
1438 if (event.type === "connected") return Deferred.succeed(connected, undefined);1437 Effect.gen(function* () {
1439 return broker.respond({1438 if (request.operation === "waitFor") {
1440 clientId: "client-1",1439 yield* Deferred.succeed(received, undefined);
1441 connectionId: event.connectionId,1440 // The browser starts its own timer after delivery, so its timeout lands late.
1442 requestId: event.request.requestId,1441 yield* Effect.sleep(request.timeoutMs + 50);
1443 ...(event.request.operation === "waitFor"1442 }
1444 ? {1443 yield* broker.respond({
1445 ok: false,1444 clientId: "client-1",
1446 error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" },1445 connectionId: request.connectionId,
1447 }1446 requestId: request.requestId,
1448 : { ok: true, result: "responsive" }),1447 ...(request.operation === "waitFor"
1449 });1448 ? {
1450 }).pipe(Effect.forkScoped);1449 ok: false,
1451 yield* Deferred.await(connected);1450 error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" },
1452 expect(1451 }
1453 yield* broker.invoke<void>({ scope, operation: "waitFor", input: {} }).pipe(Effect.flip),1452 : { ok: true, result: "responsive" }),
1454 ).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });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 });
1455 expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive");1465 expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive");
1456 }),1466 }),
1457 ),1467 ),
apps/server/src/mcp/PreviewAutomationBroker.ts+7-1
...@@ -41,6 +41,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef";...@@ -41,6 +41,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
4141
42import * as McpInvocationContext from "./McpInvocationContext.ts";42import * as McpInvocationContext from "./McpInvocationContext.ts";
4343
44const HOST_RESPONSE_GRACE_MS = 1_000;
45
44export interface PreviewAutomationInvokeInput {46export interface PreviewAutomationInvokeInput {
45 /** Preview tabs belong to a thread, so only thread callers reach the broker. */47 /** Preview tabs belong to a thread, so only thread callers reach the broker. */
46 readonly scope: McpInvocationContext.McpThreadInvocationScope;48 readonly scope: McpInvocationContext.McpThreadInvocationScope;
...@@ -651,7 +653,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -651,7 +653,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
651 }653 }
652 return yield* new PreviewAutomationRequestQueueClosedError(requestContext);654 return yield* new PreviewAutomationRequestQueueClosedError(requestContext);
653 }655 }
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));
655 return yield* Option.match(result, {661 return yield* Option.match(result, {
656 onNone: () =>662 onNone: () =>
657 Effect.gen(function* () {663 Effect.gen(function* () {
packages/contracts/src/previewAutomation.ts+4-4
...@@ -925,7 +925,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr...@@ -925,7 +925,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr
925 },925 },
926) {926) {
927 override get message(): string {927 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.`;
929 }929 }
930}930}
931931
...@@ -1129,7 +1129,7 @@ export class PreviewAutomationRecordingTransferError extends Schema.TaggedError<...@@ -1129,7 +1129,7 @@ export class PreviewAutomationRecordingTransferError extends Schema.TaggedError<
1129 },1129 },
1130) {1130) {
1131 override get message(): string {1131 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.";
1133 }1133 }
1134}1134}
11351135
...@@ -1147,7 +1147,7 @@ export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedError<...@@ -1147,7 +1147,7 @@ export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedError<
1147 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },1147 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
1148) {1148) {
1149 override get message(): string {1149 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.";
1151 }1151 }
1152}1152}
11531153
...@@ -1156,7 +1156,7 @@ export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.Tagge...@@ -1156,7 +1156,7 @@ export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.Tagge
1156 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },1156 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
1157) {1157) {
1158 override get message(): string {1158 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.";
1160 }1160 }
1161}1161}
11621162