Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ const FINGERPRINT_REREAD_MS = 30 * 60_000;

const isProviderError = Schema.is(PullRequestProviderError);

const isRateLimited = (cause: Cause.Cause<PullRequestService.PullRequestError>) =>
const providerFailureReason = (cause: Cause.Cause<PullRequestService.PullRequestError>) =>
Option.match(Cause.findErrorOption(cause), {
onNone: () => false,
onNone: () => null,
onSome: (error) =>
error._tag === "PullRequestOperationError" &&
isProviderError(error.cause) &&
error.cause.reason === "rate-limited",
error._tag === "PullRequestOperationError" && isProviderError(error.cause)
? error.cause.reason
: null,
});

const logFailure =
Expand Down Expand Up @@ -444,7 +444,7 @@ export const make = Effect.gen(function* () {
(cause): Effect.Effect<ProviderChangeRequestWatchFingerprint | null | "paused"> =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: isRateLimited(cause)
: providerFailureReason(cause) === "rate-limited"
? Effect.succeed("paused")
: Effect.logDebug("pull request watch fingerprint failed", {
pullRequest: group.key,
Expand Down Expand Up @@ -472,21 +472,33 @@ export const make = Effect.gen(function* () {
const reference = referenceOf(group);
// Invalidate cached reads before using the newer fingerprint; clients receive no notification.
if (fingerprint !== null) yield* pullRequests.invalidate({ reference });
const read = yield* Effect.exit(
Effect.all(
[
pullRequests.detail({ ...reference, allowStale: false }),
const [detailRead, activityRead] = yield* Effect.all(
[
Effect.exit(pullRequests.detail({ ...reference, allowStale: false })),
Effect.exit(
plan.activity
? pullRequests.activity(reference).pipe(Effect.asSome)
: Effect.succeed(Option.none<PullRequestActivity>()),
],
{ concurrency: 2 },
),
),
],
{ concurrency: 2 },
);
// Activity failures must not cancel or hide a permanent detail failure.
const read = Exit.isFailure(detailRead)
? Exit.failCause(detailRead.cause)
: Exit.isFailure(activityRead)
? Exit.failCause(activityRead.cause)
: Exit.succeed([detailRead.value, activityRead.value] as const);
if (Exit.isFailure(read)) {
if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause);
lastReads.delete(group.key);
if (isRateLimited(read.cause)) return;
const reason = providerFailureReason(read.cause);
if (reason === "rate-limited") return;
// Transport and host failures can outlast the failure limit; keep the watch for recovery.
if (reason === "failed") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
readFailures.delete(group.key);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return yield* Effect.failCause(read.cause);
}
const failures = (readFailures.get(group.key) ?? 0) + 1;
readFailures.set(group.key, failures);
if (failures >= READ_FAILURE_LIMIT && (yield* eachTarget(group, giveUp))) {
Expand Down
201 changes: 147 additions & 54 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider";
import { SourceControlProviderKind } from "@t3tools/contracts";
import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
Expand Down Expand Up @@ -3172,63 +3173,155 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
}),
);

it.effect("ends a watch it cannot read, and tells the agent", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const threadId = ThreadId.make("runtime-pull-request-watch-unreadable");
const projectId = ProjectId.make("pr-watch-unreadable-project");
yield* seedProject({
projectId,
title: "Watch unreadable",
workspaceRoot: "/workspace/watch-unreadable",
defaultModelSelection: null,
createdAt: "2026-10-01T00:00:00.000Z",
});
yield* orchestrator.dispatch({
type: "thread.create",
createdBy: "user",
creationSource: "web",
commandId: CommandId.make("pr-watch-unreadable-create"),
threadId,
projectId,
title: "Watch unreadable",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
});
yield* orchestrator.dispatch({
type: "thread.pull-request.watch",
commandId: CommandId.make("pr-watch-unreadable-start"),
threadId,
host: "github.com",
repository: "pingdotgg/t3code",
number: 8,
watching: true,
link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" },
});
const reactor = yield* PullRequestWatchReactor.make.pipe(
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.mock(PullRequestService.PullRequestService)({
detail: () => Effect.die("host unreachable"),
activity: () => Effect.die("host unreachable"),
it.effect.each([
{ reason: "failed", activityFails: false },
{ reason: "not-found", activityFails: false },
{ reason: "failed", activityFails: true },
{ reason: "not-found", activityFails: true },
{ reason: "forbidden", activityFails: true },
] as const)(
"handles repeated host read failures: $reason (activity fails: $activityFails)",
({ reason, activityFails }) =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const threadId = ThreadId.make(
`runtime-pull-request-watch-unreadable-${reason}-${activityFails}`,
);
const projectId = ProjectId.make(`pr-watch-unreadable-project-${reason}-${activityFails}`);
yield* seedProject({
projectId,
title: "Watch unreadable",
workspaceRoot: "/workspace/watch-unreadable",
defaultModelSelection: null,
createdAt: "2026-10-01T00:00:00.000Z",
});
yield* orchestrator.dispatch({
type: "thread.create",
createdBy: "user",
creationSource: "web",
commandId: CommandId.make(`pr-watch-unreadable-create-${reason}-${activityFails}`),
threadId,
projectId,
title: "Watch unreadable",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
});
yield* orchestrator.dispatch({
type: "thread.pull-request.watch",
commandId: CommandId.make(`pr-watch-unreadable-start-${reason}-${activityFails}`),
threadId,
host: "github.com",
repository: "pingdotgg/t3code",
number: 8,
watching: true,
link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" },
});
let disconnected = false;
let passed = false;
let genericFailure = false;
const activityFailures = yield* Queue.unbounded<void>();
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 reactor = yield* PullRequestWatchReactor.make.pipe(
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.mock(PullRequestService.PullRequestService)({
watchFingerprint: () => Effect.succeed(null),
detail: () =>
genericFailure
? Effect.die("host read failed")
: disconnected
? (activityFails ? Queue.take(activityFailures) : Effect.void).pipe(
Effect.andThen(Effect.fail(readFailure("detail", reason))),
)
: Effect.succeed({
...watchedPullRequestDetail({
projectId,
number: 8,
at: "2026-10-02T12:00:00.000Z",
}),
headSha: "abc1234def",
checks: [
{
name: "test",
status: passed ? ("success" as const) : ("pending" as const),
description: null,
url: null,
required: true,
},
],
}),
activity: () =>
disconnected && activityFails
? Queue.offer(activityFailures, undefined).pipe(
Effect.andThen(Effect.fail(readFailure("activity", "failed"))),
)
: Effect.succeed({
comments: [],
commentCount: 0,
commentsTruncated: false,
reviewThreads: [],
commits: [],
}),
}),
),
),
),
);
for (let pass = 0; pass < 15; pass += 1) yield* reactor.sweep;
);
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain()));
const summaries = Effect.map(
orchestrator.getThreadRecords(threadId, ["messages"]),
({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []),
);
yield* sweep;
assert.deepEqual(yield* summaries, []);
disconnected = true;
for (let pass = 0; pass < 15; pass += 1) {
genericFailure = reason === "failed" && (pass === 0 || (pass >= 2 && pass <= 8));
yield* TestClock.adjust("2 minutes");
yield* sweep;
}

const thread = yield* orchestrator.getThreadShell(threadId);
assert.isUndefined(thread?.pullRequests?.[0]?.watch);
const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]);
assert.deepEqual(
messages.flatMap((message) => message.notification?.summary ?? []),
["#8: stopped watching, could not read 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"]);
return;
}
assert.isDefined(thread?.pullRequests?.[0]?.watch);
assert.deepEqual(yield* summaries, []);
disconnected = false;
genericFailure = false;
passed = true;
yield* sweep;
assert.deepEqual(yield* summaries, ["#8: checks passed"]);
yield* TestClock.adjust("10 minutes");
yield* sweep;
assert.deepEqual(yield* summaries, ["#8: checks passed"]);
yield* orchestrator.dispatch({
type: "thread.pull-request.watch",
commandId: CommandId.make(`pr-watch-unreadable-stop-${reason}-${activityFails}`),
threadId,
host: "github.com",
repository: "pingdotgg/t3code",
number: 8,
watching: false,
});
yield* worker.drain();
}),
);

