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 +7 -0
- package/README.md +2 -2
- package/extensions/pr-review-subagent.ts +38 -4
- package/extensions/review-table.ts +17 -1
- 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 +1 -1
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
|
-
|
|
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);
|
|
@@ -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))
|
|
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;
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.12.
|
|
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",
|