apps/server/src/orchestration-v2/PullRequestSyncReactor.ts

import {
  CommandId,
  isOrchestrationV2WorkActive,
  type OrchestrationV2TurnItem,
  type PullRequestSummary,
  type ThreadId,
  type ThreadPullRequestKey,
  type ThreadPullRequestLink,
  type ThreadPullRequestSnapshot,
  type ThreadPullRequestStack,
} from "@t3tools/contracts";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import {
  threadPullRequestKeyOf,
  normalizeThreadPullRequestKey,
  threadPullRequestKeysEqual,
  visibleThreadPullRequests,
} from "../sourceControl/threadPullRequests.ts";
import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Schedule from "effect/Schedule";
import type * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";

import * as GitManager from "../git/GitManager.ts";
import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider";
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import { forkParked } from "../serverActivation.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import { isTerminalRunStatus } from "./ThreadManagementService.ts";

const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000;
// Every server polls every open linked pull request, and most of them sit untouched for days.
// One nobody has updated for QUIET_AFTER_MS is read on the quiet cadence instead of each sweep;
// requested refreshes, watches and a host CLI's merge command still read it at once.
const QUIET_AFTER_MS = 30 * 60 * 1_000;
const QUIET_SYNC_INTERVAL_MS = 5 * 60 * 1_000;

const isChangeRequestCloseCommand = (command: string) =>
  sourceControlDefinitions.definitions.some(
    (definition) => definition.changeRequestCloseCommand?.test(command) === true,
  );

// Where a shell starts a command, past `NAME=value` assignments and wrappers like `timeout 60`.
// A create command anywhere else, as in `echo "gh pr create"`, is text, not a command that runs.
const COMMAND_START =
  "(?:^|[;&|({`\\n]|\\b(?:do|then|else)\\s)\\s*(?:(?:\\w+=\\S*|env|time|nohup|timeout\\s+\\S+)\\s+)*";
const CREATE_COMMANDS = sourceControlDefinitions.definitions.flatMap(
  ({ kind, changeRequestCreateCommand: command }) =>
    command === undefined
      ? []
      : // The subcommand must end its shell word: `gh pr create#x` runs another one.
        [
          {
            kind,
            command,
            run: new RegExp(`${COMMAND_START}${command.source}(?=$|[\\s;&|)])`, "gu"),
          },
        ],
);
const createCommandCount = (code: string) =>
  CREATE_COMMANDS.reduce((count, { run }) => count + [...code.matchAll(run)].length, 0);

