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
7 changes: 6 additions & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
import {
GitActionProgressEvent,
Expand Down Expand Up @@ -189,6 +190,9 @@ const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
const PR_LOOKUP_CACHE_CAPACITY = 2_048;
const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5);
const SETTLED_PR_LOOKUP_RECHECK_INTERVAL = Duration.minutes(15);
// Shared by every background caller (backfill, settlement, cleanup), so startup discovery
// cannot fill the git and forge process slots that foreground work also needs.
const BRANCH_PR_LOOKUP_CONCURRENCY = 4;
const PR_THREAD_CONTEXT_BYTES = 16_000;
const PR_THREAD_COMMAND_OUTPUT_CHARS = 2_000;
const isSourceControlProviderError = Schema.is(SourceControlProviderError);
Expand Down Expand Up @@ -2579,6 +2583,7 @@ export const make = Effect.gen(function* () {
prLookupGitStateByKey.set(entryKey, { readAtMs, value });
return value;
});
const branchPrLookups = yield* Semaphore.make(BRANCH_PR_LOOKUP_CONCURRENCY);
const settledPrLookupByBranchKey = new Map<
string,
{ readonly url: string; readonly fingerprint: string; readonly confirmedAtMs: number }
Expand Down Expand Up @@ -2879,7 +2884,7 @@ export const make = Effect.gen(function* () {
});
}
return confirmed;
});
}, branchPrLookups.withPermits(1));

const checkedOutPullRequest: GitManager["Service"]["checkedOutPullRequest"] = Effect.fn(
"checkedOutPullRequest",
Expand Down
42 changes: 41 additions & 1 deletion apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ProjectId,
ProviderInstanceId,
PullRequestOperationError,
PullRequestUnavailableError,
RunId,
ThreadId,
TurnItemId,
Expand Down Expand Up @@ -180,7 +181,7 @@ interface HarnessOptions {
readonly snapshot: TestShellSnapshot;
readonly summary?: (
input: PullRequestRef,
) => Effect.Effect<PullRequestSummary, PullRequestOperationError>;
) => Effect.Effect<PullRequestSummary, PullRequestService.PullRequestError>;
readonly stack?: (
input: PullRequestRef,
) => Effect.Effect<PullRequestStack | null, PullRequestOperationError>;
Expand Down Expand Up @@ -1674,6 +1675,45 @@ describe("PullRequestSyncReactor", () => {
}),
);

it.effect("stops reading a pull request on a host it cannot browse each sweep", () =>
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const fixture = yield* makeHarness({
snapshot: makeSnapshot([
makeThread("unsupported", { pullRequests: [makeLink(9, { state: "open" })] }),
makeThread("fine", { pullRequests: [makeLink(8, { state: "open" })] }),
]),
summary: (input) =>
input.number === 9
? Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }))
: Effect.succeed(makeSummary(input)),
});
const unsupportedReads = Ref.get(fixture.summaryCalls).pipe(
Effect.map((calls) => calls.filter((call) => call.number === 9).length),
);
yield* Effect.gen(function* () {
const reactor = yield* startAndSweep(fixture);
for (let index = 0; index < 5; index += 1) yield* sweepAgain(fixture, reactor);
const afterSweeps = yield* unsupportedReads;

// A refresh another link asks for must not re-read the unsupported one as well.
yield* reactor.requestSync(makeLink(9));
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;
const afterItsRefresh = yield* unsupportedReads;
for (let index = 0; index < 3; index += 1) {
yield* reactor.requestSync(makeLink(8));
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;
}
assert.deepStrictEqual(
{ afterSweeps, afterItsRefresh, afterOtherRefreshes: yield* unsupportedReads },
{ afterSweeps: 1, afterItsRefresh: 2, afterOtherRefreshes: 2 },
);
}).pipe(Effect.provide(fixture.layer));
}),
);

it.effect(
"holds missing links through sibling and link updates but honors a reader refresh",
() =>
Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/orchestration-v2/PullRequestSyncReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,8 @@ export const make = Effect.gen(function* () {
// linking dozens of pull requests) is read together and shares the summary batches.
let requestedSweepQueued = false;
const retryStacks = new Set<string>();
// Group keys the host answered not-found for; they are read on the slow cadence until found.
// Group keys the host answered not-found for, or this server cannot read (no CLI, signed out,
// unsupported host); they are read on the slow cadence until a read succeeds.
const missing = new Set<string>();
// Pause per project and host because credentials are project-specific; leave paused PRs due until reset.
const pausedUntil = new Map<string, number>();
Expand Down Expand Up @@ -510,9 +511,10 @@ export const make = Effect.gen(function* () {
if (invalidated) yield* pullRequests.invalidate({ reference: ref });
const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }).pipe(
Effect.tapError((error) =>
error.reason === "not-found"
error.reason === "not-found" || error._tag === "PullRequestUnavailableError"
? Effect.sync(() => {
// Not-found is a host answer: it consumes the refresh and starts the backoff.
// Not-found, or a host this server cannot read, won't change by asking again
// next sweep: it consumes the refresh and starts the backoff.
missing.add(key);
lastSyncedAt.set(key, nowMs);
requested.delete(key);
Expand Down
Loading