@effect-agent/pr-review 0.1.0-beta.26 → 0.1.0-beta.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import {
11
11
  ReviewAdjudicationFailure,
12
12
  ReviewAdjudicationHost,
13
13
  } from "./adjudication.ts";
14
- import { ChangedFile } from "./diff.ts";
14
+ import { ChangedFile, ChangedPath } from "./diff.ts";
15
15
  import { extractFingerprint } from "./fingerprint.ts";
16
16
  import type { ReviewPublicationPlan } from "./render.ts";
17
17
  import {
@@ -21,8 +21,11 @@ import {
21
21
  ReviewRetirementHost,
22
22
  } from "./retirement.ts";
23
23
  import {
24
+ GitCommitSha,
25
+ MAX_TREE_COMPARISON_PATHS,
24
26
  ReviewHeadComparison,
25
27
  ReviewStateAuthenticator,
28
+ ReviewTreeComparison,
26
29
  type ReviewState,
27
30
  } from "./review-state.ts";
28
31
  import {
@@ -884,14 +887,15 @@ export class PriorReviews extends Context.Service<
884
887
  headSha: string,
885
888
  ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
886
889
  /**
887
- * Two-dot tree comparison (`base..head`). Used when the reviewed head is
888
- * not a git ancestor so a rebase or amend can still name the paths whose
889
- * blob contents actually changed.
890
+ * Compare complete commit tree snapshots for a bounded path allowlist.
891
+ * Used when the reviewed head is not a git ancestor after a rebase,
892
+ * amend, or force-push.
890
893
  */
891
894
  readonly compareTrees: (
892
895
  baseSha: string,
893
896
  headSha: string,
894
- ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
897
+ paths: ReadonlyArray<string>,
898
+ ) => Effect.Effect<ReviewTreeComparison, PriorReviewLookupFailure>;
895
899
  }
896
900
  >()("@effect-agent/pr-review/PriorReviews") {}
897
901
 
@@ -916,6 +920,45 @@ const GitHubCompareWire = Schema.Struct({
916
920
  files: GitHubFilesPageWire,
917
921
  });
918
922
 
923
+ const GitHubGitCommitWire = Schema.Struct({
924
+ sha: GitCommitSha,
925
+ tree: Schema.Struct({ sha: GitCommitSha }),
926
+ });
927
+
928
+ const GitHubTreeEntryFields = {
929
+ path: Schema.String.check(Schema.isMaxLength(4_096)),
930
+ sha: GitCommitSha,
931
+ } as const;
932
+
933
+ const GitHubTreeEntryWire = Schema.Union([
934
+ Schema.Struct({
935
+ ...GitHubTreeEntryFields,
936
+ mode: Schema.Literals(["100644", "100755", "120000"]),
937
+ type: Schema.Literal("blob"),
938
+ }),
939
+ Schema.Struct({
940
+ ...GitHubTreeEntryFields,
941
+ mode: Schema.Literal("040000"),
942
+ type: Schema.Literal("tree"),
943
+ }),
944
+ Schema.Struct({
945
+ ...GitHubTreeEntryFields,
946
+ mode: Schema.Literal("160000"),
947
+ type: Schema.Literal("commit"),
948
+ }),
949
+ ]);
950
+
951
+ const MAX_RECURSIVE_TREE_ENTRIES = 100_000;
952
+ const GitHubTreeWire = Schema.Struct({
953
+ sha: GitCommitSha,
954
+ tree: Schema.Array(GitHubTreeEntryWire).check(Schema.isMaxLength(MAX_RECURSIVE_TREE_ENTRIES)),
955
+ truncated: Schema.Boolean,
956
+ });
957
+
958
+ const TreeComparisonPaths = Schema.Array(ChangedPath).check(
959
+ Schema.isMaxLength(MAX_TREE_COMPARISON_PATHS),
960
+ );
961
+
919
962
  /** Reviews are paged chronologically; scanning stays bounded. */
920
963
  const MAX_PRIOR_REVIEW_PAGES = 5;
921
964
 
@@ -935,6 +978,24 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
935
978
  PriorReviewLookupFailure.make({
936
979
  reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
937
980
  });
981
+ const asTreeLookupFailure =
982
+ (operation: string) => (error: { readonly _tag: string; readonly message?: string }) =>
983
+ PriorReviewLookupFailure.make({
984
+ reason: `${operation}: ${error._tag}: ${error.message ?? "request failed"}`.slice(
985
+ 0,
986
+ 2_048,
987
+ ),
988
+ });
989
+ const decodeLookupJson = <S extends Schema.Top>(schema: S, operation: string) => {
990
+ const decode = Schema.decodeUnknownEffect(schema);
991
+ return (response: HttpClientResponse.HttpClientResponse) =>
992
+ response.json.pipe(
993
+ Effect.mapError(asTreeLookupFailure(operation)),
994
+ Effect.flatMap((body) =>
995
+ decode(body).pipe(Effect.mapError(asTreeLookupFailure(operation))),
996
+ ),
997
+ );
998
+ };
938
999
  const readMarkers = (authenticator: Option.Option<ReviewStateAuthenticator["Service"]>) =>
939
1000
  Effect.gen(function* () {
940
1001
  const perPage = 100;
@@ -995,12 +1056,12 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
995
1056
  }
996
1057
  return { latestFingerprint: latest, latestState };
997
1058
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
998
- const compareCommits = (baseSha: string, headSha: string, separator: "..." | "..") =>
1059
+ const compareCommits = (baseSha: string, headSha: string) =>
999
1060
  Effect.gen(function* () {
1000
1061
  const response = yield* HttpClient.execute(
1001
1062
  withCommonHeaders(
1002
1063
  HttpClientRequest.get(
1003
- `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}${separator}${encodeURIComponent(headSha)}`,
1064
+ `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`,
1004
1065
  ).pipe(HttpClientRequest.acceptJson),
1005
1066
  target.token,
1006
1067
  ),
@@ -1023,21 +1084,107 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
1023
1084
  truncated: files.length >= MAX_CHANGED_FILES,
1024
1085
  });
1025
1086
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
1026
- const compareTrees = (baseSha: string, headSha: string) =>
1027
- compareCommits(baseSha, headSha, "..").pipe(
1028
- Effect.map((comparison) =>
1029
- ReviewHeadComparison.make({
1030
- // This is a content snapshot, not a lineage claim. Selection
1031
- // intersects these files with the current PR path set.
1032
- status: comparison.status === "identical" ? "identical" : "ahead",
1033
- baseSha,
1034
- headSha,
1035
- mergeBaseSha: baseSha,
1036
- files: comparison.files,
1037
- truncated: comparison.truncated,
1038
- }),
1039
- ),
1087
+ const readTreeSnapshot = Effect.fn("PriorReviews.readTreeSnapshot")(function* (
1088
+ commitSha: string,
1089
+ ) {
1090
+ const commitResponse = yield* client
1091
+ .execute(
1092
+ withCommonHeaders(
1093
+ HttpClientRequest.get(
1094
+ `${target.apiUrl}/repos/${target.repository}/git/commits/${encodeURIComponent(commitSha)}`,
1095
+ ).pipe(HttpClientRequest.acceptJson),
1096
+ target.token,
1097
+ ),
1098
+ )
1099
+ .pipe(
1100
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
1101
+ Effect.mapError(asTreeLookupFailure("get Git commit")),
1102
+ );
1103
+ const commit = yield* decodeLookupJson(
1104
+ GitHubGitCommitWire,
1105
+ "decode Git commit",
1106
+ )(commitResponse);
1107
+ if (commit.sha !== commitSha) {
1108
+ return yield* PriorReviewLookupFailure.make({
1109
+ reason: `GitHub returned commit ${commit.sha} for requested snapshot ${commitSha}`,
1110
+ });
1111
+ }
1112
+ const treeResponse = yield* client
1113
+ .execute(
1114
+ withCommonHeaders(
1115
+ HttpClientRequest.get(
1116
+ `${target.apiUrl}/repos/${target.repository}/git/trees/${encodeURIComponent(commit.tree.sha)}`,
1117
+ ).pipe(
1118
+ HttpClientRequest.acceptJson,
1119
+ HttpClientRequest.setUrlParams({ recursive: "1" }),
1120
+ ),
1121
+ target.token,
1122
+ ),
1123
+ )
1124
+ .pipe(
1125
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
1126
+ Effect.mapError(asTreeLookupFailure("get recursive Git tree")),
1127
+ );
1128
+ const tree = yield* decodeLookupJson(
1129
+ GitHubTreeWire,
1130
+ "decode recursive Git tree",
1131
+ )(treeResponse);
1132
+ if (tree.sha !== commit.tree.sha) {
1133
+ return yield* PriorReviewLookupFailure.make({
1134
+ reason: `GitHub returned tree ${tree.sha} for requested tree ${commit.tree.sha}`,
1135
+ });
1136
+ }
1137
+ const entries = new Map<string, typeof GitHubTreeEntryWire.Type>();
1138
+ for (const entry of tree.tree) {
1139
+ if (entries.has(entry.path)) {
1140
+ return yield* PriorReviewLookupFailure.make({
1141
+ reason: `GitHub returned duplicate path '${entry.path}' in tree ${tree.sha}`,
1142
+ });
1143
+ }
1144
+ entries.set(entry.path, entry);
1145
+ }
1146
+ return { entries, truncated: tree.truncated } as const;
1147
+ });
1148
+ const compareTrees = Effect.fn("PriorReviews.compareTrees")(function* (
1149
+ baseSha: string,
1150
+ headSha: string,
1151
+ paths: ReadonlyArray<string>,
1152
+ ) {
1153
+ const decodeSha = Schema.decodeUnknownEffect(GitCommitSha);
1154
+ const [validatedBaseSha, validatedHeadSha, validatedPaths] = yield* Effect.all([
1155
+ decodeSha(baseSha),
1156
+ decodeSha(headSha),
1157
+ Schema.decodeUnknownEffect(TreeComparisonPaths)(paths),
1158
+ ]).pipe(Effect.mapError(asTreeLookupFailure("validate tree comparison request")));
1159
+ const uniquePaths = [...new Set(validatedPaths)].sort();
1160
+ const { base, head } = yield* Effect.all(
1161
+ {
1162
+ base: readTreeSnapshot(validatedBaseSha),
1163
+ head: readTreeSnapshot(validatedHeadSha),
1164
+ },
1165
+ { concurrency: 2 },
1040
1166
  );
1167
+ if (base.truncated || head.truncated) {
1168
+ return ReviewTreeComparison.make({
1169
+ baseSha: validatedBaseSha,
1170
+ headSha: validatedHeadSha,
1171
+ changedPaths: [],
1172
+ truncated: true,
1173
+ });
1174
+ }
1175
+ const changedPaths = uniquePaths.filter((path) => {
1176
+ const before = base.entries.get(path);
1177
+ const after = head.entries.get(path);
1178
+ if (before === undefined || after === undefined) return before !== after;
1179
+ return before.sha !== after.sha || before.mode !== after.mode || before.type !== after.type;
1180
+ });
1181
+ return ReviewTreeComparison.make({
1182
+ baseSha: validatedBaseSha,
1183
+ headSha: validatedHeadSha,
1184
+ changedPaths,
1185
+ truncated: false,
1186
+ });
1187
+ });
1041
1188
  return PriorReviews.of({
1042
1189
  latestFingerprint: readMarkers(Option.none()).pipe(
1043
1190
  Effect.map((markers) => markers.latestFingerprint),
@@ -1048,7 +1195,7 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
1048
1195
  Effect.map((markers) => markers.latestState),
1049
1196
  );
1050
1197
  }),
1051
- compareHeads: (baseSha, headSha) => compareCommits(baseSha, headSha, "..."),
1198
+ compareHeads: compareCommits,
1052
1199
  compareTrees,
1053
1200
  });
1054
1201
  }),