// Codex runs each command as `/bin/zsh -lc <script>`, quoting the script in pieces such as
// `"a "'`b`'" c"`.
const SHELL_WRAPPER = /^\s*(?:\S*\/)?(?:ba|z|da|k)?sh\s+-l?c\s+(?=["'])([\s\S]*)$/u;
const SHELL_WORD_PIECE = /"((?:\\[\s\S]|[^"\\])*)"|'([^']*)'|\\([\s\S])|(\S)/gu;
// Heredoc bodies, quoted strings and comments are text a command carries, not commands it runs.
const HEREDOC_BODY =
  /<<-?[ \t]*(['"]?)(\w+)\1[^\n]*\n(?:(?:[\s\S]*?\n)?[ \t]*\2[ \t]*(?=\n|$)|[\s\S]*$)/gu;
const QUOTED_OR_COMMENT = /'[^']*'|"(?:\\[\s\S]|[^"\\])*"|(?<=^|[\s;&|(])#[^\n]*/gmu;
const COMMAND_SEPARATOR = /&&|\|\||[;|\n]/u;
// Commands that print other change requests' URLs, so output beside them proves nothing.
const LISTS_CHANGE_REQUESTS =
  /\b(?:gh\s+(?:pr\s+(?:list|status)|search\s+prs|api)|glab\s+(?:mr\s+list|api))\b/u;
const URL_LINE = /^[ \t]*(https?:\/\/\S+)[ \t]*$/gmu;

/** The script a shell input runs, unwrapped from Codex's `sh -lc`. */
function shellScript(input: string): string {
  const [, wrapped] = SHELL_WRAPPER.exec(input) ?? [];
  return wrapped === undefined
    ? input
    : Array.from(
        wrapped.matchAll(SHELL_WORD_PIECE),
        ([, double, single, escaped, bare]) =>
          double?.replace(/\\(["\\$`])/gu, "$1") ?? single ?? escaped ?? bare,
      ).join("");
}

/** A script split at command separators that sit outside quotes. */
function shellSegments(script: string): ReadonlyArray<string> {
  const segments: Array<string> = [];
  let current = "";
  let quote: string | null = null;
  for (let index = 0; index < script.length; index++) {
    const char = script[index] ?? "";
    if (quote !== null) {
      current += char;
      if (char === "\\" && quote === '"') current += script[++index] ?? "";
      else if (char === quote) quote = null;
      continue;
    }
    if (char === "'" || char === '"') quote = char;
    if (char === "\\") {
      current += char + (script[++index] ?? "");
      continue;
    }
    const pair = script.slice(index, index + 2);
    if (pair === "&&" || pair === "||" || char === ";" || char === "|" || char === "\n") {
      segments.push(current);
      current = "";
      if (pair === "&&" || pair === "||") index++;
      continue;
    }
    current += char;
  }
  segments.push(current);
  return segments;
}

/** The commands a script runs, without the text it carries. */
const shellCode = (script: string) =>
  script.replace(HEREDOC_BODY, "<<").replace(QUOTED_OR_COMMENT, "''");

type CommandExecution = Extract<OrchestrationV2TurnItem, { readonly type: "command_execution" }>;

/**
 * The change requests a shell command opened: URLs on lines of their own in the output of a
 * command that runs a host's create command. One the command also names, as `gh pr view 12`
 * does, existed before it ran. More URLs than create commands means something else printed
 * some, such as `gh pr list`, so none are taken.
 */
export function changeRequestsCreatedBy(input: string, output: string) {
  if (!CREATE_COMMANDS.some(({ command }) => command.test(input))) return [];
  const script = shellScript(input);
  const code = shellCode(script);
  const creates = createCommandCount(code);
  if (creates === 0) return [];
  const otherCommands = code
    .split(COMMAND_SEPARATOR)
    .filter((part) => createCommandCount(part) === 0)
    .join("\n");
  if (LISTS_CHANGE_REQUESTS.test(otherCommands)) return [];
  const otherScript = shellSegments(script)
    .filter((segment) => createCommandCount(shellCode(segment)) === 0)
    .join("\n");
  const creators = new Set(
    CREATE_COMMANDS.filter(({ run }) => code.match(run) !== null).map(({ kind }) => kind),
  );
  const created = new Map<string, ThreadPullRequestKey & { readonly url: string }>();
  for (const [, url = ""] of output.matchAll(URL_LINE)) {
    const parsed = sourceControlDefinitions.parseChangeRequestUrl(url);
    // Only the host whose create command ran can have printed a new change request.
    if (parsed === null || !creators.has(parsed.definition.kind)) continue;
    const { link } = parsed;
    const named = new RegExp(`(?:^|(?<=[\\s#/=("']))${link.number}(?=$|[\\s);&|/"'])`, "u");
    // Quoted text in the other commands counts, as in `printf '.../pull/12'`.
    if (named.test(otherScript)) continue;
    const key = normalizeThreadPullRequestKey(link);
    created.set(threadPullRequestKeyOf(key), { ...key, url });
  }
  return created.size > creates ? [] : [...created.values()];
}

const isPullRequestProviderError = Schema.is(PullRequestProviderError);

type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">;

interface LinkEntry {
  readonly thread: ProjectionStore.ProjectionThreadPullRequests;
  readonly link: ThreadPullRequestLink;
}

/** Another layer of the stack `url` belongs to, in its host's canonical shape. */
function siblingChangeRequestUrl(url: string, number: number): string | null {
  if (!Number.isSafeInteger(number) || number < 1) return null;
  const parsed = sourceControlDefinitions.parseChangeRequestUrl(url);
  if (parsed === null) return null;
  const { definition, link } = parsed;
  return definition.changeRequestUrl({
    host: link.host,
    repository: link.repository,
    number,
    webUrl: link.repositoryUrl,
  });
}

function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields {
  return {
    state: summary.state,
    title: summary.title,
    headBranch: summary.headBranch,
    baseBranch: summary.baseBranch,
    isDraft: summary.isDraft ?? false,
    updatedAt: summary.updatedAt,
    closedAt: summary.closedAt ?? null,
    mergedAt: summary.mergedAt ?? null,
    ...(summary.author === undefined ? {} : { author: summary.author }),
    ...(summary.additions === undefined ? {} : { additions: summary.additions }),
    ...(summary.deletions === undefined ? {} : { deletions: summary.deletions }),
    ...(summary.changedFiles === undefined ? {} : { changedFiles: summary.changedFiles }),
    ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }),
    ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }),
    ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }),
  };
}

