From 0286cbfb5e2e2c41484af3307d4b231b4f17f995 Mon Sep 17 00:00:00 2001 From: Arash Molavi Date: Thu, 12 Nov 2026 11:35:23 +0100 Subject: [PATCH 1/2] fix(server): explain host CLI failures when PR watches stop --- .../PullRequestWatchReactor.ts | 32 +++++++++-- .../src/orchestration-v2/runtimeLayer.test.ts | 54 +++++++++++++------ 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 6fdea57bf9e1e..5087118b29c07 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -306,19 +306,38 @@ export const make = Effect.gen(function* () { }); }); - const giveUp = (target: WatchTarget) => - record(target, null, { - text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request only if it is still open.`, + const giveUp = (target: WatchTarget, cause: Cause.Cause) => { + const error = Option.getOrUndefined(Cause.findErrorOption(cause)); + const cliReason = + error?._tag === "PullRequestUnavailableError" && + (error.reason === "cli-missing" || error.reason === "cli-unauthenticated") + ? error.reason + : null; + const problem = + cliReason === "cli-missing" + ? "server host CLI missing" + : cliReason === "cli-unauthenticated" + ? "server host CLI unauthenticated" + : "could not read it"; + const recovery = + cliReason === "cli-missing" + ? "The host CLI is missing in the T3 Code server environment. Install it on that server, then call watch_pull_request only if the pull request is still open." + : cliReason === "cli-unauthenticated" + ? "The host CLI has no working credentials in the T3 Code server environment. Authenticate it on that server, then call watch_pull_request only if the pull request is still open." + : "Check it yourself, and call watch_pull_request only if it is still open."; + return record(target, null, { + text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. ${recovery}`, notification: { source: { kind: "monitor" }, outcome: "failed", - summary: `#${target.link.number}: stopped watching, could not read it`, + summary: `#${target.link.number}: stopped watching, ${problem}`, }, }).pipe( Effect.tap(() => woke(target)), Effect.catch(() => record(target, null)), Effect.tap(() => ended(target, "unreadable")), ); + }; const readRemarks = Effect.fn("PullRequestWatchReactor.readRemarks")( function* ( @@ -521,7 +540,10 @@ export const make = Effect.gen(function* () { } const failures = (readFailures.get(group.key) ?? 0) + 1; readFailures.set(group.key, failures); - if (failures >= READ_FAILURE_LIMIT && (yield* eachTarget(group, giveUp))) { + if ( + failures >= READ_FAILURE_LIMIT && + (yield* eachTarget(group, (target) => giveUp(target, read.cause))) + ) { readFailures.delete(group.key); } return yield* Effect.failCause(read.cause); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 954f72e2b5e95..37e82b3473e5c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,5 +1,5 @@ import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { PullRequestUnavailableError, SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import { createOrchestrationV2TurnItemVisibility } from "@t3tools/shared/orchestrationV2Timeline"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -3781,6 +3781,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { { reason: "failed", activityFails: true }, { reason: "not-found", activityFails: true }, { reason: "forbidden", activityFails: true }, + { reason: "cli-missing", activityFails: false }, + { reason: "cli-missing", activityFails: true }, + { reason: "cli-unauthenticated", activityFails: false }, + { reason: "cli-unauthenticated", activityFails: true }, ] as const)( "handles repeated host read failures: $reason (activity fails: $activityFails)", ({ reason, activityFails }) => @@ -3824,18 +3828,26 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let disconnected = false; let passed = false; let genericFailure = false; + const activityOnly = + activityFails && (reason === "cli-missing" || reason === "cli-unauthenticated"); const activityFailures = yield* Queue.unbounded(); - const readFailure = (operation: string, failure: "failed" | "not-found" | "forbidden") => - new PullRequestOperationError({ - operation, - detail: "host read failed", - cause: new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation, - reason: failure, - detail: "host read failed", - }), - }); + const readFailure = (operation: string, failure: typeof reason) => + failure === "cli-missing" || failure === "cli-unauthenticated" + ? new PullRequestUnavailableError({ + reason: failure, + provider: SourceControlProviderKind.make("forgejo"), + cause: new Error("private subprocess output"), + }) + : new PullRequestOperationError({ + operation, + detail: "host read failed", + cause: new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation, + reason: failure, + detail: "host read failed", + }), + }); const reactor = yield* PullRequestWatchReactor.make.pipe( Effect.provide( Layer.mergeAll( @@ -3846,7 +3858,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { detail: () => genericFailure ? Effect.die("host read failed") - : disconnected + : disconnected && !activityOnly ? (activityFails ? Queue.take(activityFailures) : Effect.void).pipe( Effect.andThen(Effect.fail(readFailure("detail", reason))), ) @@ -3870,7 +3882,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { activity: () => disconnected && activityFails ? Queue.offer(activityFailures, undefined).pipe( - Effect.andThen(Effect.fail(readFailure("activity", "failed"))), + Effect.andThen( + Effect.fail(readFailure("activity", activityOnly ? reason : "failed")), + ), ) : Effect.succeed({ comments: [], @@ -3901,7 +3915,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const thread = yield* orchestrator.getThreadShell(threadId); if (reason !== "failed") { assert.isUndefined(thread?.pullRequests?.[0]?.watch); - assert.deepEqual(yield* summaries, ["#8: stopped watching, could not read it"]); + assert.deepEqual(yield* summaries, [ + reason === "cli-missing" + ? "#8: stopped watching, server host CLI missing" + : reason === "cli-unauthenticated" + ? "#8: stopped watching, server host CLI unauthenticated" + : "#8: stopped watching, could not read it", + ]); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.isFalse( + messages.some((message) => message.text.includes("private subprocess output")), + ); return; } assert.isDefined(thread?.pullRequests?.[0]?.watch); From caf2c9d3616a030fdf6c7a7bedb277ad1f26f387 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 17:57:34 +0000 Subject: [PATCH 2/2] fix(server): show the host's own setup instruction when a PR watch stops Use the PullRequestUnavailableError message, which carries each host's requirement text, instead of fixed "host CLI" wording that is wrong for token and API-credential hosts. Trim the test to one case. Co-Authored-By: Claude Opus 5.5 --- .../PullRequestWatchReactor.ts | 27 +++++--------- .../src/orchestration-v2/runtimeLayer.test.ts | 37 +++++++++---------- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 1c38370dbcbee..6029d4305629e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -331,29 +331,20 @@ export const make = Effect.gen(function* () { const giveUp = (target: WatchTarget, cause: Cause.Cause) => { const error = Option.getOrUndefined(Cause.findErrorOption(cause)); - const cliReason = - error?._tag === "PullRequestUnavailableError" && - (error.reason === "cli-missing" || error.reason === "cli-unauthenticated") - ? error.reason + // The message is the host's own setup instruction, never the subprocess output in `cause`. + const unusable = + error?._tag === "PullRequestUnavailableError" && error.reason !== "provider-unsupported" + ? error.message : null; - const problem = - cliReason === "cli-missing" - ? "server host CLI missing" - : cliReason === "cli-unauthenticated" - ? "server host CLI unauthenticated" - : "could not read it"; - const recovery = - cliReason === "cli-missing" - ? "The host CLI is missing in the T3 Code server environment. Install it on that server, then call watch_pull_request only if the pull request is still open." - : cliReason === "cli-unauthenticated" - ? "The host CLI has no working credentials in the T3 Code server environment. Authenticate it on that server, then call watch_pull_request only if the pull request is still open." - : "Check it yourself, and call watch_pull_request only if it is still open."; return record(target, null, { - text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. ${recovery}`, + text: + unusable === null + ? `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request only if it is still open.` + : `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because the T3 Code server cannot use the host: ${unusable} Fix that on the server, then call watch_pull_request only if it is still open.`, notification: { source: { kind: "monitor" }, outcome: "failed", - summary: `#${target.link.number}: stopped watching, ${problem}`, + summary: `#${target.link.number}: stopped watching, ${unusable === null ? "could not read it" : "host not set up on the server"}`, }, }).pipe( Effect.tap(() => woke(target)), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7e044281076a4..c24b65c08e6e1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3948,10 +3948,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { { reason: "failed", activityFails: true }, { reason: "not-found", activityFails: true }, { reason: "forbidden", activityFails: true }, - { reason: "cli-missing", activityFails: false }, - { reason: "cli-missing", activityFails: true }, { reason: "cli-unauthenticated", activityFails: false }, - { reason: "cli-unauthenticated", activityFails: true }, ] as const)( "handles repeated host read failures: $reason (activity fails: $activityFails)", ({ reason, activityFails }) => @@ -3995,14 +3992,13 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let disconnected = false; let passed = false; let genericFailure = false; - const activityOnly = - activityFails && (reason === "cli-missing" || reason === "cli-unauthenticated"); const activityFailures = yield* Queue.unbounded(); const readFailure = (operation: string, failure: typeof reason) => - failure === "cli-missing" || failure === "cli-unauthenticated" + failure === "cli-unauthenticated" ? new PullRequestUnavailableError({ reason: failure, - provider: SourceControlProviderKind.make("forgejo"), + provider: SourceControlProviderKind.make("github"), + requirement: "Run `gh auth login`.", cause: new Error("private subprocess output"), }) : new PullRequestOperationError({ @@ -4025,7 +4021,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { detail: () => genericFailure ? Effect.die("host read failed") - : disconnected && !activityOnly + : disconnected ? (activityFails ? Queue.take(activityFailures) : Effect.void).pipe( Effect.andThen(Effect.fail(readFailure("detail", reason))), ) @@ -4049,9 +4045,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { activity: () => disconnected && activityFails ? Queue.offer(activityFailures, undefined).pipe( - Effect.andThen( - Effect.fail(readFailure("activity", activityOnly ? reason : "failed")), - ), + Effect.andThen(Effect.fail(readFailure("activity", "failed"))), ) : Effect.succeed({ comments: [], @@ -4082,17 +4076,20 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const thread = yield* orchestrator.getThreadShell(threadId); if (reason !== "failed") { assert.isUndefined(thread?.pullRequests?.[0]?.watch); - assert.deepEqual(yield* summaries, [ - reason === "cli-missing" - ? "#8: stopped watching, server host CLI missing" - : reason === "cli-unauthenticated" - ? "#8: stopped watching, server host CLI unauthenticated" - : "#8: stopped watching, could not read it", - ]); const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); - assert.isFalse( - messages.some((message) => message.text.includes("private subprocess output")), + const stopped = messages.filter((message) => message.notification !== undefined); + assert.deepEqual( + stopped.map((message) => message.notification?.summary), + [ + reason === "cli-unauthenticated" + ? "#8: stopped watching, host not set up on the server" + : "#8: stopped watching, could not read it", + ], ); + if (reason === "cli-unauthenticated") { + assert.include(stopped[0]?.text, "Run `gh auth login`."); + assert.notInclude(stopped[0]?.text, "private subprocess output"); + } return; } assert.isDefined(thread?.pullRequests?.[0]?.watch);