@@ -1,6 +1,6 @@
1
1
  import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
2
2
  import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
3
- import { Config, Layer } from "effect";
3
+ import { Config, Effect, Layer, Schema } from "effect";
4
4
  import { FetchHttpClient } from "effect/unstable/http";
5
5
 
6
6
  import { resolveEffortRung, type EffortAliasName, type EffortPosition } from "./effort.ts";
@@ -14,7 +14,36 @@ import { resolveEffortRung, type EffortAliasName, type EffortPosition } from "./
14
14
  // application supplies them at the edge (D-027).
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
- export type ReviewProvider = "openai" | "anthropic";
17
+ export const ReviewProvider = Schema.Literals(["openai", "anthropic"]);
18
+ export type ReviewProvider = typeof ReviewProvider.Type;
19
+
20
+ /** OpenAI Responses service tiers supported by the packaged reviewer. */
21
+ export const OpenAiServiceTier = Schema.Literal("fast");
22
+ export type OpenAiServiceTier = typeof OpenAiServiceTier.Type;
23
+
24
+ /** A provider-specific OpenAI tier was configured for another provider. */
25
+ export class UnsupportedServiceTierProvider extends Schema.TaggedError<UnsupportedServiceTierProvider>()(
26
+ "UnsupportedServiceTierProvider",
27
+ {
28
+ provider: ReviewProvider,
29
+ serviceTier: OpenAiServiceTier,
30
+ },
31
+ ) {
32
+ override get message() {
33
+ return `Service tier '${this.serviceTier}' requires provider 'openai'; received '${this.provider}'.`;
34
+ }
35
+ }
36
+
37
+ /** Reject an OpenAI-only service tier before constructing another provider's model. */
38
+ export const validateReviewServiceTier = Effect.fn("validateReviewServiceTier")(function* (
39
+ provider: ReviewProvider,
40
+ serviceTier: OpenAiServiceTier | undefined,
41
+ ): Effect.fn.Return<OpenAiServiceTier | undefined, UnsupportedServiceTierProvider> {
42
+ if (serviceTier !== undefined && provider !== "openai") {
43
+ return yield* UnsupportedServiceTierProvider.make({ provider, serviceTier });
44
+ }
45
+ return serviceTier;
46
+ });
18
47
 
