| ... | @@ -16,16 +16,19 @@ import { | ... | @@ -16,16 +16,19 @@ import { |
| 16 | visibleThreadPullRequests, | 16 | visibleThreadPullRequests, |
| 17 | } from "@t3tools/shared/threadPullRequests"; | 17 | } from "@t3tools/shared/threadPullRequests"; |
| 18 | import * as Cause from "effect/Cause"; | 18 | import * as Cause from "effect/Cause"; |
| | 19 | import * as Clock from "effect/Clock"; |
| 19 | import * as Context from "effect/Context"; | 20 | import * as Context from "effect/Context"; |
| 20 | import * as Crypto from "effect/Crypto"; | 21 | import * as Crypto from "effect/Crypto"; |
| 21 | import * as DateTime from "effect/DateTime"; | 22 | import * as DateTime from "effect/DateTime"; |
| 22 | import * as Effect from "effect/Effect"; | 23 | import * as Effect from "effect/Effect"; |
| 23 | import * as Layer from "effect/Layer"; | 24 | import * as Layer from "effect/Layer"; |
| | 25 | import * as Schema from "effect/Schema"; |
| 24 | import * as Schedule from "effect/Schedule"; | 26 | import * as Schedule from "effect/Schedule"; |
| 25 | import type * as Scope from "effect/Scope"; | 27 | import type * as Scope from "effect/Scope"; |
| 26 | import * as Semaphore from "effect/Semaphore"; | 28 | import * as Semaphore from "effect/Semaphore"; |
| 27 | import * as Stream from "effect/Stream"; | 29 | import * as Stream from "effect/Stream"; |
| 28 | | 30 | |
| | 31 | import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; |
| 29 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | 32 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 30 | import { forkParked } from "../serverActivation.ts"; | 33 | import { forkParked } from "../serverActivation.ts"; |
| 31 | import * as Orchestrator from "./Orchestrator.ts"; | 34 | import * as Orchestrator from "./Orchestrator.ts"; |
| ... | @@ -36,6 +39,8 @@ const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; | ... | @@ -36,6 +39,8 @@ const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; |
| 36 | /** Shell commands that can merge or close a pull request without a merge notification. */ | 39 | /** Shell commands that can merge or close a pull request without a merge notification. */ |
| 37 | const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; | 40 | const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; |
| 38 | | 41 | |
| | 42 | const isPullRequestProviderError = Schema.is(PullRequestProviderError); |
| | 43 | |
| 39 | type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">; | 44 | type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">; |
| 40 | | 45 | |
| 41 | interface LinkEntry { | 46 | interface LinkEntry { |
| ... | @@ -107,6 +112,21 @@ function stacksEqual( | ... | @@ -107,6 +112,21 @@ function stacksEqual( |
| 107 | ); | 112 | ); |
| 108 | } | 113 | } |
| 109 | | 114 | |
| | 115 | function skipReason(cause: Cause.Cause<unknown>): string { |
| | 116 | const error = Cause.squash(cause); |
| | 117 | return error instanceof Error ? error.message : String(error); |
| | 118 | } |
| | 119 | |
| | 120 | /** When a host read failed because the host is rate limited, the time that pause ends. */ |
| | 121 | function rateLimitRetryAt(cause: Cause.Cause<unknown>): number | undefined { |
| | 122 | let error: unknown = Cause.squash(cause); |
| | 123 | while (error instanceof Error) { |
| | 124 | if (isPullRequestProviderError(error) && error.reason === "rate-limited") return error.retryAt; |
| | 125 | error = error.cause; |
| | 126 | } |
| | 127 | return undefined; |
| | 128 | } |
| | 129 | |
| 110 | function isUnsettled(thread: ProjectionStore.ProjectionThreadPullRequests): boolean { | 130 | function isUnsettled(thread: ProjectionStore.ProjectionThreadPullRequests): boolean { |
| 111 | return thread.settledOverride !== "settled" && thread.settledAt === null; | 131 | return thread.settledOverride !== "settled" && thread.settledAt === null; |
| 112 | } | 132 | } |
| ... | @@ -122,7 +142,10 @@ export class PullRequestSyncReactor extends Context.Service< | ... | @@ -122,7 +142,10 @@ export class PullRequestSyncReactor extends Context.Service< |
| 122 | { | 142 | { |
| 123 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; | 143 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; |
| 124 | readonly drain: Effect.Effect<void>; | 144 | readonly drain: Effect.Effect<void>; |
| 125 | /** Force the next sweep to re-read this pull request, even when its snapshot is terminal. */ | 145 | /** |
| | 146 | * Force the next sweep to re-read this pull request, even when its snapshot is terminal. |
| | 147 | * While its host is rate limited, the read waits for the first sweep after the pause. |
| | 148 | */ |
| 126 | readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect<void>; | 149 | readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect<void>; |
| 127 | } | 150 | } |
| 128 | >()("t3/orchestration-v2/PullRequestSyncReactor") {} | 151 | >()("t3/orchestration-v2/PullRequestSyncReactor") {} |
| ... | @@ -141,6 +164,10 @@ export const make = Effect.gen(function* () { | ... | @@ -141,6 +164,10 @@ export const make = Effect.gen(function* () { |
| 141 | // linking dozens of pull requests) is read together and shares the summary batches. | 164 | // linking dozens of pull requests) is read together and shares the summary batches. |
| 142 | let requestedSweepQueued = false; | 165 | let requestedSweepQueued = false; |
| 143 | const retryStacks = new Set<string>(); | 166 | const retryStacks = new Set<string>(); |
| | 167 | // Rate limit pauses by project and host, since each project reads with its own credential. |
| | 168 | // A paused host refuses every read without asking it, so the sweep leaves its pull requests |
| | 169 | // due until the pause ends rather than failing each of them every minute. |
| | 170 | const pausedUntil = new Map<string, number>(); |
| 144 | | 171 | |
| 145 | const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => { | 172 | const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => { |
| 146 | if (requested.has(key) || retryStacks.has(key)) return true; | 173 | if (requested.has(key) || retryStacks.has(key)) return true; |
| ... | @@ -279,10 +306,15 @@ export const make = Effect.gen(function* () { | ... | @@ -279,10 +306,15 @@ export const make = Effect.gen(function* () { |
| 279 | })), | 306 | })), |
| 280 | Effect.catchCauseIf( | 307 | Effect.catchCauseIf( |
| 281 | (cause) => !Cause.hasInterruptsOnly(cause), | 308 | (cause) => !Cause.hasInterruptsOnly(cause), |
| 282 | () => | 309 | (cause) => |
| 283 | Effect.logWarning("pull request stack lookup failed", { | 310 | rateLimitRetryAt(cause) !== undefined |
| 284 | key, | 311 | ? // The sweep records the pause and holds the host's other reads until it ends. |
| 285 | }).pipe(Effect.as(null)), | 312 | Effect.sync(() => retryStacks.add(key)).pipe( |
| | 313 | Effect.andThen(Effect.failCause(cause)), |
| | 314 | ) |
| | 315 | : Effect.logWarning("pull request stack lookup failed", { |
| | 316 | key, |
| | 317 | }).pipe(Effect.as(null)), |
| 286 | ), | 318 | ), |
| 287 | ); | 319 | ); |
| 288 | if (needsStack) { | 320 | if (needsStack) { |
| ... | @@ -312,18 +344,50 @@ export const make = Effect.gen(function* () { | ... | @@ -312,18 +344,50 @@ export const make = Effect.gen(function* () { |
| 312 | ); | 344 | ); |
| 313 | }); | 345 | }); |
| 314 | | 346 | |
| | 347 | // Failed host reads by reason: how many, and the first key that failed that way. |
| | 348 | const skips = new Map<string, { count: number; readonly key: string }>(); |
| | 349 | const readGroup = (key: string, entries: ReadonlyArray<LinkEntry>, pauseKey: string) => |
| | 350 | syncGroup(key, entries).pipe( |
| | 351 | Effect.catchCause((cause) => { |
| | 352 | if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause); |
| | 353 | const retryAt = rateLimitRetryAt(cause); |
| | 354 | if (retryAt !== undefined) { |
| | 355 | pausedUntil.set(pauseKey, Math.max(retryAt, pausedUntil.get(pauseKey) ?? 0)); |
| | 356 | } |
| | 357 | const reason = skipReason(cause); |
| | 358 | const skip = skips.get(reason); |
| | 359 | if (skip === undefined) skips.set(reason, { count: 1, key }); |
| | 360 | else skip.count += 1; |
| | 361 | return Effect.void; |
| | 362 | }), |
| | 363 | ); |
| 315 | yield* Effect.forEach( | 364 | yield* Effect.forEach( |
| 316 | groups, | 365 | groups, |
| 317 | ([key, entries]) => | 366 | ([key, entries]) => { |
| 318 | (scope === "all" || requested.has(key)) && isDue(key, entries, nowMs) | 367 | if (!((scope === "all" || requested.has(key)) && isDue(key, entries, nowMs))) { |
| 319 | ? syncGroup(key, entries).pipe( | 368 | return Effect.void; |
| 320 | Effect.catchCause(logSkipped("pull request sync skipped", { key })), | 369 | } |
| 321 | ) | 370 | const first = entries[0]!; |
| 322 | : Effect.void, | 371 | const pauseKey = `${first.thread.projectId}\0${normalizeThreadPullRequestKey(first.link).host}`; |
| | 372 | // Checked against the clock as each read starts, so a pause found earlier in this sweep |
| | 373 | // holds the rest, and one that ends during the sweep lets the rest through. |
| | 374 | return Clock.currentTimeMillis.pipe( |
| | 375 | Effect.flatMap((startedAtMs) => |
| | 376 | (pausedUntil.get(pauseKey) ?? 0) > startedAtMs |
| | 377 | ? Effect.void |
| | 378 | : readGroup(key, entries, pauseKey), |
| | 379 | ), |
| | 380 | ); |
| | 381 | }, |
| 323 | // As wide as one batched summary read, so the sweep's reads on a host arrive together and | 382 | // As wide as one batched summary read, so the sweep's reads on a host arrive together and |
| 324 | // GitHub answers them in one request rather than one `gh pr view` apiece. | 383 | // GitHub answers them in one request rather than one `gh pr view` apiece. |
| 325 | { concurrency: 25, discard: true }, | 384 | { concurrency: 25, discard: true }, |
| 326 | ); | 385 | ); |
| | 386 | // A host failure such as a signed-out CLI fails every due pull request the same way, so a |
| | 387 | // sweep reports one line per reason rather than one per pull request. |
| | 388 | for (const [reason, { count, key }] of skips) { |
| | 389 | yield* Effect.logWarning("pull request sync skipped", { count, key, reason }); |
| | 390 | } |
| 327 | }); | 391 | }); |
| 328 | | 392 | |
| 329 | const worker = yield* makeDrainableWorker((scope: "all" | "requested") => | 393 | const worker = yield* makeDrainableWorker((scope: "all" | "requested") => |