pi-pr-review 1.12.0 → 1.12.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 +3 -3
- package/extensions/pr-review-subagent.ts +43 -9
- package/extensions/review-table.ts +26 -9
- package/lib/pr-review-artifacts.ts +6 -0
- package/lib/pr-review-deadlines.ts +3 -0
- package/lib/pr-review-loop.ts +69 -4
- package/lib/pr-review-markdown.ts +3 -0
- package/package.json +2 -2
- package/prompts/pr-review.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.12.2](https://github.com/10ego/pi-pr-review/compare/v1.12.1...v1.12.2) (2026-08-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **review:** defer synthesis cap while review work runs ([#65](https://github.com/10ego/pi-pr-review/issues/65)) ([2bcdd93](https://github.com/10ego/pi-pr-review/commit/2bcdd93678755fc9c6b209c2357487a3912d026f))
|
|
9
|
+
|
|
10
|
+
## [1.12.1](https://github.com/10ego/pi-pr-review/compare/v1.12.0...v1.12.1) (2026-08-19)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Documentation
|
|
14
|
+
|
|
15
|
+
* **review:** align publication guidance ([#63](https://github.com/10ego/pi-pr-review/issues/63)) ([fe019d3](https://github.com/10ego/pi-pr-review/commit/fe019d3729635d1ed7ba7fd1e72bc5983db87a01))
|
|
16
|
+
|
|
3
17
|
## [1.12.0](https://github.com/10ego/pi-pr-review/compare/v1.11.8...v1.12.0) (2026-08-19)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -176,9 +176,9 @@ Every invocation has a host-owned monotonic 15-minute hard cap, including the tw
|
|
|
176
176
|
|
|
177
177
|
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.
|
|
178
178
|
|
|
179
|
-
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. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete lens/shard, and remain eligible for the safe body-only `COMMENT` publication path. A timed-out lane is never reported as `NO FINDINGS` or full coverage.
|
|
179
|
+
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 lens/shard, and remain eligible for the safe 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.
|
|
180
180
|
|
|
181
|
-
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, and termination grace/escalation data. These are initial production targets, not a promise that every provider completes before its host deadline.
|
|
181
|
+
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.
|
|
182
182
|
|
|
183
183
|
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.
|
|
184
184
|
|
|
@@ -212,7 +212,7 @@ Review semantics are Markdown-first. A deterministic tolerant parser normalizes
|
|
|
212
212
|
|
|
213
213
|
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.
|
|
214
214
|
|
|
215
|
-
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 and
|
|
215
|
+
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 and a standalone visible `All requested lanes completed.` disclosure (matched case-insensitively, with an optional final period) may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, partial or contradictory lane disclosures, malformed or unsafe output, and lane-fallback artifacts remain body-only `COMMENT` reviews. 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 remains retained internally; if the concise body plus its canonical marker exceeds GitHub's limit, the host uses its sanitized, size-bounded Markdown projection instead of dropping the review. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
|
|
216
216
|
|
|
217
217
|
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. Raw and lane-assembled fallbacks are body-only and `COMMENT`-only; assistant text cannot select event, commit, repository, hostname, API path, or inline anchors. Unknown or invalid host states fail closed before a write.
|
|
218
218
|
|
|
@@ -60,8 +60,10 @@ import { loadReviewContext, shardUnifiedDiff } from "../lib/pr-review-context.ts
|
|
|
60
60
|
import {
|
|
61
61
|
combineAbortSignals,
|
|
62
62
|
ReviewLoopCoordinator,
|
|
63
|
+
reviewDeadlineKindOf,
|
|
63
64
|
reviewLoopDeniedResult,
|
|
64
65
|
type ReviewArtifactPublisher,
|
|
66
|
+
type ReviewDeadlineKind,
|
|
65
67
|
type ReviewFocusPublisher,
|
|
66
68
|
} from "../lib/pr-review-loop.ts";
|
|
67
69
|
import { runSelfReviewRpcSubprocess } from "../lib/pr-self-review-rpc.ts";
|
|
@@ -138,7 +140,7 @@ interface PrReviewConfig {
|
|
|
138
140
|
thinkingLevels: Partial<Record<Tier, ThinkingLevel>>;
|
|
139
141
|
/** Optional tier-level policy used when a tool call does not override it. */
|
|
140
142
|
toolPolicies: Partial<Record<Tier, ToolPolicy>>;
|
|
141
|
-
/** Automatically publish
|
|
143
|
+
/** Automatically publish the completed review as a host-gated GitHub COMMENT or APPROVE review. Disabled by default. */
|
|
142
144
|
autoPostReviews: boolean;
|
|
143
145
|
/** Permit stale publication as body-only with reviewed/current SHAs disclosed. Enabled by default. */
|
|
144
146
|
allowStalePublish: boolean;
|
|
@@ -489,6 +491,8 @@ interface RunResult {
|
|
|
489
491
|
errorMessage?: string;
|
|
490
492
|
model?: string;
|
|
491
493
|
timedOut?: boolean;
|
|
494
|
+
/** Which host invocation deadline (not the lane's own attempt deadline) ended this run. */
|
|
495
|
+
deadlineExpired?: ReviewDeadlineKind;
|
|
492
496
|
terminationGraceMs?: number;
|
|
493
497
|
forcedTermination?: boolean;
|
|
494
498
|
firstEventMs?: number;
|
|
@@ -553,8 +557,12 @@ export function runReviewSubprocess(
|
|
|
553
557
|
}
|
|
554
558
|
};
|
|
555
559
|
const onAbort = () => {
|
|
556
|
-
|
|
557
|
-
|
|
560
|
+
// A host total/synthesis deadline is a timeout, not user cancellation.
|
|
561
|
+
// The kind is recorded only when it actually wins termination, so a
|
|
562
|
+
// coincidental host abort during an attempt-timeout drain cannot
|
|
563
|
+
// mislabel the lane.
|
|
564
|
+
const kind = reviewDeadlineKindOf(signal?.reason);
|
|
565
|
+
terminate(kind ? "timeout" : "abort", kind);
|
|
558
566
|
};
|
|
559
567
|
const cleanupAbort = () => {
|
|
560
568
|
if (killTimer) clearTimeout(killTimer);
|
|
@@ -665,7 +673,9 @@ export function runReviewSubprocess(
|
|
|
665
673
|
if (termination === "timeout") {
|
|
666
674
|
result.timedOut = true;
|
|
667
675
|
result.stopReason = "timeout";
|
|
668
|
-
result.errorMessage ??=
|
|
676
|
+
result.errorMessage ??= result.deadlineExpired
|
|
677
|
+
? `Review ${result.deadlineExpired} deadline expired while this lane was still running.`
|
|
678
|
+
: "Review attempt exceeded its host deadline.";
|
|
669
679
|
} else if (termination === "abort") {
|
|
670
680
|
result.stopReason = "aborted";
|
|
671
681
|
} else if (error) result.errorMessage = error.message;
|
|
@@ -715,9 +725,10 @@ export function runReviewSubprocess(
|
|
|
715
725
|
groupKillDeadline = now() + (deadline?.terminationGraceMs ?? 5_000);
|
|
716
726
|
checkGroupCleanupGrace();
|
|
717
727
|
};
|
|
718
|
-
const terminate = (reason: "abort" | "timeout") => {
|
|
728
|
+
const terminate = (reason: "abort" | "timeout", deadlineKind?: ReviewDeadlineKind) => {
|
|
719
729
|
if (settled || termination) return;
|
|
720
730
|
termination = reason;
|
|
731
|
+
if (deadlineKind) result.deadlineExpired = deadlineKind;
|
|
721
732
|
terminationStartedAt = now();
|
|
722
733
|
pendingClose ??= { code: 1, processSignal: undefined };
|
|
723
734
|
if (processGroupId !== undefined) {
|
|
@@ -1052,6 +1063,7 @@ function retainPassArtifact(pass: SubagentPassRequest, result: SubagentPassResul
|
|
|
1052
1063
|
stopReason: result.stopReason,
|
|
1053
1064
|
errorMessage: result.errorMessage,
|
|
1054
1065
|
lifecycle: result.status,
|
|
1066
|
+
deadlineExpired: result.deadlineExpired,
|
|
1055
1067
|
attempts: result.attempts.map((attempt, index) => ({
|
|
1056
1068
|
ordinal: index + 1,
|
|
1057
1069
|
kind: attempt.kind,
|
|
@@ -1064,6 +1076,7 @@ function retainPassArtifact(pass: SubagentPassRequest, result: SubagentPassResul
|
|
|
1064
1076
|
stopReason: attempt.stopReason,
|
|
1065
1077
|
errorMessage: attempt.errorMessage,
|
|
1066
1078
|
lifecycle: attempt.status,
|
|
1079
|
+
deadlineExpired: attempt.deadlineExpired,
|
|
1067
1080
|
retryable: attempt.retryable,
|
|
1068
1081
|
elapsedMs: attempt.elapsedMs,
|
|
1069
1082
|
firstEventMs: attempt.firstEventMs,
|
|
@@ -1162,6 +1175,7 @@ async function runSubagentPass(
|
|
|
1162
1175
|
stopReason: result.stopReason,
|
|
1163
1176
|
errorMessage: result.errorMessage,
|
|
1164
1177
|
timedOut: result.timedOut,
|
|
1178
|
+
deadlineExpired: result.deadlineExpired,
|
|
1165
1179
|
terminationGraceMs: result.terminationGraceMs,
|
|
1166
1180
|
forcedTermination: result.forcedTermination,
|
|
1167
1181
|
deadlineMs,
|
|
@@ -1191,6 +1205,7 @@ async function runSubagentPass(
|
|
|
1191
1205
|
toolPolicy,
|
|
1192
1206
|
elapsedMs: monotonicNow() - startedAt,
|
|
1193
1207
|
fallbackBudgetRejected,
|
|
1208
|
+
deadlineExpired: result.deadlineExpired,
|
|
1194
1209
|
deadlineSource: budget?.source,
|
|
1195
1210
|
batchDeadlineMs: budget ? budget.batchDeadlineMs - budget.startedAtMs : undefined,
|
|
1196
1211
|
totalDeadlineMs: budget ? budget.totalDeadlineMs - budget.startedAtMs : undefined,
|
|
@@ -1228,6 +1243,7 @@ async function runSubagentPass(
|
|
|
1228
1243
|
toolPolicy,
|
|
1229
1244
|
elapsedMs: monotonicNow() - startedAt,
|
|
1230
1245
|
fallbackBudgetRejected,
|
|
1246
|
+
deadlineExpired: final.deadlineExpired,
|
|
1231
1247
|
deadlineSource: budget?.source,
|
|
1232
1248
|
batchDeadlineMs: budget ? budget.batchDeadlineMs - budget.startedAtMs : undefined,
|
|
1233
1249
|
totalDeadlineMs: budget ? budget.totalDeadlineMs - budget.startedAtMs : undefined,
|
|
@@ -1249,6 +1265,12 @@ function formatAttemptSummary(result: SubagentPassResult): string {
|
|
|
1249
1265
|
.join(" → ")}`;
|
|
1250
1266
|
}
|
|
1251
1267
|
|
|
1268
|
+
function deadlineExpiryCounts(results: readonly SubagentPassResult[]): Record<ReviewDeadlineKind, number> {
|
|
1269
|
+
return Object.fromEntries(
|
|
1270
|
+
(["total", "synthesis"] as const).map((kind) => [kind, results.filter((r) => r.deadlineExpired === kind).length]),
|
|
1271
|
+
) as Record<ReviewDeadlineKind, number>;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1252
1274
|
function formatBatchResults(
|
|
1253
1275
|
results: SubagentPassResult[],
|
|
1254
1276
|
maxParallel: number,
|
|
@@ -1258,10 +1280,18 @@ function formatBatchResults(
|
|
|
1258
1280
|
(["complete", "partial", "timed_out", "failed"] as const).map((state) => [state, results.filter((r) => r.status === state).length]),
|
|
1259
1281
|
) as Record<ReviewLaneLifecycle, number>;
|
|
1260
1282
|
const incomplete = results.length - lifecycleCounts.complete;
|
|
1283
|
+
const externalDeadlines = deadlineExpiryCounts(results);
|
|
1284
|
+
const externalTotal = externalDeadlines.total + externalDeadlines.synthesis;
|
|
1261
1285
|
const lines = [
|
|
1262
1286
|
`Review subagents completed: ${lifecycleCounts.complete}/${results.length} semantically complete (max_parallel=${maxParallel}; partial=${lifecycleCounts.partial}; timed_out=${lifecycleCounts.timed_out}; failed=${lifecycleCounts.failed}).`,
|
|
1263
1287
|
...warnings,
|
|
1264
1288
|
];
|
|
1289
|
+
if (externalTotal > 0) {
|
|
1290
|
+
const kinds = (["total", "synthesis"] as const).filter((kind) => externalDeadlines[kind] > 0).join("/");
|
|
1291
|
+
lines.push(
|
|
1292
|
+
`WARNING: the host ${kinds} deadline expired while ${externalTotal} lane(s) were still running; those lane attempt budgets were not exceeded.`,
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1265
1295
|
if (incomplete) {
|
|
1266
1296
|
lines.push(
|
|
1267
1297
|
`WARNING: ${incomplete} pass(es) are incomplete. Preserve their raw evidence, then rerun or cover those passes inline before finalizing.`,
|
|
@@ -1274,6 +1304,7 @@ function formatBatchResults(
|
|
|
1274
1304
|
`status: ${result.status}`,
|
|
1275
1305
|
`tool_policy: ${result.toolPolicy}`,
|
|
1276
1306
|
`elapsed_ms: ${result.elapsedMs}`,
|
|
1307
|
+
...(result.deadlineExpired ? [`deadline_expired: ${result.deadlineExpired}`] : []),
|
|
1277
1308
|
result.notice,
|
|
1278
1309
|
);
|
|
1279
1310
|
const attemptSummary = formatAttemptSummary(result);
|
|
@@ -1612,7 +1643,7 @@ export default function registerPrReviewSubagents(
|
|
|
1612
1643
|
"Delegate one PR-review pass to an isolated subagent running on a configured model tier.",
|
|
1613
1644
|
"Pass tier (light|medium|heavy) plus an objective and the diff as context.",
|
|
1614
1645
|
"Model per tier is configured via /pr-review-config (stored in pr-review.json).",
|
|
1615
|
-
"Returns the subagent's candidate findings; the orchestrator validates, filters, and emits the final
|
|
1646
|
+
"Returns the subagent's candidate findings; the orchestrator validates, filters, and emits the final Markdown review.",
|
|
1616
1647
|
].join(" "),
|
|
1617
1648
|
promptSnippet:
|
|
1618
1649
|
"Run a tiered PR-review pass (light/medium/heavy) in an isolated subagent on the configured model",
|
|
@@ -1896,6 +1927,7 @@ export default function registerPrReviewSubagents(
|
|
|
1896
1927
|
incompleteCount: incomplete.length,
|
|
1897
1928
|
fallbackStarts: results.filter((result) => result.attempts.length > 1).length,
|
|
1898
1929
|
fallbackBudgetRejections: results.filter((result) => result.fallbackBudgetRejected).length,
|
|
1930
|
+
deadlineExpiries: deadlineExpiryCounts(results),
|
|
1899
1931
|
deadlineSource: lease.budget?.source,
|
|
1900
1932
|
deadlineWarnings: lease.budget?.warnings,
|
|
1901
1933
|
batchDeadlineMs: lease.budget ? lease.budget.batchDeadlineMs - lease.budget.startedAtMs : undefined,
|
|
@@ -1927,8 +1959,10 @@ export default function registerPrReviewSubagents(
|
|
|
1927
1959
|
status: r.status,
|
|
1928
1960
|
stopReason: r.stopReason,
|
|
1929
1961
|
errorMessage: r.errorMessage,
|
|
1962
|
+
deadlineExpired: r.deadlineExpired,
|
|
1930
1963
|
fallbackUsed: r.fallbackUsed,
|
|
1931
1964
|
retryableFailure: r.retryableFailure,
|
|
1965
|
+
retryableFailure: r.retryableFailure,
|
|
1932
1966
|
toolPolicy: r.toolPolicy,
|
|
1933
1967
|
elapsedMs: r.elapsedMs,
|
|
1934
1968
|
firstEventMs: r.attempts.at(-1)?.firstEventMs,
|
|
@@ -2033,7 +2067,7 @@ const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
|
|
|
2033
2067
|
value: `${t}_tool_policy=`,
|
|
2034
2068
|
label: `${t}_tool_policy=<none|configured|unset> — default tool access when a pass does not override it`,
|
|
2035
2069
|
})),
|
|
2036
|
-
{ value: "auto_post_reviews=", label: "auto_post_reviews=<true|false> — automatically post
|
|
2070
|
+
{ value: "auto_post_reviews=", label: "auto_post_reviews=<true|false> — automatically post host-gated reviews (default false)" },
|
|
2037
2071
|
{ value: "allow_stale_publish=", label: "allow_stale_publish=<true|false> — permit disclosed body-only stale publication (default true)" },
|
|
2038
2072
|
{ value: "tools=", label: "tools=read,bash,grep,find,ls — allowlist used by configured policy" },
|
|
2039
2073
|
{ value: "show", label: "show — print the current review config" },
|
|
@@ -2325,7 +2359,7 @@ function summarizeConfig(
|
|
|
2325
2359
|
"- Enable stale publication (default): `/pr-review-config allow_stale_publish=true`",
|
|
2326
2360
|
"- Permit qualified stale fully parsed reviews to record APPROVE: `/pr-review-config allow_stale_approvals=true`",
|
|
2327
2361
|
"- Keep stale reviews as COMMENT (default): `/pr-review-config allow_stale_approvals=false`",
|
|
2328
|
-
"- Enable
|
|
2362
|
+
"- Enable approval for qualified fully parsed reviews: `/pr-review-config approve_max_priority_level=P2`",
|
|
2329
2363
|
"- Disable auto-approve (default): `/pr-review-config approve_max_priority_level=off`",
|
|
2330
2364
|
"- Set tier tool policy: `/pr-review-config light_tool_policy=none`",
|
|
2331
2365
|
"- Clear a tier: `/pr-review-config medium=unset`",
|
|
@@ -2452,7 +2486,7 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
|
|
|
2452
2486
|
{
|
|
2453
2487
|
id: "auto_post_reviews",
|
|
2454
2488
|
label: "user automatic posting setting",
|
|
2455
|
-
description: "Post one GitHub
|
|
2489
|
+
description: "Post one host-gated GitHub review after terminal synthesis. Disabled by default.",
|
|
2456
2490
|
currentValue: String(cfg.autoPostReviews),
|
|
2457
2491
|
values: ["false", "true"],
|
|
2458
2492
|
},
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* review-table
|
|
3
3
|
*
|
|
4
|
-
* Renders the /pr-review
|
|
5
|
-
* configured GitHub publication after
|
|
6
|
-
* invocation
|
|
7
|
-
* one formal COMMENT review with associated inline comments.
|
|
4
|
+
* Renders the /pr-review Markdown-first canonical artifact as a readable TUI review
|
|
5
|
+
* and owns configured GitHub publication after terminal synthesis. Publishing is
|
|
6
|
+
* bound to host invocation state, validates current PR state and anchors, and emits
|
|
7
|
+
* at most one formal COMMENT or qualified APPROVE review with associated inline comments.
|
|
8
8
|
*
|
|
9
|
-
* Rendering only rewrites interactive TUI output. Print/json/rpc modes retain raw
|
|
9
|
+
* Rendering only rewrites interactive TUI output. Print/json/rpc modes retain the raw
|
|
10
|
+
* assistant response.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import * as fs from "node:fs";
|
|
@@ -422,7 +423,7 @@ async function publishCompletedReview(
|
|
|
422
423
|
|
|
423
424
|
const headSha = record.review.pr?.head_sha;
|
|
424
425
|
if (typeof headSha !== "string") {
|
|
425
|
-
ctx.ui.notify("PR review was not posted: cached
|
|
426
|
+
ctx.ui.notify("PR review was not posted: cached review artifact is missing pr.head_sha", "error");
|
|
426
427
|
return;
|
|
427
428
|
}
|
|
428
429
|
const result = await publishPullReview({
|
|
@@ -780,13 +781,28 @@ export default function registerReviewTable(
|
|
|
780
781
|
}
|
|
781
782
|
});
|
|
782
783
|
|
|
784
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
785
|
+
// A turn that begins after review work (even without a review tool in
|
|
786
|
+
// flight, e.g. while the orchestrator composes the batch call) means the
|
|
787
|
+
// synthesis phase is not active. Postpone the armed cap and re-arm it at
|
|
788
|
+
// this turn's end so early review-tool turns cannot starve later lanes.
|
|
789
|
+
// This read must never clear an expired binding: its retained artifacts
|
|
790
|
+
// are reserved for degraded synthesis, so no lease acquisition happens.
|
|
791
|
+
if (!loopCoordinator.peek()) return;
|
|
792
|
+
const generation = loopCoordinator.deferActiveSynthesis(ctx);
|
|
793
|
+
if (generation !== undefined) generationsReadyForSynthesis.add(generation);
|
|
794
|
+
});
|
|
795
|
+
|
|
783
796
|
pi.on("tool_execution_start", (event, ctx) => {
|
|
784
797
|
if (!loopCoordinator.peek()) return;
|
|
785
798
|
telemetryTracker.toolStarted(event.toolCallId, event.toolName, event.args);
|
|
786
799
|
const lease = loopCoordinator.acquire(ctx);
|
|
787
800
|
if (!lease) return;
|
|
788
801
|
activeToolGenerations.set(event.toolCallId, lease.generation);
|
|
789
|
-
if (reviewToolNames.has(event.toolName))
|
|
802
|
+
if (reviewToolNames.has(event.toolName)) {
|
|
803
|
+
generationsWithReviewTools.add(lease.generation);
|
|
804
|
+
loopCoordinator.deferSynthesis(lease.generation, ctx);
|
|
805
|
+
}
|
|
790
806
|
});
|
|
791
807
|
|
|
792
808
|
pi.on("tool_execution_end", (event) => {
|
|
@@ -803,6 +819,7 @@ export default function registerReviewTable(
|
|
|
803
819
|
// turn_end includes the complete tool-result set for the assistant turn.
|
|
804
820
|
// Start the synthesis cap here rather than at the first tool end so
|
|
805
821
|
// sequential or concurrently settling tool calls cannot consume it early.
|
|
822
|
+
// Turns that deferred the cap re-arm it here with a fresh synthesis window.
|
|
806
823
|
for (const generation of generationsReadyForSynthesis) {
|
|
807
824
|
generationsReadyForSynthesis.delete(generation);
|
|
808
825
|
loopCoordinator.beginSynthesis(generation, ctx);
|
|
@@ -913,9 +930,9 @@ export default function registerReviewTable(
|
|
|
913
930
|
: text.trim()
|
|
914
931
|
? parseReview(text)
|
|
915
932
|
: null;
|
|
916
|
-
if (!review) return; // not a renderable /pr-review
|
|
933
|
+
if (!review) return; // not a renderable /pr-review artifact — leave untouched
|
|
917
934
|
|
|
918
|
-
// Keep raw
|
|
935
|
+
// Keep the raw assistant response for automation; only prettify interactive TUI output.
|
|
919
936
|
if (ctx.mode !== "tui") return;
|
|
920
937
|
const nonText = event.message.content.filter((part) => part.type !== "text");
|
|
921
938
|
return {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReviewDeadlineKind } from "./pr-review-deadlines.ts";
|
|
2
|
+
|
|
1
3
|
export type ReviewLaneLifecycle = "complete" | "partial" | "timed_out" | "failed";
|
|
2
4
|
|
|
3
5
|
export interface ReviewLaneAttemptArtifact {
|
|
@@ -12,6 +14,8 @@ export interface ReviewLaneAttemptArtifact {
|
|
|
12
14
|
readonly stopReason?: string;
|
|
13
15
|
readonly errorMessage?: string;
|
|
14
16
|
readonly lifecycle: ReviewLaneLifecycle;
|
|
17
|
+
/** Host total/synthesis deadline that ended this attempt, when it caused termination. */
|
|
18
|
+
readonly deadlineExpired?: ReviewDeadlineKind;
|
|
15
19
|
readonly retryable: boolean;
|
|
16
20
|
readonly elapsedMs: number;
|
|
17
21
|
readonly firstEventMs?: number;
|
|
@@ -49,6 +53,8 @@ export interface ReviewLaneArtifact {
|
|
|
49
53
|
readonly stopReason?: string;
|
|
50
54
|
readonly errorMessage?: string;
|
|
51
55
|
readonly lifecycle: ReviewLaneLifecycle;
|
|
56
|
+
/** Host total/synthesis deadline that ended this lane, when it caused termination. */
|
|
57
|
+
readonly deadlineExpired?: ReviewDeadlineKind;
|
|
52
58
|
readonly attempts: readonly ReviewLaneAttemptArtifact[];
|
|
53
59
|
readonly fallbackUsed: boolean;
|
|
54
60
|
readonly elapsedMs: number;
|
|
@@ -2,6 +2,9 @@ import { monotonicNow, type MonotonicNow } from "./pr-review-telemetry.ts";
|
|
|
2
2
|
|
|
3
3
|
export type ReviewTier = "light" | "medium" | "heavy";
|
|
4
4
|
|
|
5
|
+
/** Which host invocation deadline (total or synthesis) ended review work. */
|
|
6
|
+
export type ReviewDeadlineKind = "total" | "synthesis";
|
|
7
|
+
|
|
5
8
|
export interface ReviewDeadlineConfig {
|
|
6
9
|
attemptMs: Record<ReviewTier, number>;
|
|
7
10
|
fallbackAttemptMs: number;
|
package/lib/pr-review-loop.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type ExpectedReviewLane,
|
|
22
22
|
type ReviewLaneArtifact,
|
|
23
23
|
} from "./pr-review-artifacts.ts";
|
|
24
|
-
import { createReviewBudget, type DeadlineResolution, type ReviewBudget } from "./pr-review-deadlines.ts";
|
|
24
|
+
import { createReviewBudget, type DeadlineResolution, type ReviewBudget, type ReviewDeadlineKind } from "./pr-review-deadlines.ts";
|
|
25
25
|
import { monotonicNow } from "./pr-review-telemetry.ts";
|
|
26
26
|
|
|
27
27
|
export const REVIEW_LOOP_TOOL_NAMES = [
|
|
@@ -48,6 +48,30 @@ interface ReviewLoopBinding {
|
|
|
48
48
|
deadlineKind?: "total" | "synthesis";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export type { ReviewDeadlineKind };
|
|
52
|
+
|
|
53
|
+
export interface ReviewDeadlineError extends Error {
|
|
54
|
+
readonly reviewDeadlineKind: ReviewDeadlineKind;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const REVIEW_DEADLINE_KIND_PATTERN = /review (total|synthesis) deadline expired/i;
|
|
58
|
+
|
|
59
|
+
/** Host deadline aborts carry a typed kind so classification never depends on message text alone. */
|
|
60
|
+
export function reviewDeadlineError(kind: ReviewDeadlineKind): ReviewDeadlineError {
|
|
61
|
+
return Object.assign(new Error(`review ${kind} deadline expired`), {
|
|
62
|
+
reviewDeadlineKind: kind,
|
|
63
|
+
}) as ReviewDeadlineError;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Identify the host total/synthesis deadline behind an abort reason, if any. */
|
|
67
|
+
export function reviewDeadlineKindOf(reason: unknown): ReviewDeadlineKind | undefined {
|
|
68
|
+
if (!(reason instanceof Error)) return undefined;
|
|
69
|
+
const typed = (reason as Partial<ReviewDeadlineError>).reviewDeadlineKind;
|
|
70
|
+
if (typed === "total" || typed === "synthesis") return typed;
|
|
71
|
+
const matched = REVIEW_DEADLINE_KIND_PATTERN.exec(reason.message);
|
|
72
|
+
return matched ? (matched[1]!.toLowerCase() as ReviewDeadlineKind) : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
export interface ReviewLoopLease {
|
|
52
76
|
readonly generation: number;
|
|
53
77
|
readonly signal: AbortSignal;
|
|
@@ -171,7 +195,7 @@ export class ReviewLoopCoordinator {
|
|
|
171
195
|
if (this.binding !== binding || binding.deadlineKind) return;
|
|
172
196
|
binding.deadlineKind = "total";
|
|
173
197
|
if (binding.synthesisTimer) clearTimeout(binding.synthesisTimer);
|
|
174
|
-
binding.controller.abort(
|
|
198
|
+
binding.controller.abort(reviewDeadlineError("total"));
|
|
175
199
|
this.setToolsEnabled(false);
|
|
176
200
|
try { binding.onDeadline?.(); } catch { /* lifecycle callback is best-effort */ }
|
|
177
201
|
}, activeAllowanceMs);
|
|
@@ -246,10 +270,11 @@ export class ReviewLoopCoordinator {
|
|
|
246
270
|
activeTotalDeadline,
|
|
247
271
|
);
|
|
248
272
|
const expire = () => {
|
|
249
|
-
|
|
273
|
+
// A deferral between scheduling and firing must not abort live review work.
|
|
274
|
+
if (this.binding !== binding || binding.deadlineKind || !binding.synthesisStarted) return;
|
|
250
275
|
binding.deadlineKind = "synthesis";
|
|
251
276
|
if (binding.totalTimer) clearTimeout(binding.totalTimer);
|
|
252
|
-
binding.controller.abort(
|
|
277
|
+
binding.controller.abort(reviewDeadlineError("synthesis"));
|
|
253
278
|
this.setToolsEnabled(false);
|
|
254
279
|
try { binding.onDeadline?.(); } catch { /* lifecycle callback is best-effort */ }
|
|
255
280
|
};
|
|
@@ -259,6 +284,46 @@ export class ReviewLoopCoordinator {
|
|
|
259
284
|
return true;
|
|
260
285
|
}
|
|
261
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Postpone an armed, unexpired synthesis cap because review work is active
|
|
289
|
+
* again. The cap re-arms the next time a turn ends with review tools done;
|
|
290
|
+
* an expired or never-armed binding is left untouched.
|
|
291
|
+
*/
|
|
292
|
+
deferSynthesis(
|
|
293
|
+
generation: number,
|
|
294
|
+
ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
|
|
295
|
+
): boolean {
|
|
296
|
+
const binding = this.binding;
|
|
297
|
+
if (!binding || binding.generation !== generation || !sameBinding(binding, ctx)) return false;
|
|
298
|
+
return this.disarmSynthesis(binding);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Defer the armed synthesis cap for the current binding without acquiring a
|
|
303
|
+
* lease. Unlike acquire(), this never clears an aborted or expired binding,
|
|
304
|
+
* so a turn that starts after a deadline expiry cannot destroy the retained
|
|
305
|
+
* artifacts reserved for degraded synthesis. Returns the deferred generation.
|
|
306
|
+
*/
|
|
307
|
+
deferActiveSynthesis(
|
|
308
|
+
ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
|
|
309
|
+
): number | undefined {
|
|
310
|
+
const binding = this.binding;
|
|
311
|
+
const phase = this.invocationGate.phase();
|
|
312
|
+
if (!binding || this.suspendedTools !== undefined || (phase !== "reviewing" && phase !== "confirmed")) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
if (!sameBinding(binding, ctx) || binding.controller.signal.aborted) return undefined;
|
|
316
|
+
return this.disarmSynthesis(binding) ? binding.generation : undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private disarmSynthesis(binding: ReviewLoopBinding): boolean {
|
|
320
|
+
if (binding.deadlineKind || binding.controller.signal.aborted || !binding.synthesisStarted) return false;
|
|
321
|
+
if (binding.synthesisTimer) clearTimeout(binding.synthesisTimer);
|
|
322
|
+
binding.synthesisTimer = undefined;
|
|
323
|
+
binding.synthesisStarted = false;
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
262
327
|
isLeaseActive(
|
|
263
328
|
lease: ReviewLoopLease,
|
|
264
329
|
ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
|
|
@@ -553,6 +553,9 @@ function retainedLaneEvidence(lanes: readonly ReviewLaneArtifact[]): string {
|
|
|
553
553
|
const lines = ["## Host-retained lane evidence", ""];
|
|
554
554
|
for (const lane of lanes.slice(0, MAX_DISCLOSED_LANES)) {
|
|
555
555
|
lines.push(`### ${disclosedPassId(lane.passId)} — ${lane.lifecycle}`, "");
|
|
556
|
+
if (lane.deadlineExpired) {
|
|
557
|
+
lines.push(`Host ${lane.deadlineExpired} deadline expired while this lane was still running.`, "");
|
|
558
|
+
}
|
|
556
559
|
const text = retainedLaneText(lane);
|
|
557
560
|
if (text) lines.push(text, "");
|
|
558
561
|
else lines.push(`No substantive output was retained${lane.errorMessage ? `: ${lane.errorMessage}` : "."}`, "");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.12.
|
|
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
|
|
3
|
+
"version": "1.12.2",
|
|
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",
|
|
7
7
|
"pi-extension",
|
package/prompts/pr-review.md
CHANGED
|
@@ -196,7 +196,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
|
|
|
196
196
|
|
|
197
197
|
After host synthesis, the extension caches one validated completed review as a canonical host-owned 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. `/pr-review-publish` and a matching direct request publish only the cache and never start or rerun review agents. On a later turn, the extension intercepts that direct input before an agent turn and permits stale publication without asking the orchestrator to recreate the review.
|
|
198
198
|
|
|
199
|
-
Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; the extension never submits `REQUEST_CHANGES`. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch,
|
|
199
|
+
Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; the extension never submits `REQUEST_CHANGES`. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch, a standalone visible `All requested lanes completed.` disclosure (matched case-insensitively, with an optional final period), and retained strict host-bound JSON share the same gated `APPROVE` path. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, contradictory lane disclosures, and partial, malformed, unsafe, or lane-fallback Markdown remain body-only `COMMENT`. Restored cache entries must reclassify retained lane output, reproduce the exact host-recorded artifact key set, and bind every complete artifact to the frozen invocation generation, tier, and minor-hygiene contract and re-synthesize the retained raw text under that frozen binding before preserving approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The public body contains the verdict, an inline-review cue when applicable, and `Other Notes` for nits and every finding that cannot be inline; overview, verification, strengths, and transport diagnostics remain only in the retained internal artifact. If that concise body plus its canonical marker exceeds GitHub's limit, the host publishes the sanitized, size-bounded original Markdown projection instead of dropping the review. Stale reviews and authorized closed or merged reviews are body-only. A stale review may record a qualified `APPROVE` only with the separate trusted `allowStaleApprovals: true` opt-in captured before review execution. A failed write never triggers a fallback POST.
|
|
200
200
|
|
|
201
201
|
Every path retains the same safety gates: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown lifecycle states and unconfirmed non-open writes fail closed. The session-backed cache survives extension reloads and session resumes but remains bound to the originating session instance and repository. If the captured stale setting disabled publication, the user may explicitly run `/pr-review-publish <PR-NUM> --allow-stale`. Never rerun the review merely to change posting intent, and never attempt a direct GitHub write yourself.
|
|
202
202
|
|
|
@@ -225,7 +225,7 @@ Return Markdown using these stable headings. Do not emit a GitHub API payload an
|
|
|
225
225
|
**Location:** `path/to/file.ts:10-12 RIGHT`
|
|
226
226
|
|
|
227
227
|
## Lane completeness
|
|
228
|
-
<name any failed, timed-out, or partial lane and how its retained evidence was handled; otherwise
|
|
228
|
+
<name any failed, timed-out, or partial lane and how its retained evidence was handled; otherwise write: All requested lanes completed.>
|
|
229
229
|
|
|
230
230
|
## Strengths and notes
|
|
231
231
|
<useful strengths plus concise correctness, security, or performance notes>
|