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 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 the exact visible `All requested lanes completed.` disclosure 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`.
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 final review JSON as a GitHub COMMENT review. Disabled by default. */
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
- const reason = signal?.reason;
557
- terminate(reason instanceof Error && /review (?:total|synthesis) deadline expired/i.test(reason.message) ? "timeout" : "abort");
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 ??= "Review attempt exceeded its host deadline.";
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 JSON.",
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 COMMENT reviews (default false)" },
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 strict JSON approval for low-severity reviews: `/pr-review-config approve_max_priority_level=P2`",
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 COMMENT review after final JSON. Disabled by default.",
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 final JSON response as a readable TUI review and owns
5
- * configured GitHub publication after valid final JSON. Publishing is bound to raw
6
- * invocation flags/config, validates current PR state and anchors, and can emit only
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 JSON.
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 final JSON is missing pr.head_sha", "error");
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)) generationsWithReviewTools.add(lease.generation);
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 JSON payload — leave untouched
933
+ if (!review) return; // not a renderable /pr-review artifact — leave untouched
917
934
 
918
- // Keep raw JSON for automation; only prettify for interactive terminals.
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;
@@ -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(new Error("review total deadline expired"));
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
- if (this.binding !== binding || binding.deadlineKind) return;
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(new Error("review synthesis deadline expired"));
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.0",
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 safe COMMENT-only publishing.",
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",
@@ -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, the exact visible `All requested lanes completed.` disclosure, 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.
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 say all requested lanes completed>
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>