pi-pr-review 1.17.0 → 1.17.2
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/CHANGELOG.md +14 -0
- package/README.md +5 -5
- package/extensions/pr-review-subagent.ts +56 -7
- package/extensions/review-table.ts +19 -11
- package/lib/pr-review-artifacts.ts +15 -0
- package/lib/pr-review-deadlines.ts +33 -0
- package/lib/pr-review-markdown.ts +3 -3
- package/lib/pr-review-publish.ts +19 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.17.2](https://github.com/10ego/pi-pr-review/compare/v1.17.1...v1.17.2) (2026-08-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **review:** keep degraded comments concise ([#126](https://github.com/10ego/pi-pr-review/issues/126)) ([912e474](https://github.com/10ego/pi-pr-review/commit/912e474d04be9db504d2ea83bcd9a7c86db2ca5b))
|
|
9
|
+
|
|
10
|
+
## [1.17.1](https://github.com/10ego/pi-pr-review/compare/v1.17.0...v1.17.1) (2026-08-31)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **review:** start batch deadline at reviewer dispatch ([#124](https://github.com/10ego/pi-pr-review/issues/124)) ([26a38af](https://github.com/10ego/pi-pr-review/commit/26a38af6559223c6403913de32026c7d2eee5d2b))
|
|
16
|
+
|
|
3
17
|
## [1.17.0](https://github.com/10ego/pi-pr-review/compare/v1.16.0...v1.17.0) (2026-08-29)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -191,13 +191,13 @@ Example:
|
|
|
191
191
|
}
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
Every invocation has a host-owned monotonic 15-minute hard cap, including the two GitHub identity/lifecycle preflights, synthesis, termination grace, and reserved cleanup. The dependent preflight calls share the one invocation budget, so they cannot each add an independent command timeout before review timing begins. Defaults bound light/medium/heavy attempts to 3/6/8 minutes, a fallback attempt to 3 minutes, and the concurrent batch to 12 minutes. The complete `deadlines` object may be replaced at user scope or by a trusted project; partial, malformed, non-integer, out-of-range, or internally inconsistent objects are rejected as a unit and the last valid/default finite budget remains active. Supported inclusive ranges are: attempts 30–720 seconds, fallback 30–360 seconds, batch 60–840 seconds, synthesis 10–120 seconds, total 120–1200 seconds, TERM grace 0.1–15 seconds, cleanup reserve 1–30 seconds, and minimum useful fallback 10–120 seconds. Minimum fallback must not exceed its attempt cap, and batch + synthesis + termination grace + cleanup must fit inside total.
|
|
194
|
+
Every invocation has a host-owned monotonic 15-minute hard cap, including the two GitHub identity/lifecycle preflights, parent orchestration, synthesis, termination grace, and reserved cleanup. The dependent preflight calls share the one invocation budget, so they cannot each add an independent command timeout before review timing begins. The reviewer batch window activates once, at the first reviewer dispatch: preflight and diff capture remain charged to the total cap but cannot exhaust `batchMs` before any reviewer starts. The activated batch is still truncated by the original total deadline and its synthesis/termination/cleanup reserves. Defaults bound light/medium/heavy attempts to 3/6/8 minutes, a fallback attempt to 3 minutes, and the concurrent batch to 12 minutes. The complete `deadlines` object may be replaced at user scope or by a trusted project; partial, malformed, non-integer, out-of-range, or internally inconsistent objects are rejected as a unit and the last valid/default finite budget remains active. Supported inclusive ranges are: attempts 30–720 seconds, fallback 30–360 seconds, batch 60–840 seconds, synthesis 10–120 seconds, total 120–1200 seconds, TERM grace 0.1–15 seconds, cleanup reserve 1–30 seconds, and minimum useful fallback 10–120 seconds. Minimum fallback must not exceed its attempt cap, and batch + synthesis + termination grace + cleanup must fit inside total.
|
|
195
195
|
|
|
196
196
|
A timed-out or retryable quota/rate-limit/capacity lane may start at most one configured fallback attempt. It starts only when at least `minimumFallbackMs` plus cleanup reserve remains; the host never changes the configured model, thinking level, or tool policy to save time. If a tier is unset, its existing nearest-configured-tier/Pi-default behavior is unchanged.
|
|
197
197
|
|
|
198
|
-
On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for the
|
|
198
|
+
On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for the concise body-only `COMMENT` publication path. A timed-out lane is never reported as `NO FINDINGS` or full coverage, and a lane ended by the host total or synthesis deadline is disclosed with that kind (`deadline_expired`) instead of being mistaken for its own attempt deadline expiring. Host lane artifacts are authoritative for completeness in both directions: a false assistant completion claim cannot upgrade incomplete lanes, and a paraphrased or omitted `Lane completeness` line cannot downgrade a host-complete batch away from the concise renderer.
|
|
199
199
|
|
|
200
|
-
Initial operating targets are ordinary-review p50 ≤ 6 minutes and p95 ≤ 12 minutes, and large-review p50 ≤ 10 minutes and p95 ≤ 14 minutes, with the 15-minute hard cap authoritative. Invocation telemetry starts before GitHub preflight and records configured deadline source/caps, termination grace, cleanup reserve, and active wall time; batch details record first event/output timing, lifecycle counts, configured and effective batch-truncated attempt deadlines, fallback starts/budget rejections, external total/synthesis deadline expiries per lane, and termination grace/escalation data. These are initial production targets, not a promise that every provider completes before its host deadline.
|
|
200
|
+
Initial operating targets are ordinary-review p50 ≤ 6 minutes and p95 ≤ 12 minutes, and large-review p50 ≤ 10 minutes and p95 ≤ 14 minutes, with the 15-minute hard cap authoritative. Invocation telemetry starts before GitHub preflight and records configured deadline source/caps, termination grace, cleanup reserve, and active wall time; batch details record invocation time consumed before each attempt, batch/total time remaining at dispatch, first event/output timing, lifecycle counts, configured and effective batch-truncated attempt deadlines, fallback starts/budget rejections, external total/synthesis deadline expiries per lane, and termination grace/escalation data. These are initial production targets, not a promise that every provider completes before its host deadline.
|
|
201
201
|
|
|
202
202
|
Ordinary reviewer output is reconstructed from every text part of the authoritative final assistant message in order. A zero process exit is not enough to mark a lane complete: completion also requires a terminal `stop` and the expected lane sections (or an explicitly emitted `NO FINDINGS.` where that response is allowed). Empty success is never synthesized into `NO FINDINGS.`; length-limited, malformed, timed-out, and failed attempts retain their raw text with an explicit lifecycle. Batch details include raw text, attempt artifacts, lifecycle counts and elapsed totals, while the displayed batch remains in deterministic input order.
|
|
203
203
|
|
|
@@ -227,11 +227,11 @@ Publishing is off by default.
|
|
|
227
227
|
|
|
228
228
|
The extension owns normal publishing. Before review execution it captures repository, hostname, PR number/title, reviewed head, lifecycle state, posting/stale authority, and invocation identity independently of assistant text. After synthesis, it caches one validated completed review (the host-owned canonical artifact) per repository and PR in the current Pi session. `autoPostReviews` and `--comment` publish that cached review after completion; `--no-comment` suppresses publication for the run.
|
|
229
229
|
|
|
230
|
-
Review semantics are Markdown-first. A deterministic tolerant parser normalizes line endings, extracts complete findings when possible, and ignores heading-like text inside CommonMark fenced-code and HTML-block contexts. Every degraded synthesis — ambiguous, partially parsed, incomplete lane coverage, or absent terminal synthesis —
|
|
230
|
+
Review semantics are Markdown-first. A deterministic tolerant parser normalizes line endings, extracts complete findings when possible, and ignores heading-like text inside CommonMark fenced-code and HTML-block contexts. Every degraded synthesis — ambiguous, partially parsed, incomplete lane coverage, or absent terminal synthesis — is retained internally as one deterministic host-rendered artifact with generic code-review labels: a `Coverage` section with the exact lane lifecycle disclosure, a `Findings` section with host-formatted parsed findings (never a clean-review claim when nothing parsed), and the complete original synthesis plus every retained lane artifact preserved under `Retained synthesis` / `Retained lane output`. GitHub publication never dumps that artifact: it emits a concise `COMMENT` with a short incomplete-coverage or unstructured-output warning, validated inline findings, and summary-only `Other Notes`. Reserved markers are sanitized, size limits are enforced, and the canonical marker is appended only by host code. Optional formatting repair is never required and can never suppress the degraded fallback.
|
|
231
231
|
|
|
232
232
|
You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “post it as an inline review,” or “publish the review for PR #123.” The extension handles that request directly before an agent turn. `/pr-review-publish` and a matching direct request publish only the cache; they never start or rerun review agents. Unnumbered direct requests select the latest cached review for the current repository. Only fresh interactive/RPC input can use the direct path.
|
|
233
233
|
|
|
234
|
-
Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Host lane evidence is authoritative for completeness: a paraphrased or omitted `All requested lanes completed.` disclosure cannot downgrade a host-complete batch, and a false disclosure cannot upgrade incomplete or missing lane coverage; the assistant's canonical line is consulted only when no batch evidence exists. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, incomplete or missing host lane coverage, malformed or unsafe output, and lane-fallback artifacts remain `COMMENT` reviews; their safely parsed findings keep inline placement, while unparsable output publishes body-only. Cache restore reclassifies persisted lane output and requires the exact host-recorded artifact key set, with every complete artifact matching the frozen invocation generation, tier, and minor-hygiene contract, and re-synthesizes the retained raw text under that frozen binding before retaining approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The concise top-level body starts with the verdict, points readers to inline findings, and places nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow under `Other Notes`; overview, verification, strengths, and transport diagnostics stay out of the public summary. The complete original Markdown
|
|
234
|
+
Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Host lane evidence is authoritative for completeness: a paraphrased or omitted `All requested lanes completed.` disclosure cannot downgrade a host-complete batch, and a false disclosure cannot upgrade incomplete or missing lane coverage; the assistant's canonical line is consulted only when no batch evidence exists. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, incomplete or missing host lane coverage, malformed or unsafe output, and lane-fallback artifacts remain `COMMENT` reviews; their safely parsed findings keep inline placement, while unparsable output publishes body-only. Cache restore reclassifies persisted lane output and requires the exact host-recorded artifact key set, with every complete artifact matching the frozen invocation generation, tier, and minor-hygiene contract, and re-synthesizes the retained raw text under that frozen binding before retaining approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The concise top-level body starts with the verdict, points readers to inline findings, and places nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow under `Other Notes`; overview, verification, strengths, and transport diagnostics stay out of the public summary. The complete original Markdown and retained lane evidence remain internal and are never substituted for a concise GitHub review, including degraded, incomplete, cached, direct, and stale `COMMENT` publication. If the concise body plus its canonical marker exceeds GitHub's limit, publication fails closed rather than dumping the full report. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
|
|
235
235
|
|
|
236
236
|
All publication paths apply host-enforced safety gates: captured posting authority, repository and requested-PR binding, reviewed/current-head and stale policy, bounded bodies and payloads, draft and lifecycle checks, non-open authorization, authenticated-identity same-head duplicate detection, and a final head check. Fully or partially parsed review paths additionally enforce safe inline locations, including degraded incomplete-lane reviews whose findings still parse. Raw and lane-assembled fallbacks without parsed findings are body-only; every degraded path is `COMMENT`-only, and assistant text cannot select event, commit, repository, hostname, API path, or inline anchors. Unknown or invalid host states fail closed before a write.
|
|
237
237
|
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
type ReviewLaneLifecycle,
|
|
57
57
|
} from "../lib/pr-review-artifacts.ts";
|
|
58
58
|
import { runWithConcurrency } from "../lib/pr-review-concurrency.ts";
|
|
59
|
-
import { attemptDeadline, fallbackBudget, type ReviewBudget } from "../lib/pr-review-deadlines.ts";
|
|
59
|
+
import { activateReviewBatch, attemptDeadline, fallbackBudget, type ReviewBudget } from "../lib/pr-review-deadlines.ts";
|
|
60
60
|
import { buildExtractionSystemPrompt, buildExtractionTask, MAX_EXTRACTION_OUTPUT_BYTES } from "../lib/pr-review-extract.ts";
|
|
61
61
|
import { loadReviewContext } from "../lib/pr-review-context.ts";
|
|
62
62
|
import {
|
|
@@ -910,6 +910,12 @@ interface ModelAttemptReport {
|
|
|
910
910
|
forcedTermination?: boolean;
|
|
911
911
|
deadlineMs?: number;
|
|
912
912
|
configuredDeadlineMs?: number;
|
|
913
|
+
/** Invocation time already consumed before this model attempt was scheduled. */
|
|
914
|
+
budgetElapsedBeforeAttemptMs?: number;
|
|
915
|
+
/** Remaining absolute reviewer-batch window at scheduling time; may be negative. */
|
|
916
|
+
batchRemainingBeforeAttemptMs?: number;
|
|
917
|
+
/** Remaining total invocation window at scheduling time. */
|
|
918
|
+
totalRemainingBeforeAttemptMs?: number;
|
|
913
919
|
}
|
|
914
920
|
|
|
915
921
|
interface SubagentPassResult {
|
|
@@ -986,16 +992,32 @@ async function runSubagentAttempt(
|
|
|
986
992
|
onText?: (text: string) => void,
|
|
987
993
|
beforeSpawn?: () => boolean,
|
|
988
994
|
budget?: ReviewBudget,
|
|
989
|
-
): Promise<{
|
|
995
|
+
): Promise<{
|
|
996
|
+
result: RunResult;
|
|
997
|
+
notice: string;
|
|
998
|
+
elapsedMs: number;
|
|
999
|
+
deadlineMs?: number;
|
|
1000
|
+
budgetElapsedBeforeAttemptMs?: number;
|
|
1001
|
+
batchRemainingBeforeAttemptMs?: number;
|
|
1002
|
+
totalRemainingBeforeAttemptMs?: number;
|
|
1003
|
+
}> {
|
|
990
1004
|
let tmp: { dir: string; filePath: string } | undefined;
|
|
991
1005
|
const startedAt = monotonicNow();
|
|
1006
|
+
const budgetElapsedBeforeAttemptMs = budget ? startedAt - budget.startedAtMs : undefined;
|
|
1007
|
+
const batchRemainingBeforeAttemptMs = budget ? budget.batchDeadlineMs - startedAt : undefined;
|
|
1008
|
+
const totalRemainingBeforeAttemptMs = budget ? budget.totalDeadlineMs - startedAt : undefined;
|
|
992
1009
|
const deadlineAtMs = budget
|
|
993
1010
|
? attemptDeadline(budget, pass.tier, attempt.kind === "fallback", () => startedAt)
|
|
994
1011
|
: undefined;
|
|
995
1012
|
const deadlineMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - startedAt);
|
|
996
1013
|
try {
|
|
997
1014
|
if (deadlineAtMs !== undefined && deadlineAtMs <= startedAt) {
|
|
998
|
-
throw new Error(
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`Review attempt timed out before it could start within the batch deadline ` +
|
|
1017
|
+
`(invocation elapsed ${Math.round(budgetElapsedBeforeAttemptMs!)}ms; ` +
|
|
1018
|
+
`batch remaining ${Math.round(batchRemainingBeforeAttemptMs!)}ms; ` +
|
|
1019
|
+
`total remaining ${Math.round(totalRemainingBeforeAttemptMs!)}ms).`,
|
|
1020
|
+
);
|
|
999
1021
|
}
|
|
1000
1022
|
const args = buildReviewBaseArgs();
|
|
1001
1023
|
if (attempt.spec) args.push("--model", attempt.spec);
|
|
@@ -1064,13 +1086,24 @@ async function runSubagentAttempt(
|
|
|
1064
1086
|
fileBackedAccessObserved = !!pass.fileBackedRequiredReads?.length &&
|
|
1065
1087
|
pass.fileBackedRequiredReads.every((range) => completedFileBackedReads.has(`${range.offset}:${range.limit}`));
|
|
1066
1088
|
result.fileBackedAccessObserved = fileBackedAccessObserved;
|
|
1067
|
-
return {
|
|
1089
|
+
return {
|
|
1090
|
+
result,
|
|
1091
|
+
notice: noticeForAttempt(pass.tier, attempt),
|
|
1092
|
+
elapsedMs: monotonicNow() - startedAt,
|
|
1093
|
+
deadlineMs,
|
|
1094
|
+
budgetElapsedBeforeAttemptMs,
|
|
1095
|
+
batchRemainingBeforeAttemptMs,
|
|
1096
|
+
totalRemainingBeforeAttemptMs,
|
|
1097
|
+
};
|
|
1068
1098
|
} catch (e) {
|
|
1069
1099
|
return {
|
|
1070
1100
|
result: { text: "", exitCode: 1, stderr: "", errorMessage: errMessage(e), toolElapsedMs: 0, toolCallCount: 0 },
|
|
1071
1101
|
notice: noticeForAttempt(pass.tier, attempt),
|
|
1072
1102
|
elapsedMs: monotonicNow() - startedAt,
|
|
1073
1103
|
deadlineMs,
|
|
1104
|
+
budgetElapsedBeforeAttemptMs,
|
|
1105
|
+
batchRemainingBeforeAttemptMs,
|
|
1106
|
+
totalRemainingBeforeAttemptMs,
|
|
1074
1107
|
};
|
|
1075
1108
|
} finally {
|
|
1076
1109
|
if (tmp) {
|
|
@@ -1219,6 +1252,9 @@ function retainPassArtifact(pass: SubagentPassRequest, result: SubagentPassResul
|
|
|
1219
1252
|
forcedTermination: attempt.forcedTermination,
|
|
1220
1253
|
deadlineMs: attempt.deadlineMs,
|
|
1221
1254
|
configuredDeadlineMs: attempt.configuredDeadlineMs,
|
|
1255
|
+
budgetElapsedBeforeAttemptMs: attempt.budgetElapsedBeforeAttemptMs,
|
|
1256
|
+
batchRemainingBeforeAttemptMs: attempt.batchRemainingBeforeAttemptMs,
|
|
1257
|
+
totalRemainingBeforeAttemptMs: attempt.totalRemainingBeforeAttemptMs,
|
|
1222
1258
|
})),
|
|
1223
1259
|
fallbackUsed: result.fallbackUsed,
|
|
1224
1260
|
elapsedMs: result.elapsedMs,
|
|
@@ -1263,7 +1299,15 @@ async function runSubagentPass(
|
|
|
1263
1299
|
fallbackBudgetRejected = true;
|
|
1264
1300
|
break;
|
|
1265
1301
|
}
|
|
1266
|
-
const {
|
|
1302
|
+
const {
|
|
1303
|
+
result,
|
|
1304
|
+
notice,
|
|
1305
|
+
elapsedMs,
|
|
1306
|
+
deadlineMs,
|
|
1307
|
+
budgetElapsedBeforeAttemptMs,
|
|
1308
|
+
batchRemainingBeforeAttemptMs,
|
|
1309
|
+
totalRemainingBeforeAttemptMs,
|
|
1310
|
+
} = await runSubagentAttempt(
|
|
1267
1311
|
config,
|
|
1268
1312
|
ctx,
|
|
1269
1313
|
pass,
|
|
@@ -1320,6 +1364,9 @@ async function runSubagentPass(
|
|
|
1320
1364
|
configuredDeadlineMs: budget
|
|
1321
1365
|
? (attempt.kind === "fallback" ? budget.config.fallbackAttemptMs : budget.config.attemptMs[tier])
|
|
1322
1366
|
: undefined,
|
|
1367
|
+
budgetElapsedBeforeAttemptMs,
|
|
1368
|
+
batchRemainingBeforeAttemptMs,
|
|
1369
|
+
totalRemainingBeforeAttemptMs,
|
|
1323
1370
|
});
|
|
1324
1371
|
|
|
1325
1372
|
if (lifecycle === "complete") {
|
|
@@ -1767,6 +1814,7 @@ export default function registerPrReviewSubagents(
|
|
|
1767
1814
|
});
|
|
1768
1815
|
const artifactPublisher = loopCoordinator.createArtifactPublisher(lease, ctx);
|
|
1769
1816
|
const config = loadConfig(ctx);
|
|
1817
|
+
const reviewBudget = lease.budget ? activateReviewBatch(lease.budget) : undefined;
|
|
1770
1818
|
const result = await runSubagentPass(
|
|
1771
1819
|
config,
|
|
1772
1820
|
ctx,
|
|
@@ -1785,7 +1833,7 @@ export default function registerPrReviewSubagents(
|
|
|
1785
1833
|
executionSignal,
|
|
1786
1834
|
(text) => onUpdate?.({ content: [{ type: "text", text }] }),
|
|
1787
1835
|
() => loopCoordinator.isLeaseActive(lease, ctx),
|
|
1788
|
-
|
|
1836
|
+
reviewBudget,
|
|
1789
1837
|
);
|
|
1790
1838
|
|
|
1791
1839
|
const warnings = [...thinkingWarnings(config, [tier]), ...(lease.budget?.warnings ?? [])];
|
|
@@ -1978,6 +2026,7 @@ export default function registerPrReviewSubagents(
|
|
|
1978
2026
|
// reviewer may run concurrently, and no caller/model tuning knob exists.
|
|
1979
2027
|
const reviewerConcurrency = passes.length;
|
|
1980
2028
|
const config = loadConfig(ctx);
|
|
2029
|
+
const reviewBudget = lease.budget ? activateReviewBatch(lease.budget) : undefined;
|
|
1981
2030
|
const batchStartedAt = monotonicNow();
|
|
1982
2031
|
|
|
1983
2032
|
const tierPriority: Record<Tier, number> = { heavy: 0, medium: 1, light: 2 };
|
|
@@ -1993,7 +2042,7 @@ export default function registerPrReviewSubagents(
|
|
|
1993
2042
|
executionSignal,
|
|
1994
2043
|
undefined,
|
|
1995
2044
|
() => loopCoordinator.isLeaseActive(lease, ctx),
|
|
1996
|
-
|
|
2045
|
+
reviewBudget,
|
|
1997
2046
|
);
|
|
1998
2047
|
const endOffsetMs = monotonicNow() - batchStartedAt;
|
|
1999
2048
|
result.startOffsetMs = startOffsetMs;
|
|
@@ -432,6 +432,16 @@ type ReviewPublicationOrigin =
|
|
|
432
432
|
| { readonly kind: "publish-command"; readonly stalePolicy: "frozen" | "allow-stale" }
|
|
433
433
|
| { readonly kind: "direct-request" };
|
|
434
434
|
|
|
435
|
+
function conciseDegradedPublicationNotice(record: CompletedReviewRecord): string | undefined {
|
|
436
|
+
if (record.completeness === "incomplete") {
|
|
437
|
+
return "> [!WARNING]\n> Review coverage was incomplete. This COMMENT is not evidence of a clean review.";
|
|
438
|
+
}
|
|
439
|
+
if (record.synthesisQuality !== undefined && record.synthesisQuality !== "fully_parsed") {
|
|
440
|
+
return "> [!WARNING]\n> The review output was not fully structured. This COMMENT is not evidence of a clean review.";
|
|
441
|
+
}
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
|
|
435
445
|
async function publishCompletedReview(
|
|
436
446
|
record: CompletedReviewRecord,
|
|
437
447
|
origin: ReviewPublicationOrigin,
|
|
@@ -459,8 +469,9 @@ async function publishCompletedReview(
|
|
|
459
469
|
ctx.ui.notify("PR review was not posted: cached review artifact is missing pr.head_sha", "error");
|
|
460
470
|
return undefined;
|
|
461
471
|
}
|
|
462
|
-
const
|
|
463
|
-
(record.synthesisQuality !==
|
|
472
|
+
const degradedPublication = record.completeness === "incomplete" ||
|
|
473
|
+
(record.synthesisQuality !== undefined && record.synthesisQuality !== "fully_parsed");
|
|
474
|
+
const publicationNotice = conciseDegradedPublicationNotice(record);
|
|
464
475
|
const result = await publishPullReview({
|
|
465
476
|
cwd: ctx.cwd,
|
|
466
477
|
prNumber: record.invocation.prNumber,
|
|
@@ -471,21 +482,18 @@ async function publishCompletedReview(
|
|
|
471
482
|
approveMaxPriorityLevel: record.invocation.approveMaxPriorityLevel,
|
|
472
483
|
expectedRepository: record.repository,
|
|
473
484
|
review: record.review,
|
|
474
|
-
...(
|
|
475
|
-
//
|
|
476
|
-
// inline findings.
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
// still earn inline placement; only unparsable output is forced body-only.
|
|
485
|
+
...(publicationNotice ? { publicationNotice } : {}),
|
|
486
|
+
// Every review publishes only the concise host-rendered body plus validated
|
|
487
|
+
// inline findings. Retained raw synthesis and lane evidence remain private
|
|
488
|
+
// completion diagnostics, including for degraded/incomplete COMMENT runs.
|
|
489
|
+
// If the concise payload cannot fit, publication fails closed.
|
|
480
490
|
forceBodyOnly: record.synthesisQuality !== undefined &&
|
|
481
491
|
record.synthesisQuality !== "fully_parsed" &&
|
|
482
492
|
!(Array.isArray(record.review.findings) && record.review.findings.length > 0),
|
|
483
493
|
// A publication body identifies Markdown-derived or degraded synthesis.
|
|
484
494
|
// Enforce COMMENT again at the final publication boundary so restored
|
|
485
495
|
// canonical artifacts created by an older parser cannot inherit APPROVE.
|
|
486
|
-
forceComment: record.mergeApprovalEligible === false ||
|
|
487
|
-
(record.synthesisQuality !== undefined &&
|
|
488
|
-
(record.synthesisQuality !== "fully_parsed" || record.completeness === "incomplete")),
|
|
496
|
+
forceComment: record.mergeApprovalEligible === false || degradedPublication,
|
|
489
497
|
});
|
|
490
498
|
notifyPublishResult(result, source, ctx);
|
|
491
499
|
return result;
|
|
@@ -29,6 +29,12 @@ export interface ReviewLaneAttemptArtifact {
|
|
|
29
29
|
readonly deadlineMs?: number;
|
|
30
30
|
/** Configured tier/fallback cap before batch/total truncation. */
|
|
31
31
|
readonly configuredDeadlineMs?: number;
|
|
32
|
+
/** Invocation time already consumed before this attempt was scheduled. */
|
|
33
|
+
readonly budgetElapsedBeforeAttemptMs?: number;
|
|
34
|
+
/** Remaining reviewer-batch window at scheduling time; negative means dispatch was already late. */
|
|
35
|
+
readonly batchRemainingBeforeAttemptMs?: number;
|
|
36
|
+
/** Remaining total invocation window at scheduling time. */
|
|
37
|
+
readonly totalRemainingBeforeAttemptMs?: number;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface ExpectedReviewLane {
|
|
@@ -676,6 +682,9 @@ function attemptArtifactSnapshot(value: unknown): ReviewLaneAttemptArtifact | un
|
|
|
676
682
|
const forcedTermination = source.forcedTermination;
|
|
677
683
|
const deadlineMs = source.deadlineMs;
|
|
678
684
|
const configuredDeadlineMs = source.configuredDeadlineMs;
|
|
685
|
+
const budgetElapsedBeforeAttemptMs = source.budgetElapsedBeforeAttemptMs;
|
|
686
|
+
const batchRemainingBeforeAttemptMs = source.batchRemainingBeforeAttemptMs;
|
|
687
|
+
const totalRemainingBeforeAttemptMs = source.totalRemainingBeforeAttemptMs;
|
|
679
688
|
// --- Validation phase (locals only). ---
|
|
680
689
|
if (!isInteger(ordinal)) return undefined;
|
|
681
690
|
if (!isString(rawText)) return undefined;
|
|
@@ -700,6 +709,9 @@ function attemptArtifactSnapshot(value: unknown): ReviewLaneAttemptArtifact | un
|
|
|
700
709
|
if (!optional(forcedTermination, isBoolean)) return undefined;
|
|
701
710
|
if (!optional(deadlineMs, isFiniteNumber)) return undefined;
|
|
702
711
|
if (!optional(configuredDeadlineMs, isFiniteNumber)) return undefined;
|
|
712
|
+
if (!optional(budgetElapsedBeforeAttemptMs, isFiniteNumber)) return undefined;
|
|
713
|
+
if (!optional(batchRemainingBeforeAttemptMs, isFiniteNumber)) return undefined;
|
|
714
|
+
if (!optional(totalRemainingBeforeAttemptMs, isFiniteNumber)) return undefined;
|
|
703
715
|
// --- Snapshot phase. ---
|
|
704
716
|
const snapshot: Record<string, unknown> = {
|
|
705
717
|
ordinal,
|
|
@@ -726,6 +738,9 @@ function attemptArtifactSnapshot(value: unknown): ReviewLaneAttemptArtifact | un
|
|
|
726
738
|
withOptional(snapshot, "forcedTermination", forcedTermination);
|
|
727
739
|
withOptional(snapshot, "deadlineMs", deadlineMs);
|
|
728
740
|
withOptional(snapshot, "configuredDeadlineMs", configuredDeadlineMs);
|
|
741
|
+
withOptional(snapshot, "budgetElapsedBeforeAttemptMs", budgetElapsedBeforeAttemptMs);
|
|
742
|
+
withOptional(snapshot, "batchRemainingBeforeAttemptMs", batchRemainingBeforeAttemptMs);
|
|
743
|
+
withOptional(snapshot, "totalRemainingBeforeAttemptMs", totalRemainingBeforeAttemptMs);
|
|
729
744
|
return Object.freeze(snapshot) as ReviewLaneAttemptArtifact;
|
|
730
745
|
} catch {
|
|
731
746
|
// A throwing getter is a malformed attempt, never a crash at this boundary.
|
|
@@ -105,6 +105,8 @@ export function resolveReviewDeadlines(user: unknown, project?: unknown): Deadli
|
|
|
105
105
|
|
|
106
106
|
export interface ReviewBudget {
|
|
107
107
|
readonly startedAtMs: number;
|
|
108
|
+
/** First reviewer dispatch time. Undefined until the batch window is activated. */
|
|
109
|
+
readonly batchStartedAtMs?: number;
|
|
108
110
|
readonly totalDeadlineMs: number;
|
|
109
111
|
readonly batchDeadlineMs: number;
|
|
110
112
|
readonly config: ReviewDeadlineConfig;
|
|
@@ -130,6 +132,37 @@ export function createReviewBudget(
|
|
|
130
132
|
});
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
const activatedBatchBudgets = new WeakMap<ReviewBudget, ReviewBudget>();
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start the reviewer-batch window once, at the first reviewer dispatch.
|
|
139
|
+
*
|
|
140
|
+
* PR binding, diff capture, and parent orchestration remain charged to the total
|
|
141
|
+
* invocation deadline, but no longer consume `batchMs` before a reviewer exists.
|
|
142
|
+
* The original synthesis and termination reserves remain hard upper bounds.
|
|
143
|
+
*/
|
|
144
|
+
export function activateReviewBatch(
|
|
145
|
+
budget: ReviewBudget,
|
|
146
|
+
now: MonotonicNow = monotonicNow,
|
|
147
|
+
): ReviewBudget {
|
|
148
|
+
if (budget.batchStartedAtMs !== undefined) return budget;
|
|
149
|
+
const existing = activatedBatchBudgets.get(budget);
|
|
150
|
+
if (existing) return existing;
|
|
151
|
+
const batchStartedAtMs = now();
|
|
152
|
+
const terminationReserveMs = budget.config.terminationGraceMs + budget.config.cleanupReserveMs;
|
|
153
|
+
const batchDeadlineMs = Math.min(
|
|
154
|
+
batchStartedAtMs + budget.config.batchMs,
|
|
155
|
+
budget.totalDeadlineMs - budget.config.synthesisMs - terminationReserveMs,
|
|
156
|
+
);
|
|
157
|
+
const activated = Object.freeze({
|
|
158
|
+
...budget,
|
|
159
|
+
batchStartedAtMs,
|
|
160
|
+
batchDeadlineMs,
|
|
161
|
+
});
|
|
162
|
+
activatedBatchBudgets.set(budget, activated);
|
|
163
|
+
return activated;
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
export function attemptDeadline(
|
|
134
167
|
budget: ReviewBudget,
|
|
135
168
|
tier: ReviewTier,
|
|
@@ -840,9 +840,9 @@ export function synthesizeReviewArtifact(input: {
|
|
|
840
840
|
}
|
|
841
841
|
return reasons.length > 0 ? reasons : ["terminal synthesis was not structurally parseable; preserved as body-only Markdown"];
|
|
842
842
|
})();
|
|
843
|
-
// Markdown is the durable semantic product.
|
|
844
|
-
//
|
|
845
|
-
//
|
|
843
|
+
// Markdown is the durable semantic product. Keep the complete deterministic
|
|
844
|
+
// body for local rendering, cache diagnostics, and extraction. GitHub
|
|
845
|
+
// publication independently renders a concise host summary for every quality.
|
|
846
846
|
const body = quality === "fully_parsed"
|
|
847
847
|
? safeReviewBody(raw)
|
|
848
848
|
: buildDegradedReviewBody({
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -1257,7 +1257,11 @@ function findingAnchor(finding: ReviewFindingLike): string | undefined {
|
|
|
1257
1257
|
return `${path}:${side}:${start}:${end}`;
|
|
1258
1258
|
}
|
|
1259
1259
|
|
|
1260
|
-
export function buildReviewSummary(
|
|
1260
|
+
export function buildReviewSummary(
|
|
1261
|
+
review: ReviewLike,
|
|
1262
|
+
inlineComments: PublishComment[] = [],
|
|
1263
|
+
publicationNotice?: string,
|
|
1264
|
+
): string {
|
|
1261
1265
|
const findings = Array.isArray(review.findings) ? review.findings : [];
|
|
1262
1266
|
const inlineAnchors = new Map<string, number>();
|
|
1263
1267
|
for (const comment of inlineComments) {
|
|
@@ -1281,6 +1285,7 @@ export function buildReviewSummary(review: ReviewLike, inlineComments: PublishCo
|
|
|
1281
1285
|
? "Approve"
|
|
1282
1286
|
: "Comment";
|
|
1283
1287
|
const lines = [`**Verdict:** ${verdict}`];
|
|
1288
|
+
if (publicationNotice?.trim()) lines.push("", publicationNotice.trim());
|
|
1284
1289
|
if (inlineComments.length > 0) {
|
|
1285
1290
|
lines.push("", "See the inline review comments for the primary findings.");
|
|
1286
1291
|
}
|
|
@@ -1503,6 +1508,7 @@ function buildLosslessReviewPayload(input: {
|
|
|
1503
1508
|
changedFiles?: readonly ChangedFileLike[];
|
|
1504
1509
|
bodyPreamble?: string;
|
|
1505
1510
|
bodyOverride?: string;
|
|
1511
|
+
publicationNotice?: string;
|
|
1506
1512
|
diagnostics?: readonly string[];
|
|
1507
1513
|
event?: ReviewEventType;
|
|
1508
1514
|
}): { payload?: PullReviewPayload; diagnostics: string[]; errors: string[] } {
|
|
@@ -1520,6 +1526,9 @@ function buildLosslessReviewPayload(input: {
|
|
|
1520
1526
|
if (input.bodyPreamble && containsReservedReviewMarker(input.bodyPreamble)) {
|
|
1521
1527
|
return { diagnostics, errors: ["publication preamble contains a reserved pi-pr-review marker"] };
|
|
1522
1528
|
}
|
|
1529
|
+
if (input.publicationNotice && containsReservedReviewMarker(input.publicationNotice)) {
|
|
1530
|
+
return { diagnostics, errors: ["publication notice contains a reserved pi-pr-review marker"] };
|
|
1531
|
+
}
|
|
1523
1532
|
const selected = input.allowInlineComments
|
|
1524
1533
|
? selectInlineComments(input.review, input.changedFiles ?? [])
|
|
1525
1534
|
: { comments: [], diagnostics: [], errors: [] };
|
|
@@ -1529,7 +1538,10 @@ function buildLosslessReviewPayload(input: {
|
|
|
1529
1538
|
// Inline-placement diagnostics remain available to the host notification,
|
|
1530
1539
|
// while every affected finding is retained under Other Notes. Do not expose
|
|
1531
1540
|
// transport diagnostics as if they were review findings.
|
|
1532
|
-
let content = input.bodyOverride?.trim() || buildReviewSummary(input.review, selected.comments);
|
|
1541
|
+
let content = input.bodyOverride?.trim() || buildReviewSummary(input.review, selected.comments, input.publicationNotice);
|
|
1542
|
+
if (input.bodyOverride?.trim() && input.publicationNotice?.trim()) {
|
|
1543
|
+
content = `${content}\n\n${input.publicationNotice.trim()}`;
|
|
1544
|
+
}
|
|
1533
1545
|
if (input.bodyPreamble?.trim()) content = `${input.bodyPreamble.trim()}\n\n${content}`;
|
|
1534
1546
|
const marker = canonicalReviewMarker(markerHeadSha);
|
|
1535
1547
|
const bodyError = validateReviewBody(content);
|
|
@@ -2148,8 +2160,10 @@ export async function publishPullReview(input: {
|
|
|
2148
2160
|
approveMaxPriorityLevel?: ApproveMaxPriorityLevel;
|
|
2149
2161
|
expectedRepository?: RepositoryBinding;
|
|
2150
2162
|
review: ReviewLike;
|
|
2151
|
-
/**
|
|
2163
|
+
/** Compatibility-only body override. The extension lifecycle keeps retained synthesis private. */
|
|
2152
2164
|
publicationBody?: string;
|
|
2165
|
+
/** Short host-owned warning included in the concise summary. */
|
|
2166
|
+
publicationNotice?: string;
|
|
2153
2167
|
/** Prevent uncertain/raw synthesis from selecting APPROVE or inline anchors. */
|
|
2154
2168
|
forceBodyOnly?: boolean;
|
|
2155
2169
|
/** Prevent partially trusted synthesis from selecting a merge-relevant event. */
|
|
@@ -2166,6 +2180,7 @@ export async function publishPullReview(input: {
|
|
|
2166
2180
|
expectedRepository,
|
|
2167
2181
|
review,
|
|
2168
2182
|
publicationBody,
|
|
2183
|
+
publicationNotice,
|
|
2169
2184
|
forceBodyOnly = false,
|
|
2170
2185
|
forceComment = false,
|
|
2171
2186
|
} = input;
|
|
@@ -2266,6 +2281,7 @@ export async function publishPullReview(input: {
|
|
|
2266
2281
|
allowInlineComments,
|
|
2267
2282
|
changedFiles,
|
|
2268
2283
|
...(publicationBody ? { bodyOverride: publicationBody } : {}),
|
|
2284
|
+
...(publicationNotice ? { publicationNotice } : {}),
|
|
2269
2285
|
...(isApprove ? { event: APPROVE_EVENT } : {}),
|
|
2270
2286
|
...(changedFileLookupFailed ? { diagnostics: [CHANGED_FILE_LOOKUP_DIAGNOSTIC] } : {}),
|
|
2271
2287
|
...(headPlan.stale
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.2",
|
|
4
4
|
"description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and host-gated COMMENT or qualified APPROVE publishing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|