// Rotating ghe.com avatar tokens should not make otherwise identical snapshots differ.
function avatarIdentity(avatarUrl: string | null | undefined): string | null {
  if (avatarUrl === null || avatarUrl === undefined) return null;
  const url = URL.parse(avatarUrl);
  if (url === null || (url.hostname !== "ghe.com" && !url.hostname.endsWith(".ghe.com"))) {
    return avatarUrl;
  }
  url.searchParams.delete("token");
  return url.href;
}

function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boolean {
  return (
    left.state === right.state &&
    left.title === right.title &&
    left.headBranch === right.headBranch &&
    left.baseBranch === right.baseBranch &&
    left.isDraft === right.isDraft &&
    left.updatedAt === right.updatedAt &&
    (left.closedAt ?? null) === (right.closedAt ?? null) &&
    (left.mergedAt ?? null) === (right.mergedAt ?? null) &&
    (left.author?.login ?? null) === (right.author?.login ?? null) &&
    avatarIdentity(left.author?.avatarUrl) === avatarIdentity(right.author?.avatarUrl) &&
    left.additions === right.additions &&
    left.deletions === right.deletions &&
    left.changedFiles === right.changedFiles &&
    (left.reviewDecision ?? null) === (right.reviewDecision ?? null) &&
    (left.checksState ?? null) === (right.checksState ?? null) &&
    left.mergeability === right.mergeability
  );
}

function stacksEqual(
  left: ThreadPullRequestStack | null,
  right: ThreadPullRequestStack | null,
): boolean {
  if (left === null || right === null) return left === right;
  return (
    left.kind === right.kind &&
    left.id === right.id &&
    left.number === right.number &&
    left.url === right.url &&
    left.base === right.base &&
    left.layers.length === right.layers.length &&
    left.layers.every((layer, index) => {
      const other = right.layers[index]!;
      return (
        layer.number === other.number &&
        layer.headBranch === other.headBranch &&
        layer.state === other.state
      );
    })
  );
}

/** Stack ids are unique only within a repository, e.g. GitHub's fallback to the stack number. */
function nativeStackKeyOf(link: ThreadPullRequestLink, stack: ThreadPullRequestStack): string {
  const key = normalizeThreadPullRequestKey(link);
  return `${key.host}/${key.repository}#stack:${stack.id}`;
}

function sameLayerNumbers(left: ThreadPullRequestStack, right: ThreadPullRequestStack): boolean {
  return (
    left.layers.length === right.layers.length &&
    left.layers.every((layer, index) => layer.number === right.layers[index]!.number)
  );
}

function skipReason(cause: Cause.Cause<unknown>): string {
  const error = Cause.squash(cause);
  return error instanceof Error ? error.message : String(error);
}

function rateLimitRetryAt(cause: Cause.Cause<unknown>): number | undefined {
  let error: unknown = Cause.squash(cause);
  while (error instanceof Error) {
    if (isPullRequestProviderError(error) && error.reason === "rate-limited") return error.retryAt;
    error = error.cause;
  }
  return undefined;
}

function isUnsettled(thread: ProjectionStore.ProjectionThreadPullRequests): boolean {
  return thread.settledOverride !== "settled" && thread.settledAt === null;
}