19
48
  export const DEFAULT_PROVIDER: ReviewProvider = "openai";
20
49
 
@@ -44,7 +73,11 @@ export const PROVIDER_EFFORT_RUNGS = {
44
73
  >;
45
74
 
46
75
  /** One OpenAI review model binding with the package's structured-output settings. */
47
- export const makeOpenAiReviewModel = (model?: string, effort?: EffortPosition) =>
76
+ export const makeOpenAiReviewModel = (
77
+ model?: string,
78
+ effort?: EffortPosition,
79
+ serviceTier?: OpenAiServiceTier,
80
+ ) =>
48
81
  OpenAiLanguageModel.model(model ?? DEFAULT_MODEL.openai, {
49
82
  // OpenAI counts hidden reasoning tokens and visible answer tokens against
50
83
  // this same ceiling. High-effort reviews can exhaust an 8k allowance
@@ -52,6 +85,7 @@ export const makeOpenAiReviewModel = (model?: string, effort?: EffortPosition) =
52
85
  max_output_tokens: 32_000,
53
86
  store: false,
54
87
  strictJsonSchema: true,
88
+ ...(serviceTier === undefined ? {} : { service_tier: serviceTier }),
55
89
  ...(effort === undefined
56
90
  ? {}
57
91
  : { reasoning: { effort: resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS.openai) } }),
@@ -68,19 +102,24 @@ export const makeAnthropicReviewModel = (model?: string, effort?: EffortPosition
68
102
 
69
103
  /**
70
104
  * The human-readable descriptor of one provider binding, e.g.
71
- * `openai/gpt-5.6-sol (effort high)`. Rendered into the review footer and
105
+ * `openai/gpt-5.6-sol (effort high, service tier fast)`. Rendered into the review footer and
72
106
  * included in the changeset-fingerprint signature, so a provider, model, or
73
- * effort change re-reviews instead of skipping.
107
+ * request-profile change re-reviews instead of skipping.
74
108
  */
75
109
  export const describeReviewModel = (
76
110
  provider: ReviewProvider,
77
111
  model?: string,
78
112
  effort?: EffortPosition,
113
+ serviceTier?: OpenAiServiceTier,
79
114
  ): string => {
80
115
  const base = `${provider}/${model ?? DEFAULT_MODEL[provider]}`;
81
- return effort === undefined
82
- ? base
83
- : `${base} (effort ${resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS[provider])})`;
116
+ const details = [
117
+ ...(effort === undefined
118
+ ? []
119
+ : [`effort ${resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS[provider])}`]),
120
+ ...(serviceTier === undefined ? [] : [`service tier ${serviceTier}`]),
121
+ ];
122
+ return details.length === 0 ? base : `${base} (${details.join(", ")})`;
84
123
  };
85
124
 
86
125
  /** The OpenAI client Layer, credential from `OPENAI_API_KEY`. */
@@ -131,11 +131,11 @@ export const MAX_STORED_UNREVIEWED_PATHS = 100;
131
131
  /** Failed-pass records stored beside the leftover paths; one per unit stage. */
132
132
  export const MAX_STORED_UNREVIEWED_PASSES = 24;
133
133
 
134
- /** Stages a leftover path may need retried without a second general discovery. */
134
+ /** Stages a leftover path may need retried on the next incremental run. */
135
135
  export const UnreviewedStage = Schema.Literals(["discovery", "specialist", "verification"]);
136
136
  export type UnreviewedStage = typeof UnreviewedStage.Type;
137
137
 
138
- /** One failed fan-out pass whose paths should be retried, not rediscovered. */
138
+ /** One failed fan-out pass whose stage remains attached to its exact paths. */
139
139
  export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
140
140
  "@effect-agent/pr-review/StoredUnreviewedPass",
141
141
  )({
@@ -395,6 +395,23 @@ export class ReviewHeadComparison extends Schema.Class<ReviewHeadComparison>(
395
395
  truncated: Schema.Boolean,
396
396
  }) {}
397
397
 
398
+ /**
399
+ * Current and previous paths for 300 PR files plus bounded stored continuity
400
+ * paths. The live adapter refuses a larger snapshot-comparison request.
401
+ */
402
+ export const MAX_TREE_COMPARISON_PATHS = 750;
403
+
404
+ /** A direct comparison of two complete commit tree snapshots. */
405
+ export class ReviewTreeComparison extends Schema.Class<ReviewTreeComparison>(
406
+ "@effect-agent/pr-review/ReviewTreeComparison",
407
+ )({
408
+ baseSha: GitCommitSha,
409
+ headSha: GitCommitSha,
410
+ changedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_TREE_COMPARISON_PATHS)),
411
+ /** True when GitHub returned either recursive tree incompletely. */
412
+ truncated: Schema.Boolean,
413
+ }) {}
414
+
398
415
  /** Internal review selection applied as a decorator over the full PR source. */
399
416
  export interface ReviewSelection {
400
417
  readonly mode: ReviewScopeMode;
@@ -403,9 +420,12 @@ export interface ReviewSelection {
403
420
  /** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
404
421
  readonly affectedPaths: ReadonlyArray<string>;
405
422
  /**
406
- * Leftover paths whose contents did not change. Fan-out retries only the
407
- * recorded failed stages on these paths and keeps their stored findings.
423
+ * Failed stages attached to the unchanged paths that own them. Verification
424
+ * retries reopen discovery for only their paths because candidates are not
425
+ * persisted in review state.
408
426
  */
427
+ readonly retryPasses?: ReadonlyArray<StoredUnreviewedPass>;
428
+ /** Flattened summaries retained for diagnostics and compatibility. */
409
429
  readonly retryPaths: ReadonlyArray<string>;
410
430
  readonly retryStages: ReadonlyArray<UnreviewedStage>;
411
431
  readonly totalFiles: number;
@@ -429,6 +449,7 @@ export const fullReviewSelection = (input: {
429
449
  affectedPaths: input.files.flatMap((file) =>
430
450
  file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
431
451
  ),
452
+ retryPasses: [],
432
453
  retryPaths: [],
433
454
  retryStages: [],
434
455
  totalFiles: input.totalFiles,
@@ -478,19 +499,15 @@ const filePaths = (file: ChangedFile): ReadonlyArray<string> =>
478
499
  file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
479
500
 
480
501
  const incrementalFromDelta = (input: {
481
- readonly current: PullRequestMetadata;
482
502
  readonly fullFiles: ReadonlyArray<ChangedFile>;
483
503
  readonly profileFingerprint: string;
484
504
  readonly priorState: ReviewState;
485
- readonly deltaFiles: ReadonlyArray<ChangedFile>;
505
+ readonly deltaPaths: ReadonlyArray<string>;
486
506
  readonly extraAffectedPaths?: ReadonlyArray<string> | undefined;
487
507
  readonly reason: string;
488
508
  }): ReviewSelection => {
489
509
  const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
490
- const affectedPaths = new Set([
491
- ...input.deltaFiles.flatMap(filePaths),
492
- ...(input.extraAffectedPaths ?? []),
493
- ]);
510
+ const affectedPaths = new Set([...input.deltaPaths, ...(input.extraAffectedPaths ?? [])]);
494
511
  const initialAffectedCount = affectedPaths.size;
495
512
  // Reopen every current path needed to reassess a concern touched by this
496
513
  // delta. Repeat to a fixed point because two concerns may overlap on a path.
@@ -509,38 +526,45 @@ const incrementalFromDelta = (input: {
509
526
  }
510
527
  }
511
528
  const selectedByPath = new Map<string, ChangedFile>();
512
- for (const file of input.deltaFiles) {
513
- if (
514
- currentPaths.has(file.path) ||
515
- (file.previousPath !== undefined && currentPaths.has(file.previousPath))
516
- ) {
517
- selectedByPath.set(file.path, file);
518
- }
519
- }
520
529
  const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
521
530
  const retryOnly = new Set<string>();
522
- const retryStages = new Set<UnreviewedStage>();
523
531
  for (const path of carriedPaths) {
524
532
  if (affectedPaths.has(path)) continue;
525
533
  retryOnly.add(path);
526
- for (const pass of input.priorState.unreviewedPasses) {
527
- if (pass.paths.includes(path)) retryStages.add(pass.stage);
528
- }
529
534
  }
530
- if (
531
- retryStages.has("verification") &&
532
- !retryStages.has("discovery") &&
533
- !retryStages.has("specialist")
534
- ) {
535
- retryStages.add("discovery");
536
- retryStages.add("specialist");
535
+
536
+ const retryPathsByStage = new Map<UnreviewedStage, Set<string>>();
537
+ const representedRetryPaths = new Set<string>();
538
+ for (const pass of input.priorState.unreviewedPasses) {
539
+ for (const path of pass.paths) {
540
+ if (!retryOnly.has(path)) continue;
541
+ const paths = retryPathsByStage.get(pass.stage) ?? new Set<string>();
542
+ paths.add(path);
543
+ retryPathsByStage.set(pass.stage, paths);
544
+ representedRetryPaths.add(path);
545
+ }
537
546
  }
538
- if (retryOnly.size > 0 && retryStages.size === 0) {
539
- for (const path of retryOnly) affectedPaths.add(path);
540
- retryStages.add("discovery");
541
- retryStages.add("specialist");
542
- retryStages.add("verification");
547
+ // Some continuity gaps (capacity overflow, partial evidence, legacy state)
548
+ // have no failed-pass record. They conservatively re-enter fresh discovery
549
+ // instead of inheriting another path's unrelated failed stage.
550
+ for (const path of retryOnly) {
551
+ if (representedRetryPaths.has(path)) continue;
552
+ retryOnly.delete(path);
553
+ affectedPaths.add(path);
543
554
  }
555
+ const retryPasses = (["discovery", "specialist", "verification"] as const).flatMap((stage) => {
556
+ const paths = [...(retryPathsByStage.get(stage) ?? [])]
557
+ .filter((path) => retryOnly.has(path))
558
+ .sort();
559
+ return Array.from({ length: Math.ceil(paths.length / 12) }, (_, index) =>
560
+ StoredUnreviewedPass.make({
561
+ stage,
562
+ paths: paths.slice(index * 12, (index + 1) * 12),
563
+ }),
564
+ );
565
+ });
566
+ const retryPaths = [...retryOnly].sort();
567
+ const retryStages = [...new Set(retryPasses.map((pass) => pass.stage))];
544
568
  for (const file of input.fullFiles) {
545
569
  const needed =
546
570
  affectedPaths.has(file.path) ||
@@ -552,10 +576,10 @@ const incrementalFromDelta = (input: {
552
576
  const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
553
577
  left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
554
578
  );
555
- const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
579
+ const leftoverCount = retryPaths.length;
556
580
  const carriedReason =
557
581
  leftoverCount > 0
558
- ? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery`
582
+ ? `; retrying ${leftoverCount} unchanged leftover path(s) by recorded failed stage`
559
583
  : carriedPaths.length > 0
560
584
  ? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
561
585
  : "";
@@ -569,8 +593,9 @@ const incrementalFromDelta = (input: {
569
593
  reason: `${input.reason}${carriedReason}${concernReason}`,
570
594
  files: selectedFiles,
571
595
  affectedPaths: [...affectedPaths].sort(),
572
- retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
573
- retryStages: [...retryStages].sort(),
596
+ retryPasses,
597
+ retryPaths,
598
+ retryStages,
574
599
  totalFiles: selectedFiles.length,
575
600
  baselineSha: input.priorState.reviewedHeadSha,
576
601
  priorState: input.priorState,
@@ -588,11 +613,12 @@ export const selectReviewRange = (input: {
588
613
  readonly comparison: ReviewHeadComparison | undefined;
589
614
  readonly baseComparison?: ReviewHeadComparison | undefined;
590
615
  /**
591
- * Two-dot tree comparison used when the reviewed head is not a git ancestor
592
- * (rebase, amend, force-push). Intersected with the current PR path set so
593
- * main-drift outside the pull request never re-enters scope.
616
+ * Direct commit-tree snapshot comparison used when the reviewed head is not
617
+ * a git ancestor. Selection hydrates these paths from the current PR files.
594
618
  */
595
- readonly contentComparison?: ReviewHeadComparison | undefined;
619
+ readonly contentComparison?: ReviewTreeComparison | undefined;
620
+ /** Why the direct snapshot comparison could not produce complete evidence. */
621
+ readonly contentComparisonFailure?: string | undefined;
596
622
  readonly lookupFailure?: string | undefined;
597
623
  }): ReviewSelection => {
598
624
  const full = (reason: string) =>
@@ -634,26 +660,38 @@ export const selectReviewRange = (input: {
634
660
  baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
635
661
  }
636
662
  return incrementalFromDelta({
637
- current: input.current,
638
663
  fullFiles: input.fullFiles,
639
664
  profileFingerprint: input.profileFingerprint,
640
665
  priorState: input.priorState,
641
- deltaFiles: comparison.files,
666
+ deltaPaths: comparison.files.flatMap(filePaths),
642
667
  extraAffectedPaths: extraAffected,
643
668
  reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
644
669
  });
645
670
  }
646
671
  const contentComparison = input.contentComparison;
647
- if (contentComparison !== undefined && !contentComparison.truncated) {
672
+ if (contentComparison !== undefined) {
673
+ if (
674
+ contentComparison.baseSha !== input.priorState.reviewedHeadSha ||
675
+ contentComparison.headSha !== input.current.headSha
676
+ ) {
677
+ return full("the rewritten-head tree snapshot comparison did not match the requested heads");
678
+ }
679
+ if (contentComparison.truncated) {
680
+ return full("the rewritten-head tree snapshot comparison was truncated");
681
+ }
648
682
  return incrementalFromDelta({
649
- current: input.current,
650
683
  fullFiles: input.fullFiles,
651
684
  profileFingerprint: input.profileFingerprint,
652
685
  priorState: input.priorState,
653
- deltaFiles: contentComparison.files,
686
+ deltaPaths: contentComparison.changedPaths,
654
687
  reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`,
655
688
  });
656
689
  }
690
+ if (input.contentComparisonFailure !== undefined) {
691
+ return full(
692
+ `the rewritten-head tree snapshot comparison failed: ${input.contentComparisonFailure.slice(0, 2_048)}`,
693
+ );
694
+ }
657
695
  if (comparison === undefined) return full("the incremental head comparison was unavailable");
658
696
  if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
659
697
  return full("the prior reviewed head is not an ancestor of the current head");
@@ -553,17 +553,22 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
553
553
  const budget = yield* makeUsageBudget(options.limits ?? fanOutReviewBudgetLimits);
554
554
  const totalFiles = executionContext.totalFiles;
555
555
  const continuity = yield* resolveReviewContinuityContext();
556
+ const retryPasses =
557
+ executionContext.retryPasses ??
558
+ executionContext.retryStages.map((stage) => ({
559
+ stage,
560
+ paths: executionContext.retryPaths,
561
+ }));
556
562
  const pipeline = yield* runFanOutReview(binding, {
557
563
  files,
558
564
  anchorFiles,
559
565
  totalChangedFiles: totalFiles,
560
566
  maxFindings: options.maxFindings,
561
567
  budget: toRunBudgetHook(budget),
562
- ...(executionContext.retryPaths.length > 0
568
+ ...(retryPasses.length > 0
563
569
  ? {
564
570
  retry: {
565
- paths: executionContext.retryPaths,
566
- stages: executionContext.retryStages,
571
+ passes: retryPasses,
567
572
  },
568
573
  }
569
574
  : {}),