From 069cde3ff642d4e81b5745e158dd82f2b8759b85 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:24:11 +1100 Subject: [PATCH 1/2] fix(server): a peer refusing one call no longer marks its link as failing Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerMcpClient.test.ts | 47 ++++++++++++++++++++++ apps/server/src/peer/PeerMcpClient.ts | 31 +++++++++----- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/server/src/peer/PeerMcpClient.test.ts b/apps/server/src/peer/PeerMcpClient.test.ts index 7d19f4e856bf7..5207e5b929525 100644 --- a/apps/server/src/peer/PeerMcpClient.test.ts +++ b/apps/server/src/peer/PeerMcpClient.test.ts @@ -1,5 +1,6 @@ import { NodeHttpServer } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; +import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -30,8 +31,19 @@ const toolkit = Toolkit.make( success: Result, dependencies: [McpInvocationContext.McpInvocationContext], }), + Tool.make("refuse", { + description: "Refuses the call.", + success: Result, + failure: OrchestratorMcpFailure, + failureMode: "return", + }), ); +const refusal = new OrchestratorMcpFailure({ + code: "thread_not_found", + message: "Thread thread-gone was not found.", +}); + it.effect.each([false, true])( "reuses an initialized session before its first call finishes (cancelled: %s)", (cancelFirst) => @@ -47,6 +59,7 @@ it.effect.each([false, true])( ); const handlers = McpToolAccess.toLayer(toolkit, { probe: McpToolAccess.reads(() => modes), + refuse: McpToolAccess.reads(() => Effect.fail(refusal)), hold: McpToolAccess.reads(() => Deferred.succeed(entered, undefined).pipe( Effect.andThen(Deferred.await(release)), @@ -91,6 +104,40 @@ it.effect.each([false, true])( ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("keeps a link healthy when the peer refuses a call", () => + Effect.scoped( + Effect.gen(function* () { + const handlers = McpToolAccess.toLayer(toolkit, { + probe: McpToolAccess.reads(() => Effect.succeed({ modes: "" })), + hold: McpToolAccess.reads(() => Effect.succeed({ modes: "" })), + refuse: McpToolAccess.reads(() => Effect.fail(refusal)), + }); + const box = descriptorOf("environment-box", "Box"); + const b = yield* servePeer(box, McpHttpServer.toolkitRegistration(toolkit, handlers)); + const context = yield* Layer.build( + layerLinkingEnvironment(descriptorOf("environment-a", "A")), + ); + const links = Context.get(context, PeerLinks.PeerLinks); + const client = Context.get(context, PeerMcpClient.PeerMcpClient); + yield* linkTo(links, b); + yield* links.recordOutcome(box.environmentId, { error: "earlier outage" }); + const refused = yield* client + .call({ + environmentId: box.environmentId, + tool: "refuse", + arguments: {}, + limits: { runtimeMode: "full-access", interactionMode: "default" }, + success: Result, + }) + .pipe(Effect.flip); + // The agent hears the peer's own refusal; the link answered, so it is not failing. + expect(refused).toMatchObject({ code: refusal.code, message: refusal.message }); + const link = yield* links.get(box.environmentId); + expect(link._tag === "Some" ? link.value.lastError : "missing").toBeNull(); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("renews a missing peer session once and reuses its replacement", () => Effect.gen(function* () { const sessionHeaders: Array = []; diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 27397d54ba7de..2be5aa27739ec 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -9,6 +9,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; @@ -211,24 +212,32 @@ const make = Effect.gen(function* () { ), ); if (body.error !== undefined || body.result === undefined) { - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: body.error?.message ?? "The linked environment returned no result.", - }); + return Result.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: body.error?.message ?? "The linked environment returned no result.", + }), + ); } const text = body.result.content.map((part) => part.text ?? "").join(""); if (body.result.isError === true) { // A refusal keeps the peer's own code, so the agent sees why it was refused. const refused = decodeFailure(text); - return yield* refused._tag === "Some" - ? new OrchestratorMcpFailure({ code: refused.value.code, message: refused.value.message }) - : new OrchestratorMcpFailure({ - code: "orchestration_error", - message: text || "The linked environment refused the call.", - }); + return Result.fail( + refused._tag === "Some" + ? new OrchestratorMcpFailure({ + code: refused.value.code, + message: refused.value.message, + }) + : new OrchestratorMcpFailure({ + code: "orchestration_error", + message: text || "The linked environment refused the call.", + }), + ); } const success = Schema.fromJsonString(input.success); return yield* Schema.decodeEffect(success)(text).pipe( + Effect.map(Result.succeed), Effect.mapError( () => new OrchestratorMcpFailure({ @@ -244,6 +253,7 @@ const make = Effect.gen(function* () { Effect.tapError((error) => links.recordOutcome(input.environmentId, { error: error.message }), ), + // A refusal is still an answer: the link works, so only this call fails. Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })), // Recorded above with the transport's own message; the agent hears it plainly. Effect.catchTags({ @@ -255,6 +265,7 @@ const make = Effect.gen(function* () { }), ), }), + Effect.flatMap(Effect.fromResult), ), ); From 35dd22888802996cc35de5b4fe821a29c8cebeec Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:28:31 +1100 Subject: [PATCH 2/2] fix(server): an empty peer answer still marks the link as failing Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerMcpClient.test.ts | 33 ++++++++++++++++------ apps/server/src/peer/PeerMcpClient.ts | 13 +++++---- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/apps/server/src/peer/PeerMcpClient.test.ts b/apps/server/src/peer/PeerMcpClient.test.ts index 5207e5b929525..cd2b0a9e52b16 100644 --- a/apps/server/src/peer/PeerMcpClient.test.ts +++ b/apps/server/src/peer/PeerMcpClient.test.ts @@ -141,7 +141,8 @@ it.effect("keeps a link healthy when the peer refuses a call", () => it.effect("renews a missing peer session once and reuses its replacement", () => Effect.gen(function* () { const sessionHeaders: Array = []; - const statuses = [200, 202, 404, 200, 202, 200, 200]; + const statuses = [200, 202, 404, 200, 202, 200, 200, 200]; + const outcomes: Array = []; const http = HttpClient.make((request) => Effect.sync(() => { const index = sessionHeaders.length; @@ -151,13 +152,15 @@ it.effect("renews a missing peer session once and reuses its replacement", () => return HttpClientResponse.fromWeb( request, new Response( - index >= 5 - ? JSON.stringify({ - result: { - content: [{ type: "text", text: '{"modes":"approval-required/plan"}' }], - }, - }) - : "", + index === 7 + ? "{}" + : index >= 5 + ? JSON.stringify({ + result: { + content: [{ type: "text", text: '{"modes":"approval-required/plan"}' }], + }, + }) + : "", { status, headers: @@ -193,7 +196,7 @@ it.effect("renews a missing peer session once and reuses its replacement", () => url: "https://peer.example", token: "fixture", }), - recordOutcome: () => Effect.void, + recordOutcome: (_, outcome) => Effect.sync(() => outcomes.push(outcome.error)), }), ), ), @@ -211,6 +214,17 @@ it.effect("renews a missing peer session once and reuses its replacement", () => }), ).toEqual({ modes: "approval-required/plan" }); } + // An answer with neither a result nor an error is the link's problem, not the call's. + const empty = yield* client + .call({ + environmentId: box.environmentId, + tool: "probe", + arguments: {}, + limits: { runtimeMode: "approval-required", interactionMode: "plan" }, + success: Result, + }) + .pipe(Effect.flip); + expect(outcomes).toEqual([null, null, empty.message]); expect(sessionHeaders).toEqual([ undefined, "old", @@ -219,6 +233,7 @@ it.effect("renews a missing peer session once and reuses its replacement", () => "renewed", "renewed", "renewed", + "renewed", ]); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 2be5aa27739ec..885c91dce8665 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -211,14 +211,17 @@ const make = Effect.gen(function* () { }), ), ); - if (body.error !== undefined || body.result === undefined) { + if (body.error !== undefined) { return Result.fail( - new OrchestratorMcpFailure({ - code: "invalid_request", - message: body.error?.message ?? "The linked environment returned no result.", - }), + new OrchestratorMcpFailure({ code: "invalid_request", message: body.error.message }), ); } + if (body.result === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "The linked environment returned no result.", + }); + } const text = body.result.content.map((part) => part.text ?? "").join(""); if (body.result.isError === true) { // A refusal keeps the peer's own code, so the agent sees why it was refused.