@effect-agent/pr-review 0.1.0-beta.22 → 0.1.0-beta.24
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.
- package/README.md +72 -48
- package/dist/action.d.mts +13 -13
- package/dist/action.mjs +42 -25
- package/dist/action.mjs.map +1 -1
- package/dist/cli.mjs +2 -2
- package/dist/{fan-out-n-00ppWr.d.mts → fan-out-Bi1v0VaU.d.mts} +314 -307
- package/dist/{github-BgtP7Rdv.mjs → github-C6jrBLA2.mjs} +763 -285
- package/dist/github-C6jrBLA2.mjs.map +1 -0
- package/dist/index.d.mts +84 -126
- package/dist/index.mjs +11 -11
- package/dist/index.mjs.map +1 -1
- package/dist/{providers-BguZK4B_.mjs → providers-2Jao2ZAX.mjs} +173 -585
- package/dist/providers-2Jao2ZAX.mjs.map +1 -0
- package/dist/testing.d.mts +6 -15
- package/dist/testing.mjs +14 -42
- package/dist/testing.mjs.map +1 -1
- package/package.json +2 -2
- package/src/action.ts +58 -34
- package/src/internal/action-entry.ts +0 -1
- package/src/internal/coverage.ts +147 -548
- package/src/internal/factory.ts +33 -54
- package/src/internal/fan-out-scripted.ts +26 -80
- package/src/internal/fan-out.ts +669 -432
- package/src/internal/fingerprint.ts +16 -10
- package/src/internal/fixtures.ts +6 -0
- package/src/internal/github.ts +36 -7
- package/src/internal/profiles.ts +8 -8
- package/src/internal/render.ts +34 -45
- package/src/internal/retirement.ts +3 -1
- package/src/internal/review-state.ts +204 -89
- package/src/internal/review-units.ts +25 -0
- package/src/internal/run.ts +231 -174
- package/src/internal/source.ts +1 -1
- package/dist/github-BgtP7Rdv.mjs.map +0 -1
- package/dist/providers-BguZK4B_.mjs.map +0 -1
package/src/internal/fan-out.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { Effect,
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
2
|
import {
|
|
3
3
|
Agent,
|
|
4
4
|
AgentPolicy,
|
|
5
|
-
|
|
6
|
-
SubagentPolicy,
|
|
7
|
-
SubagentRuntime,
|
|
8
|
-
ToolExecutionClass,
|
|
5
|
+
AgentRuntime,
|
|
9
6
|
ToolResultBounds,
|
|
7
|
+
type BudgetAdapterError,
|
|
8
|
+
type BudgetExceeded,
|
|
9
|
+
type RunBudgetHook,
|
|
10
10
|
type RuntimeBinding,
|
|
11
11
|
} from "effect-agent";
|
|
12
|
-
import {
|
|
12
|
+
import { Toolkit } from "effect/unstable/ai";
|
|
13
13
|
|
|
14
14
|
import { anchorViolation } from "./anchors.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { boundedListReason, FailedReviewPass, ReviewAssurance } from "./coverage.ts";
|
|
16
|
+
import { ChangedFileStatus, ChangedPath, type ChangedFile } from "./diff.ts";
|
|
16
17
|
import {
|
|
17
|
-
clampMaxFindings,
|
|
18
18
|
CodeReview,
|
|
19
19
|
fileReviewEvidenceChunks,
|
|
20
20
|
MAX_PATCH_CHARS,
|
|
@@ -22,32 +22,40 @@ import {
|
|
|
22
22
|
REVIEW_TOOL_RESULT_MAX_BYTES,
|
|
23
23
|
ReviewConcern,
|
|
24
24
|
ReviewFinding,
|
|
25
|
-
ReviewMission,
|
|
26
25
|
WalkthroughEntry,
|
|
27
26
|
} from "./review-agent.ts";
|
|
28
27
|
import {
|
|
28
|
+
MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS,
|
|
29
29
|
MAX_REVIEW_UNITS,
|
|
30
30
|
MAX_UNIT_EVIDENCE_SHARDS,
|
|
31
31
|
MAX_UNIT_FILES,
|
|
32
32
|
findingAnchorInUnitEvidence,
|
|
33
33
|
planReviewUnits,
|
|
34
|
+
rankAndDedupeConcerns,
|
|
35
|
+
rankAndDedupeFindings,
|
|
36
|
+
ReviewDiscoveryPass,
|
|
34
37
|
ReviewEvidenceShardId,
|
|
35
38
|
ReviewPassId,
|
|
36
39
|
ReviewRiskCategory,
|
|
40
|
+
ReviewUnit,
|
|
37
41
|
ReviewUnitId,
|
|
38
42
|
ReviewUnitPlan,
|
|
39
43
|
} from "./review-units.ts";
|
|
40
|
-
import { PullRequestSource, PullRequestSourceFailure } from "./source.ts";
|
|
41
44
|
|
|
42
45
|
// ---------------------------------------------------------------------------
|
|
43
46
|
// The assured fan-out reviewer is a bounded, deterministic three-stage
|
|
44
|
-
// pipeline
|
|
47
|
+
// pipeline scheduled ENTIRELY by host code:
|
|
45
48
|
//
|
|
46
49
|
// host plan -> independent discovery passes -> independent verification
|
|
47
50
|
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
+
// `planReviewUnits` is a pure function, so dispatch is plain Effect structured
|
|
52
|
+
// concurrency over its exact work list — there is no coordinator model, no
|
|
53
|
+
// delegation tool, and therefore no prompt-compliance failure mode. A pass
|
|
54
|
+
// that fails (child fault, malformed output) is retried once; a pass that
|
|
55
|
+
// still fails is recorded and its unit's paths are carried forward as
|
|
56
|
+
// retryable unreviewed scope instead of freezing the run's continuity
|
|
57
|
+
// baseline. A finding whose anchor is invalid is discarded and counted —
|
|
58
|
+
// never a reason to reject the whole pass.
|
|
51
59
|
// ---------------------------------------------------------------------------
|
|
52
60
|
|
|
53
61
|
/** One discovery pass returns at most this many anchored candidates. */
|
|
@@ -59,9 +67,16 @@ export const MAX_CHILD_CONCERNS = 3;
|
|
|
59
67
|
/** Every unit receives independent general and specialist discovery passes. */
|
|
60
68
|
export const MAX_UNIT_CANDIDATES = (MAX_CHILD_FINDINGS + MAX_CHILD_CONCERNS) * 2;
|
|
61
69
|
|
|
62
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* General + specialist discovery for every unit, then one verifier per unit.
|
|
72
|
+
* The one-retry budget doubles the worst-case child Run count, but the
|
|
73
|
+
* schedule itself never exceeds this bound.
|
|
74
|
+
*/
|
|
63
75
|
export const MAX_REVIEW_CHILDREN = MAX_REVIEW_UNITS * 3;
|
|
64
76
|
|
|
77
|
+
/** Bounded structured concurrency across units; passes inside a unit are sequential. */
|
|
78
|
+
export const REVIEW_UNIT_CONCURRENCY = 4;
|
|
79
|
+
|
|
65
80
|
/** Structural minimum for a child that exposes no tools. */
|
|
66
81
|
export const MAX_FILE_REVIEW_TOOL_CALLS = 1;
|
|
67
82
|
|
|
@@ -128,8 +143,8 @@ export class CandidateAssessment extends Schema.Class<CandidateAssessment>(
|
|
|
128
143
|
|
|
129
144
|
/**
|
|
130
145
|
* Exact suggestion settlement shape: a carried suggestion must be settled and
|
|
131
|
-
* nothing else may be.
|
|
132
|
-
* and
|
|
146
|
+
* nothing else may be. A verification report that violates it is treated as a
|
|
147
|
+
* misbehaving pass and retried within the pass budget.
|
|
133
148
|
*/
|
|
134
149
|
export const assessmentSettlesSuggestionExactly = (
|
|
135
150
|
assessment: CandidateAssessment,
|
|
@@ -181,21 +196,6 @@ const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
|
|
|
181
196
|
.check(Schema.isMinLength(1))
|
|
182
197
|
.check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS));
|
|
183
198
|
|
|
184
|
-
/** Strict-object coordinator request for either discovery or verification. */
|
|
185
|
-
export class FileReviewRequest extends Schema.Class<FileReviewRequest>(
|
|
186
|
-
"@effect-agent/pr-review/FileReviewRequest",
|
|
187
|
-
)({
|
|
188
|
-
phase: ReviewWorkPhase,
|
|
189
|
-
workId: ReviewPassId,
|
|
190
|
-
unitId: ReviewUnitId,
|
|
191
|
-
paths: UnitPaths,
|
|
192
|
-
evidenceShardIds: EvidenceShardIds,
|
|
193
|
-
perspective: ReviewWorkPerspective,
|
|
194
|
-
riskCategories: RiskCategories,
|
|
195
|
-
/** Empty for discovery; the exact discovered set for unit verification. */
|
|
196
|
-
candidates: Candidates,
|
|
197
|
-
}) {}
|
|
198
|
-
|
|
199
199
|
/** One complete host-selected evidence shard supplied to a review child. */
|
|
200
200
|
export class FileReviewEvidence extends Schema.Class<FileReviewEvidence>(
|
|
201
201
|
"@effect-agent/pr-review/FileReviewEvidence",
|
|
@@ -220,6 +220,7 @@ export class FileReviewBrief extends Schema.Class<FileReviewBrief>(
|
|
|
220
220
|
evidenceShardIds: EvidenceShardIds,
|
|
221
221
|
perspective: ReviewWorkPerspective,
|
|
222
222
|
riskCategories: RiskCategories,
|
|
223
|
+
/** Empty for discovery; the exact discovered set for unit verification. */
|
|
223
224
|
candidates: Candidates,
|
|
224
225
|
evidence: Schema.Array(FileReviewEvidence)
|
|
225
226
|
.check(Schema.isMinLength(1))
|
|
@@ -239,36 +240,20 @@ export class FileReviewReport extends Schema.Class<FileReviewReport>(
|
|
|
239
240
|
assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES)),
|
|
240
241
|
}) {}
|
|
241
242
|
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
fileSummaries: Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
|
|
251
|
-
assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES)),
|
|
252
|
-
}) {}
|
|
253
|
-
|
|
254
|
-
export class FileReviewUnitFailed extends Schema.TaggedError<FileReviewUnitFailed>()(
|
|
255
|
-
"FileReviewUnitFailed",
|
|
256
|
-
{
|
|
257
|
-
childErrorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
|
|
258
|
-
message: Schema.String.check(Schema.isMaxLength(400)),
|
|
259
|
-
},
|
|
260
|
-
) {}
|
|
261
|
-
|
|
262
|
-
export class FileReviewWorkRejected extends Schema.TaggedError<FileReviewWorkRejected>()(
|
|
263
|
-
"FileReviewWorkRejected",
|
|
243
|
+
/**
|
|
244
|
+
* A structurally valid child report that does not answer the scheduled pass:
|
|
245
|
+
* wrong identity, phase-inapplicable fields, or an inexact assessment set.
|
|
246
|
+
* Retried once like any other pass fault, because it is model misbehavior,
|
|
247
|
+
* not evidence about the code under review.
|
|
248
|
+
*/
|
|
249
|
+
export class ReviewPassMisbehaved extends Schema.TaggedError<ReviewPassMisbehaved>()(
|
|
250
|
+
"ReviewPassMisbehaved",
|
|
264
251
|
{
|
|
265
252
|
workId: ReviewPassId,
|
|
266
253
|
reason: Schema.NonEmptyString.check(Schema.isMaxLength(600)),
|
|
267
254
|
},
|
|
268
255
|
) {}
|
|
269
256
|
|
|
270
|
-
export const FileReviewFailure = Schema.Union([FileReviewUnitFailed, FileReviewWorkRejected]);
|
|
271
|
-
|
|
272
257
|
export interface FanOutInstructionOptions {
|
|
273
258
|
readonly guidance?: string | ReadonlyArray<string> | undefined;
|
|
274
259
|
}
|
|
@@ -327,9 +312,6 @@ export const fileReviewerInstructions = makeFileReviewerInstructions();
|
|
|
327
312
|
|
|
328
313
|
export const FileReviewToolkit = Toolkit.empty;
|
|
329
314
|
|
|
330
|
-
/** Compatibility export: the evidence-only child has no handler requirements. */
|
|
331
|
-
export const FileReviewToolkitLayer = Layer.empty;
|
|
332
|
-
|
|
333
315
|
export const defaultFileReviewerPolicy = AgentPolicy.make({
|
|
334
316
|
maxTurns: 6,
|
|
335
317
|
maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
|
|
@@ -344,121 +326,89 @@ export const defaultFileReviewerPolicy = AgentPolicy.make({
|
|
|
344
326
|
onExhaustion: "fail",
|
|
345
327
|
});
|
|
346
328
|
|
|
347
|
-
export const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
readonly _tag: string;
|
|
358
|
-
readonly message?: string;
|
|
359
|
-
}): FileReviewUnitFailed =>
|
|
360
|
-
FileReviewUnitFailed.make({
|
|
361
|
-
childErrorTag: failure._tag,
|
|
362
|
-
message: (failure.message ?? "").slice(0, 400),
|
|
329
|
+
export const makeFileReviewerDefinition = (options: FanOutInstructionOptions = {}) =>
|
|
330
|
+
Agent.define("pr-review-worker", {
|
|
331
|
+
input: FileReviewBrief,
|
|
332
|
+
output: FileReviewReport,
|
|
333
|
+
instructions: makeFileReviewerInstructions(options),
|
|
334
|
+
toolkit: FileReviewToolkit,
|
|
335
|
+
policy: defaultFileReviewerPolicy,
|
|
336
|
+
description:
|
|
337
|
+
"Perform one bounded discovery or independent candidate-verification pass over host-supplied pull-request evidence.",
|
|
338
|
+
metadata: { deploymentClass: "E", surface: "read-only", stage: "discovery-verification" },
|
|
363
339
|
});
|
|
364
340
|
|
|
365
|
-
const
|
|
366
|
-
left.length === right.length && left.every((value, index) => value === right[index]);
|
|
341
|
+
export const FileReviewer = makeFileReviewerDefinition();
|
|
367
342
|
|
|
368
|
-
|
|
369
|
-
|
|
343
|
+
/** The exact child binding shape the host pipeline schedules. */
|
|
344
|
+
export type FileReviewerBinding<Provider, ModelProvides, ModelRequires> = RuntimeBinding<
|
|
345
|
+
typeof FileReviewBrief,
|
|
346
|
+
typeof FileReviewReport,
|
|
347
|
+
ReturnType<typeof makeFileReviewerInstructions>,
|
|
348
|
+
Toolkit.Tools<typeof FileReviewToolkit>,
|
|
349
|
+
Provider,
|
|
350
|
+
ModelProvides,
|
|
351
|
+
ModelRequires
|
|
352
|
+
>;
|
|
370
353
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const source = yield* PullRequestSource;
|
|
375
|
-
const mapSourceFailure = (failure: PullRequestSourceFailure) =>
|
|
376
|
-
rejectWork(
|
|
377
|
-
request.workId,
|
|
378
|
-
`pull-request source ${failure.operation} failed: ${failure.reason}`.slice(0, 600),
|
|
379
|
-
);
|
|
380
|
-
const files = yield* source.changedFiles.pipe(Effect.mapError(mapSourceFailure));
|
|
381
|
-
const metadata = yield* source.metadata.pipe(Effect.mapError(mapSourceFailure));
|
|
382
|
-
const plan = planReviewUnits(files, { totalChangedFiles: metadata.totalChangedFiles });
|
|
383
|
-
const unit = plan.units.find((candidate) => candidate.unitId === request.unitId);
|
|
384
|
-
if (
|
|
385
|
-
unit === undefined ||
|
|
386
|
-
!sameStrings(request.paths, unit.paths) ||
|
|
387
|
-
!sameStrings(
|
|
388
|
-
request.evidenceShardIds,
|
|
389
|
-
unit.evidenceShards.map((shard) => shard.shardId),
|
|
390
|
-
)
|
|
391
|
-
) {
|
|
392
|
-
return yield* rejectWork(request.workId, "request does not match a host-planned unit");
|
|
393
|
-
}
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Host pipeline.
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
394
357
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
candidateIds.has(candidate.candidateId) ||
|
|
427
|
-
candidateSubjects.has(subjectKey) ||
|
|
428
|
-
candidate.unitId !== unit.unitId ||
|
|
429
|
-
candidate.evidencePaths.some((path) => !allowed.has(path)) ||
|
|
430
|
-
(candidate._tag === "FindingCandidate" && !allowed.has(candidate.finding.path))
|
|
431
|
-
) {
|
|
432
|
-
return yield* rejectWork(
|
|
433
|
-
request.workId,
|
|
434
|
-
"verification candidates are duplicated or outside the planned unit",
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
candidateIds.add(candidate.candidateId);
|
|
438
|
-
candidateSubjects.add(subjectKey);
|
|
358
|
+
/** Everything one settled fan-out pipeline run produced, before publication. */
|
|
359
|
+
export interface FanOutPipelineOutcome {
|
|
360
|
+
readonly review: CodeReview;
|
|
361
|
+
readonly assurance: ReviewAssurance;
|
|
362
|
+
readonly plan: ReviewUnitPlan;
|
|
363
|
+
/** Paths of units with an unsettled pass — retryable scope for the next run. */
|
|
364
|
+
readonly unreviewedPaths: ReadonlyArray<string>;
|
|
365
|
+
/** Failed stages paired with the leftover paths they still own. */
|
|
366
|
+
readonly unreviewedPasses: ReadonlyArray<{
|
|
367
|
+
readonly stage: FailedReviewPass["stage"];
|
|
368
|
+
readonly paths: ReadonlyArray<string>;
|
|
369
|
+
}>;
|
|
370
|
+
/** Total settled child turns across every scheduled pass. */
|
|
371
|
+
readonly turns: number;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface FanOutPipelineInput {
|
|
375
|
+
readonly files: ReadonlyArray<ChangedFile>;
|
|
376
|
+
readonly anchorFiles: ReadonlyArray<ChangedFile>;
|
|
377
|
+
readonly totalChangedFiles: number;
|
|
378
|
+
readonly maxFindings?: number | undefined;
|
|
379
|
+
/** Shared run budget observed by every child pass. */
|
|
380
|
+
readonly budget?: RunBudgetHook<BudgetExceeded | BudgetAdapterError> | undefined;
|
|
381
|
+
/**
|
|
382
|
+
* Unchanged leftover paths from a prior failed pass. Those units retry only
|
|
383
|
+
* the recorded stages — no second general discovery on files nobody touched.
|
|
384
|
+
*/
|
|
385
|
+
readonly retry?:
|
|
386
|
+
| {
|
|
387
|
+
readonly paths: ReadonlyArray<string>;
|
|
388
|
+
readonly stages: ReadonlyArray<FailedReviewPass["stage"]>;
|
|
439
389
|
}
|
|
440
|
-
|
|
390
|
+
| undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
|
|
441
394
|
|
|
395
|
+
/** Rebuild one unit's complete evidence from the same snapshot the plan used. */
|
|
396
|
+
const unitEvidence = (
|
|
397
|
+
unit: ReviewUnit,
|
|
398
|
+
files: ReadonlyArray<ChangedFile>,
|
|
399
|
+
): Effect.Effect<ReadonlyArray<FileReviewEvidence>> =>
|
|
400
|
+
Effect.gen(function* () {
|
|
442
401
|
const byPath = new Map(files.map((file) => [file.path, file] as const));
|
|
443
402
|
const evidence: Array<FileReviewEvidence> = [];
|
|
444
403
|
for (const shard of unit.evidenceShards) {
|
|
445
404
|
const file = byPath.get(shard.path);
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const chunk = chunks[shard.ordinal - 1];
|
|
454
|
-
if (
|
|
455
|
-
chunk === undefined ||
|
|
456
|
-
chunks.length !== shard.total ||
|
|
457
|
-
chunk.annotatedPatch.length !== shard.evidenceChars
|
|
458
|
-
) {
|
|
459
|
-
return yield* rejectWork(
|
|
460
|
-
request.workId,
|
|
461
|
-
`planned evidence shard no longer matches source: ${shard.shardId}`,
|
|
405
|
+
const chunk =
|
|
406
|
+
file === undefined ? undefined : fileReviewEvidenceChunks(file)[shard.ordinal - 1];
|
|
407
|
+
if (file === undefined || chunk === undefined) {
|
|
408
|
+
// The plan and this evidence derive from the same immutable snapshot
|
|
409
|
+
// via the same pure function; a mismatch is a host defect, not input.
|
|
410
|
+
return yield* Effect.die(
|
|
411
|
+
new Error(`planned evidence shard has no source: ${shard.shardId} (${shard.path})`),
|
|
462
412
|
);
|
|
463
413
|
}
|
|
464
414
|
evidence.push(
|
|
@@ -473,303 +423,590 @@ const prepareReviewBrief = (request: FileReviewRequest) =>
|
|
|
473
423
|
}),
|
|
474
424
|
);
|
|
475
425
|
}
|
|
476
|
-
return
|
|
426
|
+
return evidence;
|
|
477
427
|
});
|
|
478
428
|
|
|
479
|
-
|
|
429
|
+
interface SettledPass {
|
|
430
|
+
readonly report: FileReviewReport;
|
|
431
|
+
readonly turns: number;
|
|
432
|
+
}
|
|
480
433
|
|
|
481
|
-
|
|
434
|
+
type PassOutcome =
|
|
435
|
+
| ({ readonly _tag: "settled" } & SettledPass)
|
|
436
|
+
| { readonly _tag: "failed"; readonly errorTag: string };
|
|
437
|
+
|
|
438
|
+
const misbehaved = (workId: string, reason: string) =>
|
|
439
|
+
ReviewPassMisbehaved.make({ workId, reason: reason.slice(0, 600) });
|
|
440
|
+
|
|
441
|
+
/** Validate that a verification report assesses exactly the scheduled candidates. */
|
|
442
|
+
const validateVerificationReport = (
|
|
443
|
+
brief: FileReviewBrief,
|
|
482
444
|
report: FileReviewReport,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (context.budgetExhausted) {
|
|
487
|
-
return Effect.fail(
|
|
488
|
-
rejectWork(report.workId, "review work exhausted its budget before exact settlement"),
|
|
489
|
-
);
|
|
445
|
+
): ReviewPassMisbehaved | undefined => {
|
|
446
|
+
if (report.findings.length > 0 || report.concerns.length > 0 || report.fileSummaries.length > 0) {
|
|
447
|
+
return misbehaved(brief.workId, "verification output contained discovery-only fields");
|
|
490
448
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
) {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
449
|
+
const expectedById = new Map(
|
|
450
|
+
brief.candidates.map((candidate) => [candidate.candidateId, candidate] as const),
|
|
451
|
+
);
|
|
452
|
+
const assessedIds = new Set<string>();
|
|
453
|
+
for (const assessment of report.assessments) {
|
|
454
|
+
const candidate = expectedById.get(assessment.candidateId);
|
|
455
|
+
if (candidate === undefined || assessedIds.has(assessment.candidateId)) {
|
|
456
|
+
return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
|
|
457
|
+
}
|
|
458
|
+
if (!assessmentSettlesSuggestionExactly(assessment, candidate)) {
|
|
459
|
+
return misbehaved(
|
|
460
|
+
brief.workId,
|
|
461
|
+
"verification output did not settle suggestion publication exactly",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
assessedIds.add(assessment.candidateId);
|
|
499
465
|
}
|
|
500
|
-
if (
|
|
466
|
+
if (assessedIds.size !== expectedById.size) {
|
|
467
|
+
return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
|
|
468
|
+
}
|
|
469
|
+
return undefined;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Run one scheduled pass: execute the child, decode its report, and enforce
|
|
474
|
+
* the pass contract. Any typed fault — child failure, malformed or misdirected
|
|
475
|
+
* output — is retried once; budget exhaustion is terminal because a retry
|
|
476
|
+
* would fail the same way. The settled outcome is a value either way, so one
|
|
477
|
+
* flaky pass can never fail the whole pipeline.
|
|
478
|
+
*/
|
|
479
|
+
const runReviewPass = <Provider, ModelProvides, ModelRequires>(
|
|
480
|
+
binding: FileReviewerBinding<Provider, ModelProvides, ModelRequires>,
|
|
481
|
+
brief: FileReviewBrief,
|
|
482
|
+
budget: RunBudgetHook<BudgetExceeded | BudgetAdapterError> | undefined,
|
|
483
|
+
) =>
|
|
484
|
+
Effect.gen(function* () {
|
|
485
|
+
const result = yield* AgentRuntime.run(binding, brief, {
|
|
486
|
+
...(budget === undefined ? {} : { budget }),
|
|
487
|
+
estimateCostMicrousd: () => Effect.succeed(500),
|
|
488
|
+
});
|
|
489
|
+
const report = yield* Schema.decodeUnknownEffect(FileReviewReport)(result.output).pipe(
|
|
490
|
+
Effect.mapError((error) =>
|
|
491
|
+
misbehaved(brief.workId, `child report failed to decode: ${error.message}`),
|
|
492
|
+
),
|
|
493
|
+
);
|
|
501
494
|
if (
|
|
502
|
-
report.
|
|
503
|
-
report.
|
|
504
|
-
report.
|
|
495
|
+
report.phase !== brief.phase ||
|
|
496
|
+
report.workId !== brief.workId ||
|
|
497
|
+
report.unitId !== brief.unitId
|
|
505
498
|
) {
|
|
506
|
-
return
|
|
507
|
-
|
|
499
|
+
return yield* misbehaved(
|
|
500
|
+
brief.workId,
|
|
501
|
+
"child report identity does not match the scheduled pass",
|
|
508
502
|
);
|
|
509
503
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
504
|
+
if (brief.phase === "verification") {
|
|
505
|
+
const violation = validateVerificationReport(brief, report);
|
|
506
|
+
if (violation !== undefined) return yield* violation;
|
|
507
|
+
} else if (report.assessments.length > 0) {
|
|
508
|
+
return yield* misbehaved(
|
|
509
|
+
brief.workId,
|
|
510
|
+
"discovery output contained verification-only assessments",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
return { report, turns: result.turns } satisfies SettledPass;
|
|
514
|
+
}).pipe(
|
|
515
|
+
Effect.scoped,
|
|
516
|
+
Effect.retry({ times: 1, while: (error) => error._tag !== "BudgetExceeded" }),
|
|
517
|
+
Effect.map((settled): PassOutcome => ({ _tag: "settled", ...settled })),
|
|
518
|
+
Effect.catch((error) =>
|
|
519
|
+
Effect.succeed<PassOutcome>({ _tag: "failed", errorTag: String(error._tag).slice(0, 256) }),
|
|
520
|
+
),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
interface DiscoveryHarvest {
|
|
524
|
+
readonly candidates: ReadonlyArray<ReviewCandidate>;
|
|
525
|
+
readonly fileSummaries: ReadonlyArray<WalkthroughEntry>;
|
|
526
|
+
readonly discarded: number;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Keep only findings anchored inside the pass's exact assigned evidence and
|
|
531
|
+
* concerns bound to unit paths. Everything else is discarded and counted —
|
|
532
|
+
* an invalid anchor invalidates one claim, never the pass that produced it.
|
|
533
|
+
*/
|
|
534
|
+
const harvestDiscovery = (
|
|
535
|
+
pass: ReviewDiscoveryPass,
|
|
536
|
+
unit: ReviewUnit,
|
|
537
|
+
files: ReadonlyArray<ChangedFile>,
|
|
538
|
+
anchorFiles: ReadonlyArray<ChangedFile>,
|
|
539
|
+
report: FileReviewReport,
|
|
540
|
+
): DiscoveryHarvest => {
|
|
541
|
+
const allowed = new Set(pass.paths);
|
|
542
|
+
let discarded = 0;
|
|
543
|
+
const keptFindings: Array<ReviewFinding> = [];
|
|
544
|
+
for (const finding of report.findings) {
|
|
545
|
+
if (
|
|
546
|
+
!allowed.has(finding.path) ||
|
|
547
|
+
anchorViolation(finding, anchorFiles) !== undefined ||
|
|
548
|
+
!findingAnchorInUnitEvidence(finding, unit, files)
|
|
549
|
+
) {
|
|
550
|
+
discarded += 1;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
keptFindings.push(finding);
|
|
554
|
+
}
|
|
555
|
+
const keptConcerns: Array<DiscoveredConcern> = [];
|
|
556
|
+
for (const candidate of report.concerns) {
|
|
557
|
+
if (candidate.evidencePaths.some((path) => !allowed.has(path))) {
|
|
558
|
+
discarded += 1;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
keptConcerns.push(candidate);
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
candidates: [
|
|
565
|
+
...keptFindings.map((finding, index) =>
|
|
566
|
+
FindingCandidate.make({
|
|
567
|
+
candidateId: `${pass.passId}:finding:${candidateOrdinal(index)}`,
|
|
568
|
+
workId: pass.passId,
|
|
569
|
+
unitId: pass.unitId,
|
|
570
|
+
finding,
|
|
571
|
+
evidencePaths: [finding.path],
|
|
572
|
+
}),
|
|
573
|
+
),
|
|
574
|
+
...keptConcerns.map((candidate, index) =>
|
|
575
|
+
ConcernCandidate.make({
|
|
576
|
+
candidateId: `${pass.passId}:concern:${candidateOrdinal(index)}`,
|
|
577
|
+
workId: pass.passId,
|
|
578
|
+
unitId: pass.unitId,
|
|
579
|
+
concern: candidate.concern,
|
|
580
|
+
evidencePaths: candidate.evidencePaths,
|
|
581
|
+
}),
|
|
582
|
+
),
|
|
583
|
+
],
|
|
584
|
+
fileSummaries: report.fileSummaries.filter((entry) => allowed.has(entry.path)),
|
|
585
|
+
discarded,
|
|
586
|
+
};
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
interface UnitReviewOutcome {
|
|
590
|
+
readonly failedPasses: ReadonlyArray<FailedReviewPass>;
|
|
591
|
+
readonly discoveredCandidates: number;
|
|
592
|
+
readonly confirmed: ReadonlyArray<{
|
|
593
|
+
readonly assessment: CandidateAssessment;
|
|
594
|
+
readonly candidate: ReviewCandidate;
|
|
595
|
+
}>;
|
|
596
|
+
readonly rejectedCandidates: number;
|
|
597
|
+
readonly unsettledCandidates: number;
|
|
598
|
+
readonly discardedFindings: number;
|
|
599
|
+
readonly walkthrough: ReadonlyArray<WalkthroughEntry>;
|
|
600
|
+
readonly turns: number;
|
|
601
|
+
readonly completedGeneralPasses: number;
|
|
602
|
+
readonly completedSpecialistPasses: number;
|
|
603
|
+
readonly requiredVerificationPasses: number;
|
|
604
|
+
readonly completedVerificationPasses: number;
|
|
605
|
+
readonly unreviewedPaths: ReadonlyArray<string>;
|
|
606
|
+
readonly unreviewedPasses: ReadonlyArray<{
|
|
607
|
+
readonly stage: FailedReviewPass["stage"];
|
|
608
|
+
readonly paths: ReadonlyArray<string>;
|
|
609
|
+
}>;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const reviewUnit = <Provider, ModelProvides, ModelRequires>(
|
|
613
|
+
binding: FileReviewerBinding<Provider, ModelProvides, ModelRequires>,
|
|
614
|
+
unit: ReviewUnit,
|
|
615
|
+
passes: ReadonlyArray<ReviewDiscoveryPass>,
|
|
616
|
+
input: FanOutPipelineInput,
|
|
617
|
+
) =>
|
|
618
|
+
Effect.gen(function* () {
|
|
619
|
+
const evidence = yield* unitEvidence(unit, input.files);
|
|
620
|
+
const failedPasses: Array<FailedReviewPass> = [];
|
|
621
|
+
const candidates: Array<ReviewCandidate> = [];
|
|
622
|
+
const subjects = new Set<string>();
|
|
623
|
+
const walkthrough: Array<WalkthroughEntry> = [];
|
|
624
|
+
let discardedFindings = 0;
|
|
625
|
+
let turns = 0;
|
|
626
|
+
let completedGeneralPasses = 0;
|
|
627
|
+
let completedSpecialistPasses = 0;
|
|
628
|
+
for (const pass of passes) {
|
|
629
|
+
const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
|
|
630
|
+
const brief = FileReviewBrief.make({
|
|
631
|
+
phase: "discovery",
|
|
632
|
+
workId: pass.passId,
|
|
633
|
+
unitId: pass.unitId,
|
|
634
|
+
paths: pass.paths,
|
|
635
|
+
evidenceShardIds: pass.evidenceShardIds,
|
|
636
|
+
perspective: pass.perspective,
|
|
637
|
+
riskCategories: pass.riskCategories,
|
|
638
|
+
candidates: [],
|
|
639
|
+
evidence,
|
|
640
|
+
});
|
|
641
|
+
const outcome = yield* runReviewPass(binding, brief, input.budget);
|
|
642
|
+
if (outcome._tag === "failed") {
|
|
643
|
+
failedPasses.push(
|
|
644
|
+
FailedReviewPass.make({ workId: pass.passId, stage, errorTag: outcome.errorTag }),
|
|
519
645
|
);
|
|
646
|
+
continue;
|
|
520
647
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
648
|
+
turns += outcome.turns;
|
|
649
|
+
if (stage === "specialist") {
|
|
650
|
+
completedSpecialistPasses += 1;
|
|
651
|
+
} else {
|
|
652
|
+
completedGeneralPasses += 1;
|
|
653
|
+
}
|
|
654
|
+
const harvest = harvestDiscovery(pass, unit, input.files, input.anchorFiles, outcome.report);
|
|
655
|
+
discardedFindings += harvest.discarded;
|
|
656
|
+
if (pass.perspective === "general") walkthrough.push(...harvest.fileSummaries);
|
|
657
|
+
for (const candidate of harvest.candidates) {
|
|
658
|
+
const subject = reviewCandidateSubjectKey(candidate);
|
|
659
|
+
if (subjects.has(subject)) continue;
|
|
660
|
+
subjects.add(subject);
|
|
661
|
+
candidates.push(candidate);
|
|
528
662
|
}
|
|
529
|
-
assessedIds.add(assessment.candidateId);
|
|
530
663
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
664
|
+
const confirmed: Array<{
|
|
665
|
+
readonly assessment: CandidateAssessment;
|
|
666
|
+
readonly candidate: ReviewCandidate;
|
|
667
|
+
}> = [];
|
|
668
|
+
let rejectedCandidates = 0;
|
|
669
|
+
let unsettledCandidates = 0;
|
|
670
|
+
let completedVerificationPasses = 0;
|
|
671
|
+
const requiredVerificationPasses = candidates.length > 0 ? 1 : 0;
|
|
672
|
+
if (candidates.length > 0) {
|
|
673
|
+
const workId = `${unit.unitId}-verification`;
|
|
674
|
+
const brief = FileReviewBrief.make({
|
|
675
|
+
phase: "verification",
|
|
676
|
+
workId,
|
|
677
|
+
unitId: unit.unitId,
|
|
678
|
+
paths: unit.paths,
|
|
679
|
+
evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
|
|
680
|
+
perspective: "candidate-verification",
|
|
681
|
+
riskCategories: unit.riskCategories,
|
|
682
|
+
candidates,
|
|
683
|
+
evidence,
|
|
684
|
+
});
|
|
685
|
+
const outcome = yield* runReviewPass(binding, brief, input.budget);
|
|
686
|
+
if (outcome._tag === "failed") {
|
|
687
|
+
unsettledCandidates = candidates.length;
|
|
688
|
+
failedPasses.push(
|
|
689
|
+
FailedReviewPass.make({ workId, stage: "verification", errorTag: outcome.errorTag }),
|
|
690
|
+
);
|
|
691
|
+
} else {
|
|
692
|
+
turns += outcome.turns;
|
|
693
|
+
completedVerificationPasses = 1;
|
|
694
|
+
const byId = new Map(candidates.map((candidate) => [candidate.candidateId, candidate]));
|
|
695
|
+
for (const assessment of outcome.report.assessments) {
|
|
696
|
+
const candidate = byId.get(assessment.candidateId);
|
|
697
|
+
if (candidate === undefined) continue;
|
|
698
|
+
if (assessment.disposition === "confirmed") {
|
|
699
|
+
confirmed.push({ assessment, candidate });
|
|
700
|
+
} else {
|
|
701
|
+
rejectedCandidates += 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
535
705
|
}
|
|
536
|
-
return
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
706
|
+
return {
|
|
707
|
+
failedPasses,
|
|
708
|
+
discoveredCandidates: candidates.length,
|
|
709
|
+
confirmed,
|
|
710
|
+
rejectedCandidates,
|
|
711
|
+
unsettledCandidates,
|
|
712
|
+
discardedFindings,
|
|
713
|
+
walkthrough,
|
|
714
|
+
turns,
|
|
715
|
+
completedGeneralPasses,
|
|
716
|
+
completedSpecialistPasses,
|
|
717
|
+
requiredVerificationPasses,
|
|
718
|
+
completedVerificationPasses,
|
|
719
|
+
unreviewedPaths: failedPasses.length > 0 ? unit.paths : [],
|
|
720
|
+
unreviewedPasses: failedPasses.map((pass) => ({
|
|
721
|
+
stage: pass.stage,
|
|
722
|
+
paths: unit.paths,
|
|
723
|
+
})),
|
|
724
|
+
} satisfies UnitReviewOutcome;
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const countNoun = (count: number, noun: string): string =>
|
|
728
|
+
`${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
729
|
+
|
|
730
|
+
const composeSummary = (plan: ReviewUnitPlan, assurance: ReviewAssurance): string => {
|
|
731
|
+
const requiredDiscovery =
|
|
732
|
+
assurance.requiredGeneralDiscoveryPasses + assurance.requiredSpecialistPasses;
|
|
733
|
+
const completedDiscovery =
|
|
734
|
+
assurance.completedGeneralDiscoveryPasses + assurance.completedSpecialistPasses;
|
|
735
|
+
const parts = [
|
|
736
|
+
`Reviewed ${countNoun(plan.totalFiles, "changed file")} across ${countNoun(plan.units.length, "bounded unit")}: ${completedDiscovery}/${requiredDiscovery} discovery and ${assurance.completedVerificationPasses}/${assurance.requiredVerificationPasses} verification pass(es) settled; ${assurance.confirmedCandidates} of ${countNoun(assurance.discoveredCandidates, "discovered candidate")} confirmed by independent verification.`,
|
|
737
|
+
];
|
|
738
|
+
if (assurance.failedPasses.length > 0) {
|
|
739
|
+
parts.push(
|
|
740
|
+
`${countNoun(assurance.failedPasses.length, "pass")} did not settle; the affected paths are carried forward and retried on the next run. This is a reviewer-side gap, not a code defect.`,
|
|
545
741
|
);
|
|
546
742
|
}
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
743
|
+
if (assurance.discardedInvalidFindings > 0) {
|
|
744
|
+
parts.push(
|
|
745
|
+
`${countNoun(assurance.discardedInvalidFindings, "candidate")} discarded for anchors or paths outside the assigned evidence.`,
|
|
550
746
|
);
|
|
551
747
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
report.concerns.some((candidate) =>
|
|
556
|
-
candidate.evidencePaths.some((path) => !allowed.has(path)),
|
|
557
|
-
) ||
|
|
558
|
-
report.fileSummaries.some((entry) => !allowed.has(entry.path))
|
|
559
|
-
) {
|
|
560
|
-
return Effect.fail(
|
|
561
|
-
rejectWork(report.workId, "discovery output referenced evidence outside the scheduled unit"),
|
|
748
|
+
if (plan.undiffablePaths.length > 0) {
|
|
749
|
+
parts.push(
|
|
750
|
+
`${countNoun(plan.undiffablePaths.length, "path")} had no reviewable textual evidence and keep input coverage incomplete; exclude such paths with ignore globs when that is intended.`,
|
|
562
751
|
);
|
|
563
752
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
rejectWork(
|
|
568
|
-
request.workId,
|
|
569
|
-
`pull-request source ${failure.operation} failed: ${failure.reason}`.slice(0, 600),
|
|
570
|
-
);
|
|
571
|
-
const files = yield* source.changedFiles.pipe(Effect.mapError(mapSourceFailure));
|
|
572
|
-
const metadata = yield* source.metadata.pipe(Effect.mapError(mapSourceFailure));
|
|
573
|
-
const anchorFiles = yield* source.anchorFiles.pipe(Effect.mapError(mapSourceFailure));
|
|
574
|
-
const unit = planReviewUnits(files, {
|
|
575
|
-
totalChangedFiles: metadata.totalChangedFiles,
|
|
576
|
-
}).units.find((candidate) => candidate.unitId === request.unitId);
|
|
577
|
-
if (unit === undefined) {
|
|
578
|
-
return yield* rejectWork(request.workId, "scheduled review unit is no longer available");
|
|
579
|
-
}
|
|
580
|
-
for (const finding of report.findings) {
|
|
581
|
-
const violation = anchorViolation(finding, anchorFiles);
|
|
582
|
-
if (violation !== undefined || !findingAnchorInUnitEvidence(finding, unit, files)) {
|
|
583
|
-
return yield* rejectWork(
|
|
584
|
-
request.workId,
|
|
585
|
-
`discovery finding has no valid anchor in its assigned evidence: ${violation ?? finding.path}`,
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
const findingCandidates = report.findings.map((finding, index) =>
|
|
590
|
-
FindingCandidate.make({
|
|
591
|
-
candidateId: `${request.workId}:finding:${candidateOrdinal(index)}`,
|
|
592
|
-
workId: request.workId,
|
|
593
|
-
unitId: request.unitId,
|
|
594
|
-
finding,
|
|
595
|
-
evidencePaths: [finding.path],
|
|
596
|
-
}),
|
|
597
|
-
);
|
|
598
|
-
const concernCandidates = report.concerns.map((candidate, index) =>
|
|
599
|
-
ConcernCandidate.make({
|
|
600
|
-
candidateId: `${request.workId}:concern:${candidateOrdinal(index)}`,
|
|
601
|
-
workId: request.workId,
|
|
602
|
-
unitId: request.unitId,
|
|
603
|
-
concern: candidate.concern,
|
|
604
|
-
evidencePaths: candidate.evidencePaths,
|
|
605
|
-
}),
|
|
753
|
+
if (plan.unassignedPaths.length > 0 || plan.unassignedEvidenceShardCount > 0) {
|
|
754
|
+
parts.push(
|
|
755
|
+
"The changeset exceeded the bounded fan-out capacity; unassigned scope is reported under input coverage.",
|
|
606
756
|
);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
fileSummaries: report.fileSummaries,
|
|
613
|
-
assessments: [],
|
|
614
|
-
});
|
|
615
|
-
});
|
|
757
|
+
}
|
|
758
|
+
parts.push(
|
|
759
|
+
"No configured pipeline can prove absence of defects; this describes settled work only.",
|
|
760
|
+
);
|
|
761
|
+
return parts.join(" ").slice(0, 4_000);
|
|
616
762
|
};
|
|
617
763
|
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
)
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
parameters: ListReviewUnitsQuery,
|
|
644
|
-
success: ReviewUnitPlan,
|
|
645
|
-
failure: PullRequestSourceFailure,
|
|
646
|
-
failureMode: "error",
|
|
647
|
-
dependencies: [PullRequestSource],
|
|
648
|
-
}).annotate(ToolExecutionClass, "readonly");
|
|
649
|
-
|
|
650
|
-
export const FanOutCoordinatorToolkit = Toolkit.make(ListReviewUnits);
|
|
651
|
-
|
|
652
|
-
export const FanOutCoordinatorToolkitLayer = FanOutCoordinatorToolkit.toLayer({
|
|
653
|
-
list_review_units: () =>
|
|
654
|
-
Effect.gen(function* () {
|
|
655
|
-
const source = yield* PullRequestSource;
|
|
656
|
-
const files = yield* source.changedFiles;
|
|
657
|
-
const metadata = yield* source.metadata;
|
|
658
|
-
return planReviewUnits(files, { totalChangedFiles: metadata.totalChangedFiles });
|
|
764
|
+
const remapPlanUnitIds = (plan: ReviewUnitPlan, offset: number): ReviewUnitPlan => {
|
|
765
|
+
if (offset === 0) return plan;
|
|
766
|
+
const units = plan.units.map((unit, index) =>
|
|
767
|
+
ReviewUnit.make({
|
|
768
|
+
...unit,
|
|
769
|
+
unitId: `unit-${String(offset + index + 1).padStart(3, "0")}`,
|
|
770
|
+
}),
|
|
771
|
+
);
|
|
772
|
+
const mappedIds = new Map<string, string>();
|
|
773
|
+
for (const [index, unit] of plan.units.entries()) {
|
|
774
|
+
const remapped = units[index];
|
|
775
|
+
if (remapped !== undefined) {
|
|
776
|
+
mappedIds.set(unit.unitId, remapped.unitId);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return ReviewUnitPlan.make({
|
|
780
|
+
...plan,
|
|
781
|
+
units,
|
|
782
|
+
discoveryPasses: plan.discoveryPasses.map((pass) => {
|
|
783
|
+
const unitId = mappedIds.get(pass.unitId) ?? pass.unitId;
|
|
784
|
+
return ReviewDiscoveryPass.make({
|
|
785
|
+
...pass,
|
|
786
|
+
unitId,
|
|
787
|
+
passId: `${unitId}${pass.passId.slice(pass.unitId.length)}`,
|
|
788
|
+
});
|
|
659
789
|
}),
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
export const makeFanOutReviewInstructions =
|
|
663
|
-
(options: FanOutInstructionOptions & { readonly maxFindings?: number | undefined } = {}) =>
|
|
664
|
-
(mission: ReviewMission): string => {
|
|
665
|
-
const maxFindings = clampMaxFindings(options.maxFindings);
|
|
666
|
-
return [
|
|
667
|
-
`You coordinate the bounded multi-pass review of pull request #${mission.number} ("${mission.title}") in ${mission.repository}.`,
|
|
668
|
-
mission.body.length > 0 ? `Author description:\n${mission.body}` : "No author description.",
|
|
669
|
-
...staticGuidanceLines(options.guidance),
|
|
670
|
-
"1. Call list_review_units exactly once.",
|
|
671
|
-
'2. For EVERY discoveryPass, call delegate_file_review exactly once with phase "discovery", workId=passId, and the pass unitId/paths/evidenceShardIds/perspective/riskCategories verbatim; candidates must be []. Prefer one bounded parallel batch. Never retry.',
|
|
672
|
-
'3. Group candidates returned by all successful discovery passes by unit. Deterministically deduplicate byte-identical finding or concern payloads, retaining the first candidate in discoveryPass plan order. For every unit with at least one retained candidate, call delegate_file_review exactly once with phase "verification", workId "<unitId>-verification", perspective "candidate-verification", the unit paths/evidenceShardIds/riskCategories, and EVERY retained candidate copied byte-for-byte. Prefer one bounded parallel batch. Never retry.',
|
|
673
|
-
"4. Verification is authoritative: rejected candidates must not be reported. The host independently reconstructs publishable findings from exact confirmed assessments, so do not select, rewrite, downgrade, or invent findings.",
|
|
674
|
-
`5. Return ONLY CodeReview JSON. Write a concise summary of completed and failed stages. Set findings=[] and concerns=[]; the host injects exact confirmed candidates. Copy factual fileSummaries into walkthrough without invention. The host publication cap is ${maxFindings}.`,
|
|
675
|
-
"No configured pipeline can prove absence of defects. Describe settled work, never an exhaustive or defect-free review.",
|
|
676
|
-
].join("\n");
|
|
677
|
-
};
|
|
678
|
-
|
|
679
|
-
export const fanOutReviewInstructions = makeFanOutReviewInstructions();
|
|
680
|
-
|
|
681
|
-
export const defaultFanOutPolicy = AgentPolicy.make({
|
|
682
|
-
maxTurns: 7,
|
|
683
|
-
maxToolCalls: 1 + MAX_REVIEW_CHILDREN,
|
|
684
|
-
maxDuration: "20 minutes",
|
|
685
|
-
toolConcurrency: 4,
|
|
686
|
-
repeatedFailureLimit: 3,
|
|
687
|
-
tokenBudget: 400_000,
|
|
688
|
-
contextTokenLimit: 150_000,
|
|
689
|
-
// Coordinator exhaustion cannot become an assured result; exact stage
|
|
690
|
-
// settlement, not this final prose, determines assurance.
|
|
691
|
-
onExhaustion: "final-answer",
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
export interface FanOutReviewSuite {
|
|
695
|
-
readonly child: ReturnType<typeof makeFileReviewerDefinition>;
|
|
696
|
-
readonly parent: ReturnType<typeof makeFanOutReviewerDefinition>;
|
|
697
|
-
readonly delegation: ReturnType<typeof makeFileReviewDelegation>;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const makeFileReviewerDefinition = (options: FanOutInstructionOptions = {}) =>
|
|
701
|
-
Agent.define("pr-review-worker", {
|
|
702
|
-
input: FileReviewBrief,
|
|
703
|
-
output: FileReviewReport,
|
|
704
|
-
instructions: makeFileReviewerInstructions(options),
|
|
705
|
-
toolkit: FileReviewToolkit,
|
|
706
|
-
policy: defaultFileReviewerPolicy,
|
|
707
|
-
description:
|
|
708
|
-
"Perform one bounded discovery or independent candidate-verification pass over host-supplied pull-request evidence.",
|
|
709
|
-
metadata: { deploymentClass: "E", surface: "read-only", stage: "discovery-verification" },
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
const delegationToolFor = (delegation: ReturnType<typeof makeFileReviewDelegation>) =>
|
|
713
|
-
delegation.tool.annotate(ToolExecutionClass, "readonly");
|
|
714
|
-
|
|
715
|
-
const makeFanOutReviewerDefinition = (
|
|
716
|
-
options: FanOutInstructionOptions & { readonly maxFindings?: number | undefined },
|
|
717
|
-
delegation: ReturnType<typeof makeFileReviewDelegation>,
|
|
718
|
-
) =>
|
|
719
|
-
Agent.define("pr-fanout-reviewer", {
|
|
720
|
-
input: ReviewMission,
|
|
721
|
-
output: CodeReview,
|
|
722
|
-
instructions: makeFanOutReviewInstructions(options),
|
|
723
|
-
toolkit: Toolkit.make(ListReviewUnits, delegationToolFor(delegation)),
|
|
724
|
-
policy: defaultFanOutPolicy,
|
|
725
|
-
description:
|
|
726
|
-
"Coordinate deterministic general/specialist discovery and independent candidate verification over bounded review units.",
|
|
727
|
-
metadata: {
|
|
728
|
-
deploymentClass: "E",
|
|
729
|
-
surface: "read-only",
|
|
730
|
-
delegation: "S1-attached",
|
|
731
|
-
assurance: "multi-pass",
|
|
732
|
-
},
|
|
733
790
|
});
|
|
791
|
+
};
|
|
734
792
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
793
|
+
const scheduleFanOutWork = (
|
|
794
|
+
input: FanOutPipelineInput,
|
|
795
|
+
): {
|
|
796
|
+
readonly plan: ReviewUnitPlan;
|
|
797
|
+
readonly passesByUnit: Map<string, ReadonlyArray<ReviewDiscoveryPass>>;
|
|
798
|
+
readonly overflowRetryPaths: ReadonlyArray<string>;
|
|
799
|
+
} => {
|
|
800
|
+
const retryPathSet = new Set(input.retry?.paths ?? []);
|
|
801
|
+
const retryStages = new Set(input.retry?.stages ?? []);
|
|
802
|
+
if (retryPathSet.size === 0) {
|
|
803
|
+
const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
|
|
804
|
+
const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
|
|
805
|
+
for (const pass of plan.discoveryPasses) {
|
|
806
|
+
const passes = passesByUnit.get(pass.unitId) ?? [];
|
|
807
|
+
passes.push(pass);
|
|
808
|
+
passesByUnit.set(pass.unitId, passes);
|
|
809
|
+
}
|
|
810
|
+
return { plan, passesByUnit, overflowRetryPaths: [] };
|
|
811
|
+
}
|
|
812
|
+
const freshFiles = input.files.filter((file) => !retryPathSet.has(file.path));
|
|
813
|
+
const retryFiles = input.files.filter((file) => retryPathSet.has(file.path));
|
|
814
|
+
const freshPlan = planReviewUnits(freshFiles, { totalChangedFiles: input.totalChangedFiles });
|
|
815
|
+
const retryPlan = remapPlanUnitIds(
|
|
816
|
+
planReviewUnits(retryFiles, { totalChangedFiles: input.totalChangedFiles }),
|
|
817
|
+
freshPlan.units.length,
|
|
818
|
+
);
|
|
819
|
+
const acceptedFresh = freshPlan.units.slice(0, MAX_REVIEW_UNITS);
|
|
820
|
+
const acceptedRetry = retryPlan.units.slice(
|
|
821
|
+
0,
|
|
822
|
+
Math.max(0, MAX_REVIEW_UNITS - acceptedFresh.length),
|
|
823
|
+
);
|
|
824
|
+
const overflowRetryPaths = retryPlan.units
|
|
825
|
+
.slice(acceptedRetry.length)
|
|
826
|
+
.flatMap((unit) => [...unit.paths]);
|
|
827
|
+
const retryPassFilter = (pass: ReviewDiscoveryPass): boolean => {
|
|
828
|
+
const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
|
|
829
|
+
return retryStages.has(stage);
|
|
746
830
|
};
|
|
831
|
+
const acceptedRetryIds = new Set(acceptedRetry.map((unit) => unit.unitId));
|
|
832
|
+
let retryPasses = retryPlan.discoveryPasses.filter(
|
|
833
|
+
(pass) => acceptedRetryIds.has(pass.unitId) && retryPassFilter(pass),
|
|
834
|
+
);
|
|
835
|
+
if (
|
|
836
|
+
retryStages.has("verification") &&
|
|
837
|
+
!retryStages.has("discovery") &&
|
|
838
|
+
!retryStages.has("specialist")
|
|
839
|
+
) {
|
|
840
|
+
retryPasses = retryPlan.discoveryPasses.filter((pass) => acceptedRetryIds.has(pass.unitId));
|
|
841
|
+
}
|
|
842
|
+
const acceptedFreshIds = new Set(acceptedFresh.map((unit) => unit.unitId));
|
|
843
|
+
const freshPasses = freshPlan.discoveryPasses.filter((pass) => acceptedFreshIds.has(pass.unitId));
|
|
844
|
+
const discoveryPasses = [...freshPasses, ...retryPasses];
|
|
845
|
+
const plan = ReviewUnitPlan.make({
|
|
846
|
+
totalFiles: input.files.length,
|
|
847
|
+
truncated: freshPlan.truncated || retryPlan.truncated,
|
|
848
|
+
units: [...acceptedFresh, ...acceptedRetry],
|
|
849
|
+
discoveryPasses,
|
|
850
|
+
undiffablePaths: [
|
|
851
|
+
...new Set([...freshPlan.undiffablePaths, ...retryPlan.undiffablePaths]),
|
|
852
|
+
].sort(),
|
|
853
|
+
partialEvidencePaths: [
|
|
854
|
+
...new Set([...freshPlan.partialEvidencePaths, ...retryPlan.partialEvidencePaths]),
|
|
855
|
+
].sort(),
|
|
856
|
+
unassignedEvidenceShardCount:
|
|
857
|
+
freshPlan.unassignedEvidenceShardCount + retryPlan.unassignedEvidenceShardCount,
|
|
858
|
+
unassignedEvidenceShardIds: [
|
|
859
|
+
...freshPlan.unassignedEvidenceShardIds,
|
|
860
|
+
...retryPlan.unassignedEvidenceShardIds,
|
|
861
|
+
].slice(0, MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS),
|
|
862
|
+
unassignedPaths: [
|
|
863
|
+
...new Set([
|
|
864
|
+
...freshPlan.unassignedPaths,
|
|
865
|
+
...retryPlan.unassignedPaths,
|
|
866
|
+
...overflowRetryPaths,
|
|
867
|
+
]),
|
|
868
|
+
].sort(),
|
|
869
|
+
});
|
|
870
|
+
const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
|
|
871
|
+
for (const pass of discoveryPasses) {
|
|
872
|
+
const passes = passesByUnit.get(pass.unitId) ?? [];
|
|
873
|
+
passes.push(pass);
|
|
874
|
+
passesByUnit.set(pass.unitId, passes);
|
|
875
|
+
}
|
|
876
|
+
return { plan, passesByUnit, overflowRetryPaths };
|
|
747
877
|
};
|
|
748
878
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
export const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
(
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Run the complete host-scheduled fan-out pipeline over one selected
|
|
881
|
+
* changeset snapshot: plan, independent discovery, exact verification, and a
|
|
882
|
+
* deterministic host-composed CodeReview from verifier-confirmed candidates
|
|
883
|
+
* only. The verdict is derived from confirmed severities, never model prose.
|
|
884
|
+
*/
|
|
885
|
+
export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
|
|
886
|
+
binding: FileReviewerBinding<Provider, ModelProvides, ModelRequires>,
|
|
887
|
+
input: FanOutPipelineInput,
|
|
888
|
+
) =>
|
|
889
|
+
Effect.gen(function* () {
|
|
890
|
+
const { plan, passesByUnit, overflowRetryPaths } = scheduleFanOutWork(input);
|
|
891
|
+
const outcomes = yield* Effect.forEach(
|
|
892
|
+
plan.units,
|
|
893
|
+
(unit) => reviewUnit(binding, unit, passesByUnit.get(unit.unitId) ?? [], input),
|
|
894
|
+
{ concurrency: REVIEW_UNIT_CONCURRENCY },
|
|
895
|
+
);
|
|
896
|
+
const failedPasses = outcomes.flatMap((outcome) => outcome.failedPasses);
|
|
897
|
+
const unsettledCandidates = outcomes.reduce(
|
|
898
|
+
(total, outcome) => total + outcome.unsettledCandidates,
|
|
899
|
+
0,
|
|
900
|
+
);
|
|
901
|
+
const reasons: Array<string> = [];
|
|
902
|
+
if (failedPasses.length > 0) {
|
|
903
|
+
reasons.push(
|
|
904
|
+
boundedListReason(
|
|
905
|
+
"configured review passes did not settle",
|
|
906
|
+
failedPasses.map((pass) => `${pass.workId} (${pass.errorTag})`),
|
|
907
|
+
),
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (unsettledCandidates > 0) {
|
|
911
|
+
reasons.push(
|
|
912
|
+
`${unsettledCandidates} discovered candidate(s) did not receive exact verification`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
const requiredSpecialistPasses = plan.discoveryPasses.filter(
|
|
916
|
+
(pass) => pass.perspective === "risk-specialist",
|
|
917
|
+
).length;
|
|
918
|
+
const confirmed = outcomes.flatMap((outcome) => outcome.confirmed);
|
|
919
|
+
const assurance = ReviewAssurance.make({
|
|
920
|
+
status: reasons.length === 0 ? "settled" : "incomplete",
|
|
921
|
+
requiredGeneralDiscoveryPasses: plan.discoveryPasses.length - requiredSpecialistPasses,
|
|
922
|
+
completedGeneralDiscoveryPasses: outcomes.reduce(
|
|
923
|
+
(total, outcome) => total + outcome.completedGeneralPasses,
|
|
924
|
+
0,
|
|
925
|
+
),
|
|
926
|
+
requiredSpecialistPasses,
|
|
927
|
+
completedSpecialistPasses: outcomes.reduce(
|
|
928
|
+
(total, outcome) => total + outcome.completedSpecialistPasses,
|
|
929
|
+
0,
|
|
930
|
+
),
|
|
931
|
+
requiredVerificationPasses: outcomes.reduce(
|
|
932
|
+
(total, outcome) => total + outcome.requiredVerificationPasses,
|
|
933
|
+
0,
|
|
934
|
+
),
|
|
935
|
+
completedVerificationPasses: outcomes.reduce(
|
|
936
|
+
(total, outcome) => total + outcome.completedVerificationPasses,
|
|
937
|
+
0,
|
|
938
|
+
),
|
|
939
|
+
discoveredCandidates: outcomes.reduce(
|
|
940
|
+
(total, outcome) => total + outcome.discoveredCandidates,
|
|
941
|
+
0,
|
|
942
|
+
),
|
|
943
|
+
confirmedCandidates: confirmed.length,
|
|
944
|
+
rejectedCandidates: outcomes.reduce(
|
|
945
|
+
(total, outcome) => total + outcome.rejectedCandidates,
|
|
946
|
+
0,
|
|
947
|
+
),
|
|
948
|
+
unsettledCandidates,
|
|
949
|
+
discardedInvalidFindings: outcomes.reduce(
|
|
950
|
+
(total, outcome) => total + outcome.discardedFindings,
|
|
951
|
+
0,
|
|
952
|
+
),
|
|
953
|
+
failedPasses,
|
|
954
|
+
reasons,
|
|
773
955
|
});
|
|
774
|
-
|
|
775
|
-
|
|
956
|
+
const findings = rankAndDedupeFindings(
|
|
957
|
+
confirmed.flatMap(({ assessment, candidate }) =>
|
|
958
|
+
candidate._tag === "FindingCandidate"
|
|
959
|
+
? [confirmedFindingForPublication(assessment, candidate)]
|
|
960
|
+
: [],
|
|
961
|
+
),
|
|
962
|
+
);
|
|
963
|
+
const concerns = rankAndDedupeConcerns(
|
|
964
|
+
confirmed.flatMap(({ candidate }) =>
|
|
965
|
+
candidate._tag === "ConcernCandidate" ? [candidate.concern] : [],
|
|
966
|
+
),
|
|
967
|
+
);
|
|
968
|
+
const walkthrough = outcomes.flatMap((outcome) => outcome.walkthrough);
|
|
969
|
+
const blocking =
|
|
970
|
+
findings.some((finding) => finding.severity === "blocking") ||
|
|
971
|
+
concerns.some((concern) => concern.severity === "blocking");
|
|
972
|
+
const review = CodeReview.make({
|
|
973
|
+
summary: composeSummary(plan, assurance),
|
|
974
|
+
verdict: blocking
|
|
975
|
+
? "request-changes"
|
|
976
|
+
: findings.length > 0 || concerns.length > 0
|
|
977
|
+
? "comment"
|
|
978
|
+
: "approve",
|
|
979
|
+
findings,
|
|
980
|
+
...(concerns.length === 0 ? {} : { concerns }),
|
|
981
|
+
...(walkthrough.length === 0 ? {} : { walkthrough }),
|
|
982
|
+
});
|
|
983
|
+
return {
|
|
984
|
+
review,
|
|
985
|
+
assurance,
|
|
986
|
+
plan,
|
|
987
|
+
// Everything not fully reviewed this run and still part of the pull
|
|
988
|
+
// request carries forward, so the baseline can advance without ever
|
|
989
|
+
// moving unreviewed scope behind a green check: failed units retry,
|
|
990
|
+
// whole overflow files review in later installments, and partial or
|
|
991
|
+
// undiffable files keep the check fail-closed until they are reviewed,
|
|
992
|
+
// removed, or explicitly ignored.
|
|
993
|
+
unreviewedPaths: [
|
|
994
|
+
...new Set([
|
|
995
|
+
...outcomes.flatMap((outcome) => outcome.unreviewedPaths),
|
|
996
|
+
...plan.unassignedPaths,
|
|
997
|
+
...plan.partialEvidencePaths,
|
|
998
|
+
...plan.undiffablePaths,
|
|
999
|
+
]),
|
|
1000
|
+
].sort(),
|
|
1001
|
+
unreviewedPasses: [
|
|
1002
|
+
...outcomes.flatMap((outcome) => outcome.unreviewedPasses),
|
|
1003
|
+
...(overflowRetryPaths.length === 0
|
|
1004
|
+
? []
|
|
1005
|
+
: (input.retry?.stages.length
|
|
1006
|
+
? input.retry.stages
|
|
1007
|
+
: (["discovery", "specialist", "verification"] as const)
|
|
1008
|
+
).map((stage) => ({ stage, paths: overflowRetryPaths }))),
|
|
1009
|
+
],
|
|
1010
|
+
turns: outcomes.reduce((total, outcome) => total + outcome.turns, 0),
|
|
1011
|
+
} satisfies FanOutPipelineOutcome;
|
|
1012
|
+
});
|