it.effect("wakes once for a state it reported, across a restart and a new watch", () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class PullRequestProviderError extends Schema.TaggedError<PullRequestProv
reason: Schema.Literals([
"missing-tool",
"unauthenticated",
"forbidden",
"rate-limited",
"not-found",
"failed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,44 @@ it.effect("maps credential verification failures without relabeling operation fa
}),
);

it.effect.each([
{ status: 403, githubErrorTypes: [], reason: "forbidden" },
{ status: 503, githubErrorTypes: [], reason: "failed" },
{ status: 200, githubErrorTypes: ["FORBIDDEN"], reason: "forbidden" },
{ status: 200, githubErrorTypes: ["INTERNAL"], reason: "failed" },
{ status: 200, githubErrorTypes: ["FORBIDDEN", "NOT_FOUND"], reason: "forbidden" },
{ status: 200, githubErrorTypes: ["FORBIDDEN", "INTERNAL"], reason: "failed" },
{ status: 200, githubErrorTypes: ["FORBIDDEN", null], reason: "failed" },
])("classifies GitHub response $status as $reason", ({ status, githubErrorTypes, reason }) =>
Effect.gen(function* () {
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
revalidateChecks: (_input, read) => read,
getPullRequestDetail: () =>
Effect.fail(
new GitHubRequestPolicy.GitHubApiResponseError({
host: "github.com",
operation: "detail",
status,
githubErrorTypes,
}),
),
}),
),
);
const error = yield* provider
.getChangeRequest({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
})
.pipe(Effect.flip);
expect(error.reason).toBe(reason);
}),
);

