pi-pr-review 1.12.1 → 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,12 @@
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
+
3
10
  ## [1.12.1](https://github.com/10ego/pi-pr-review/compare/v1.12.0...v1.12.1) (2026-08-19)
4
11
 
5
12
 
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
 
@@ -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";
@@ -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);
@@ -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,
@@ -781,13 +781,28 @@ export default function registerReviewTable(
781
781
  }
782
782
  });
783
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
+
784
796
  pi.on("tool_execution_start", (event, ctx) => {
785
797
  if (!loopCoordinator.peek()) return;
786
798
  telemetryTracker.toolStarted(event.toolCallId, event.toolName, event.args);
787
799
  const lease = loopCoordinator.acquire(ctx);
788
800
  if (!lease) return;
789
801
  activeToolGenerations.set(event.toolCallId, lease.generation);
790
- 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
+ }
791
806
  });
792
807
 
793
808
  pi.on("tool_execution_end", (event) => {
@@ -804,6 +819,7 @@ export default function registerReviewTable(
804
819
  // turn_end includes the complete tool-result set for the assistant turn.
805
820
  // Start the synthesis cap here rather than at the first tool end so
806
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.
807
823
  for (const generation of generationsReadyForSynthesis) {
808
824
  generationsReadyForSynthesis.delete(generation);
809
825
  loopCoordinator.beginSynthesis(generation, ctx);
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.12.1",
3
+ "version": "1.12.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",