diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index ddfe74160459a..6029d4305629e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -329,19 +329,29 @@ 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)); + // 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; + return record(target, null, { + 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, could not read it`, + summary: `#${target.link.number}: stopped watching, ${unusable === null ? "could not read it" : "host not set up on the server"}`, }, }).pipe( Effect.tap(() => woke(target)), Effect.catch(() => record(target, null)), Effect.tap(() => ended(target, "unreadable")), ); + }; const readRemarks = Effect.fn("PullRequestWatchReactor.readRemarks")( function* ( @@ -630,7 +640,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 678cb17f0266a..c24b65c08e6e1 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"; @@ -3948,6 +3948,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { { reason: "failed", activityFails: true }, { reason: "not-found", activityFails: true }, { reason: "forbidden", activityFails: true }, + { reason: "cli-unauthenticated", activityFails: false }, ] as const)( "handles repeated host read failures: $reason (activity fails: $activityFails)", ({ reason, activityFails }) => @@ -3992,17 +3993,24 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let passed = false; let genericFailure = false; 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-unauthenticated" + ? new PullRequestUnavailableError({ + reason: failure, + provider: SourceControlProviderKind.make("github"), + requirement: "Run `gh auth login`.", + 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( @@ -4068,7 +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, ["#8: stopped watching, could not read it"]); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + 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);