it.effect("advertises standalone branch deletion for source repository writers", () =>
Effect.gen(function* () {
let action: string | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ function gitHubProviderFailure(
};
case "SourceControlRateLimitPausedError":
return { reason: "rate-limited", retryAt: error.retryAt };
case "GitHubApiResponseError":
return {
reason:
error.status === 403 ||
(error.githubErrorTypes?.includes("FORBIDDEN") &&
error.githubErrorTypes.every((type) => type === "FORBIDDEN" || type === "NOT_FOUND"))
? "forbidden"
: "failed",
};
case "GitHubApiNotFoundError":
return error.credential === undefined || error.credential === "app"
? { reason: "not-found" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export class GitHubApiResponseError extends Schema.TaggedError<GitHubApiResponse
operation: Schema.String,
status: Schema.Int,
githubErrors: Schema.optionalKey(Schema.Array(Schema.String)),
githubErrorTypes: Schema.optionalKey(Schema.Array(Schema.NullOr(Schema.String))),
},
) {
override get message(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ describe("call", () => {
errors: [
{ type: "NOT_FOUND", message: "Could not resolve" },
{ type: "FORBIDDEN", message: "Resource not accessible" },
{ message: "Unknown error" },
],
}),
);
Expand All @@ -310,7 +311,8 @@ describe("call", () => {
expect(error).toMatchObject({
_tag: "GitHubApiResponseError",
status: 200,
githubErrors: ["Could not resolve", "Resource not accessible"],
githubErrors: ["Could not resolve", "Resource not accessible", "Unknown error"],
githubErrorTypes: ["NOT_FOUND", "FORBIDDEN", null],
});
}).pipe(Effect.provide(layer));
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ const fromClientError = (
...context,
status: reason.status ?? 200,
githubErrors: reason.errors.map((graphQlError) => graphQlError.message),
githubErrorTypes: reason.errors.map((graphQlError) =>
typeof graphQlError["type"] === "string" ? graphQlError["type"] : null,
),
});
};

Expand Down
Loading