export class PullRequestSyncReactor extends Context.Service<
  PullRequestSyncReactor,
  {
    readonly start: () => Effect.Effect<void, never, Scope.Scope>;
    readonly drain: Effect.Effect<void>;
    /** Forces reread on the next sweep, including terminal snapshots; rate-limited hosts wait until the pause ends. */
    readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect<void>;
  }
>()("t3/orchestration-v2/PullRequestSyncReactor") {}

/** @public */
export const make = Effect.gen(function* () {
  const engine = yield* Orchestrator.OrchestratorV2;
  const projections = yield* ProjectionStore.ProjectionStoreV2;
  const pullRequests = yield* PullRequestService.PullRequestService;
  const git = yield* GitManager.GitManager;
  const crypto = yield* Crypto.Crypto;

  const lastSyncedAt = new Map<string, number>();
  const requested = new Set<string>();
  // Requests arriving during a host read wait for the next sweep.
  const pendingRequests = new Set<string>();
  // PR keys wait in `pendingRequests` for one queued sweep, so a burst of links (an agent
  // linking dozens of pull requests) is read together and shares the summary batches.
  let requestedSweepQueued = false;
  const retryStacks = 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>();

  const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => {
    if (requested.has(key) || retryStacks.has(key)) return true;
    if (entries.some((entry) => entry.link.snapshot === null)) return true;
    const active = entries.filter((entry) => isUnsettled(entry.thread));
    // Merged snapshots without mergedAt cannot settle threads; reread slowly until supplied.
    if (
      active.every(
        (entry) => entry.link.snapshot?.state === "merged" && entry.link.snapshot.mergedAt != null,
      )
    ) {
      return false;
    }
    const last = lastSyncedAt.get(key);
    const sinceLastMs = last === undefined ? Number.POSITIVE_INFINITY : nowMs - last;
    const open = active.filter((entry) => entry.link.snapshot?.state === "open");
    if (open.length > 0) {
      const quiet = open.every((entry) => {
        const updatedAt = entry.link.snapshot?.updatedAt;
        return updatedAt != null && nowMs - Date.parse(updatedAt) >= QUIET_AFTER_MS;
      });
      return !quiet || sinceLastMs >= QUIET_SYNC_INTERVAL_MS;
    }
    return sinceLastMs >= SLOW_SYNC_INTERVAL_MS;
  };

  const logSkipped =
    (message: string, fields: Record<string, unknown>) =>
    <E>(cause: Cause.Cause<E>): Effect.Effect<void, E> =>
      Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields);

  const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (scope: "all" | "requested") {
    const threads = yield* projections.getThreadsWithPullRequests();
    const now = yield* DateTime.now;
    const nowMs = DateTime.toEpochMillis(now);
    const nowIso = DateTime.formatIso(now);

    const groups = new Map<string, Array<LinkEntry>>();
    for (const thread of threads) {
      for (const link of visibleThreadPullRequests(thread.pullRequests ?? [])) {
        // Reads depend on the project's checkout and credentials, including their failures.
        const key = `${thread.projectId}\0${threadPullRequestKeyOf(link)}`;
        const entries = groups.get(key) ?? [];
        entries.push({ thread, link });
        groups.set(key, entries);
      }
    }

    // A host-level refresh reaches every project, each retaining its own pending retry.
    for (const [key, entries] of groups) {
      if (pendingRequests.has(threadPullRequestKeyOf(entries[0]!.link))) requested.add(key);
    }
    pendingRequests.clear();

    for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key);
    for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key);
    for (const key of requested.keys()) if (!groups.has(key)) requested.delete(key);

    const linkedThisSweep = new Set<string>();
    // Fresh stack reads by group, so a member flagged by a sibling is compared against what the
    // host answered this sweep, not the saved layers that sibling is about to replace.
    const stacksReadThisSweep = new Map<string, ThreadPullRequestStack | null>();
    const persistence = yield* Semaphore.make(1);

    // Effect.fn's stack frames close over these sweep-scoped bodies and their arguments, and a
    // cache lookup forked inside keeps those frames, so a plain span leaves the sweep collectable.
    const syncEntry = Effect.fnUntraced(function* (
      entry: LinkEntry,
      fields: SnapshotFields,
      fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null,
    ) {
      const { thread, link } = entry;
      const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack;
      const changed =
        link.snapshot === null ||
        !snapshotFieldsEqual(link.snapshot, fields) ||
        !stacksEqual(link.stack, nextStack);
      // Persist siblings before terminal snapshots trigger settlement; settled threads get snapshots without new links.
      for (const layer of isUnsettled(thread) ? (fetchedStack?.stack?.layers ?? []) : []) {
        const layerKey = {
          host: normalizeThreadPullRequestKey(link).host,
          repository: link.repository,
          number: layer.number,
        };
        const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`;
        if (linkedThisSweep.has(dedupeKey)) continue;
        // Tombstones count as present: a dismissed layer is never re-added.
        if (
          (thread.pullRequests ?? []).some((existing) =>
            threadPullRequestKeysEqual(existing, layerKey),
          )
        ) {
          continue;
        }
        const url = siblingChangeRequestUrl(link.url, layer.number);
        if (url === null) continue;
        const uuid = yield* crypto.randomUUIDv4();
        yield* engine.dispatch({
          type: "thread.pull-request.link",
          commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`),
          threadId: thread.id,
          ...layerKey,
          url,
          source: "stack",
        });
        linkedThisSweep.add(dedupeKey);
      }
      if (changed) {
        const uuid = yield* crypto.randomUUIDv4();
        yield* engine.dispatch({
          type: "thread.pull-request-link.sync",
          commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`),
          threadId: thread.id,
          host: normalizeThreadPullRequestKey(link).host,
          repository: link.repository,
          number: link.number,
          snapshot: { ...fields, syncedAt: nowIso },
          stack: nextStack,
        });
      }
    }, Effect.withSpan("PullRequestSyncReactor.syncEntry"));

    const syncGroup = Effect.fnUntraced(function* (key: string, entries: ReadonlyArray<LinkEntry>) {
      const first = entries[0]!;
      const ref = {
        projectId: first.thread.projectId,
        host: normalizeThreadPullRequestKey(first.link).host,
        repository: first.link.repository,
        number: first.link.number,
      };
      const refreshRequested = requested.has(key);
      // A held merged summary is served without asking the host, so a snapshot still missing its
      // merge time has to drop it or the slow-cadence re-read would hand back the same gap.
      const missingMergedAt = entries.some(
        (entry) => entry.link.snapshot?.state === "merged" && entry.link.snapshot.mergedAt == null,
      );
      // A stack retry reads past the cache, or it could get back the layer list it is retrying.
      const invalidated = refreshRequested || missingMergedAt || retryStacks.has(key);
      if (invalidated) yield* pullRequests.invalidate({ reference: ref });
      const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false });
      const fields = snapshotFieldsOf(summary);
      const needsStack =
        refreshRequested ||
        retryStacks.has(key) ||
        entries.some(
          (entry) =>
            entry.link.snapshot === null ||
            !snapshotFieldsEqual(entry.link.snapshot, fields) ||
            (summary.stack !== undefined &&
              (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)),
        );
      const knownUnstacked =
        summary.stack === null && entries.every((entry) => entry.link.stack === null);
      // A sibling can flag this group while its summary read is in flight.
      if (!invalidated && retryStacks.has(key) && !knownUnstacked) {
        yield* pullRequests.invalidate({ reference: ref });
      }
      // The retry this read answers is consumed before the read, not after it: a sibling may
      // flag this group again while the read is in flight, and that flag must survive to the
      // next sweep because the answer below may predate the sibling's layers.
      if (needsStack) retryStacks.delete(key);
      const fetchedStack = !needsStack
        ? null
        : knownUnstacked
          ? { stack: null }
          : yield* pullRequests.stack(ref, { includeDetails: false }).pipe(
              Effect.map((stack) => ({
                stack: stack === null ? null : ({ kind: "native", ...stack } as const),
              })),
              Effect.catchCauseIf(
                (cause) => !Cause.hasInterruptsOnly(cause),
                (cause) =>
                  rateLimitRetryAt(cause) !== undefined
                    ? Effect.sync(() => retryStacks.add(key)).pipe(
                        Effect.andThen(Effect.failCause(cause)),
                      )
                    : Effect.logWarning("pull request stack lookup failed", {
                        key,
                      }).pipe(Effect.as(null)),
              ),
            );
      if (needsStack) {
        if (fetchedStack === null) {
          retryStacks.add(key);
          return;
        }
        const fetched = fetchedStack.stack;
        stacksReadThisSweep.set(key, fetched);
        // New upper layers leave older members' fields unchanged; differing layer lists need a stack read.
        // Only flag siblings in the same project: different credentials can yield lists that never agree.
        if (fetched !== null) {
          const fetchedKey = nativeStackKeyOf(first.link, fetched);
          for (const [siblingKey, siblingEntries] of groups) {
            if (siblingKey === key) continue;
            if (siblingEntries[0]!.thread.projectId !== first.thread.projectId) continue;
            const readThisSweep = stacksReadThisSweep.get(siblingKey);
            const stale = siblingEntries.some(({ link }) => {
              const stack = readThisSweep === undefined ? link.stack : readThisSweep;
              return (
                stack !== null &&
                nativeStackKeyOf(link, stack) === fetchedKey &&
                !sameLayerNumbers(stack, fetched)
              );
            });
            if (stale) retryStacks.add(siblingKey);
          }
        }
      }
      // The host answered, so the cadence clock ticks even if a dispatch below is rejected.
      lastSyncedAt.set(key, nowMs);
      requested.delete(key);
      yield* Effect.forEach(
        entries,
        (entry) =>
          syncEntry(entry, fields, fetchedStack).pipe(
            persistence.withPermits(1),
            Effect.catchCause((cause) => {
              if (!Cause.hasInterruptsOnly(cause)) retryStacks.add(key);
              return logSkipped("pull request sync skipped", { threadId: entry.thread.id, key })(
                cause,
              );
            }),
          ),
        { discard: true },
      );
    }, Effect.withSpan("PullRequestSyncReactor.syncGroup"));

    const skips = new Map<string, { count: number; readonly key: string }>();
    const readGroup = (key: string, entries: ReadonlyArray<LinkEntry>, pauseKey: string) =>
      syncGroup(key, entries).pipe(
        Effect.catchCause((cause) => {
          if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
          const retryAt = rateLimitRetryAt(cause);
          if (retryAt !== undefined) {
            pausedUntil.set(pauseKey, Math.max(retryAt, pausedUntil.get(pauseKey) ?? 0));
          }
          const reason = skipReason(cause);
          const skip = skips.get(reason);
          if (skip === undefined) skips.set(reason, { count: 1, key });
          else skip.count += 1;
          return Effect.void;
        }),
      );
    yield* Effect.forEach(
      groups,
      ([key, entries]) => {
        if (!((scope === "all" || requested.has(key)) && isDue(key, entries, nowMs))) {
          return Effect.void;
        }
        const first = entries[0]!;
        const pauseKey = `${first.thread.projectId}\0${normalizeThreadPullRequestKey(first.link).host}`;
        // Check pauses at each read so changes during the sweep apply immediately.
        return Clock.currentTimeMillis.pipe(
          Effect.flatMap((startedAtMs) =>
            (pausedUntil.get(pauseKey) ?? 0) > startedAtMs
              ? Effect.void
              : readGroup(key, entries, pauseKey),
          ),
        );
      },
      // Match summary batch width so GitHub reads coalesce into one request.
      { concurrency: 25, discard: true },
    );
    for (const [reason, { count, key }] of skips) {
      yield* Effect.logWarning("pull request sync skipped", { count, key, reason });
    }
  });

  const worker = yield* makeDrainableWorker((scope: "all" | "requested") =>
    Effect.suspend(() => {
      if (scope === "requested") requestedSweepQueued = false;
      return sweep(scope);
    }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))),
  );

  // Threads that ran a close command this run, with the commands already read for.
  const closeCommands = new Map<ThreadId, Set<string>>();
  const refreshOpenLinks = (threadId: ThreadId) =>
    projections.getThreadsWithPullRequests(threadId).pipe(
      Effect.flatMap((threads) =>
        Effect.forEach(
          threads.flatMap((thread) =>
            visibleThreadPullRequests(thread.pullRequests ?? []).filter(
              (link) => link.snapshot?.state === "open",
            ),
          ),
          requestSync,
          { discard: true },
        ),
      ),
      Effect.catchCause(logSkipped("pull request refresh after run skipped", { threadId })),
    );

  // An agent that opens a pull request from a shell gets it linked as link_pull_request would.
  const linkCreatedChangeRequests = Effect.fn("PullRequestSyncReactor.linkCreatedChangeRequests")(
    function* (threadId: ThreadId, item: CommandExecution) {
      if (isOrchestrationV2WorkActive(item.status)) return;
      yield* Effect.forEach(
        changeRequestsCreatedBy(item.input, item.output ?? ""),
        (created) =>
          engine
            .dispatch({
              type: "thread.pull-request.link",
              // Stable per command, so a repeated settlement cannot relink one the user unlinked.
              commandId: CommandId.make(
                `server:pr-created-link:${threadId}:${item.id}:${threadPullRequestKeyOf(created)}`,
              ),
              threadId,
              ...created,
              source: "created",
            })
            .pipe(
              Effect.catchCause(
                logSkipped("linking a pull request a command created failed", { threadId }),
              ),
            ),
        { discard: true },
      );
    },
  );

  const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn(
    "PullRequestSyncReactor.start",
  )(function* () {
    const events = engine.streamDomainEvents;
    const stateChanges = Stream.merge(
      yield* pullRequests.subscribeStateChanges,
      yield* git.subscribePullRequestStateChanges,
    );
    yield* forkParked(
      Stream.runForEach(stateChanges, requestSync).pipe(
        Effect.catchCause(logSkipped("pull request state change stream failed", {})),
      ),
    );
    yield* forkParked(
      Stream.runForEach(events, (event) => {
        switch (event.type) {
          case "thread.pull-request-link-updated":
            return event.payload.snapshot === null ? requestSync(event.payload) : Effect.void;
          case "thread.pull-request-synced":
            return Effect.forEach(
              visibleThreadPullRequests(event.payload.pullRequests ?? []).filter(
                (link) => link.snapshot === null,
              ),
              requestSync,
              { discard: true },
            );
          // An agent can merge or close its pull request from a shell with its host's CLI, which
          // sends no merge notification. Read the thread's open links fresh once that command
          // ends, while the agent usually still replies, so settlement can land before the run
          // ends. Read again when the run ends, since `merge --auto` returns before it merges.
          case "turn-item.updated": {
            const item = event.payload;
            if (item.type !== "command_execution") return Effect.void;
            const linked = linkCreatedChangeRequests(event.threadId, item);
            if (!isChangeRequestCloseCommand(item.input)) return linked;
            const ended = closeCommands.get(event.threadId) ?? new Set<string>();
            closeCommands.set(event.threadId, ended);
            if (isOrchestrationV2WorkActive(item.status) || ended.has(item.id)) return linked;
            ended.add(item.id);
            return Effect.andThen(linked, refreshOpenLinks(event.threadId));
          }
          case "run.updated":
            return isTerminalRunStatus(event.payload.status) && closeCommands.delete(event.threadId)
              ? refreshOpenLinks(event.threadId)
              : Effect.void;
          default:
            return Effect.void;
        }
      }).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))),
    );
    yield* forkParked(
      Effect.gen(function* () {
        yield* worker.enqueue("all");
        yield* worker.drain;
      }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
    );
  });

  const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) =>
    Effect.suspend(() => {
      pendingRequests.add(threadPullRequestKeyOf(key));
      if (requestedSweepQueued) return Effect.void;
      requestedSweepQueued = true;
      return worker.enqueue("requested");
    });

  return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"];
});

export const layer = Layer.effect(PullRequestSyncReactor, make);