@dieulc/workflow 0.9.2 → 0.11.0
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/README.md +104 -20
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +2868 -496
- package/package.json +16 -6
- package/provider-retarget.ts +258 -0
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +126 -0
- package/review-recovery.ts +67 -0
- package/review-runtime.ts +454 -94
- package/review-tools.ts +472 -7
- package/review.ts +451 -33
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
package/review-runtime.ts
CHANGED
|
@@ -26,9 +26,12 @@ import * as path from "node:path";
|
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
DEFAULT_REVIEW_MODE_CONFIG,
|
|
29
|
+
buildReviewerAttempts,
|
|
30
|
+
classifyAssistantStop,
|
|
29
31
|
createReviewRoundGate,
|
|
30
32
|
createReviewSession,
|
|
31
33
|
createReviewState,
|
|
34
|
+
isAbortStopText,
|
|
32
35
|
planHash,
|
|
33
36
|
readProviderErrorEvent,
|
|
34
37
|
reviewNeedsVerification,
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
type CreateReviewSessionOptions,
|
|
38
41
|
type CreateReviewSessionResult,
|
|
39
42
|
type ReviewChildSession,
|
|
43
|
+
type ReviewFallbackModel,
|
|
40
44
|
type ReviewModeConfig,
|
|
41
45
|
type ReviewPassResult,
|
|
42
46
|
type ReviewPhase,
|
|
@@ -50,6 +54,7 @@ import {
|
|
|
50
54
|
ReviewTranscript as Transcript,
|
|
51
55
|
} from "./review.ts";
|
|
52
56
|
import { appendProviderAuthHint } from "./review-providers.ts";
|
|
57
|
+
import { isRecoverableProviderFailure } from "./review-recovery.ts";
|
|
53
58
|
import { createReviewTools } from "./review-tools.ts";
|
|
54
59
|
import { buildPlanDiff, renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
|
|
55
60
|
|
|
@@ -75,6 +80,8 @@ export interface ReviewRuntimeDeps {
|
|
|
75
80
|
reviewerModel: () =>
|
|
76
81
|
| { provider: string; id: string; thinking: string }
|
|
77
82
|
| undefined;
|
|
83
|
+
/** Automatic fallback models for provider recovery (parent, base reviewer, base planner). */
|
|
84
|
+
fallbackReviewerModels?: () => readonly ReviewFallbackModel[];
|
|
78
85
|
/** Resolve a model from the parent's registry. */
|
|
79
86
|
findModel: (provider: string, id: string) => unknown;
|
|
80
87
|
/**
|
|
@@ -193,7 +200,15 @@ export function buildPass1Prompt(input: {
|
|
|
193
200
|
round: number;
|
|
194
201
|
totalRounds: number;
|
|
195
202
|
verify: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Max characters per submitted chunk (step 18). `0` disables chunked mode.
|
|
205
|
+
* When set and the plan is longer than two chunks, pass 1 asks for the
|
|
206
|
+
* chunked protocol instead of one oversized response.
|
|
207
|
+
*/
|
|
208
|
+
chunkChars?: number;
|
|
196
209
|
}): string {
|
|
210
|
+
const chunkChars = input.chunkChars ?? 0;
|
|
211
|
+
const chunked = chunkChars > 0 && input.planText.length > chunkChars * 2;
|
|
197
212
|
const parts: string[] = [
|
|
198
213
|
`[REVIEW MODE — PASS 1 of ${input.verify ? 2 : 1} — round ${input.round}/${input.totalRounds}]`,
|
|
199
214
|
"",
|
|
@@ -256,13 +271,53 @@ export function buildPass1Prompt(input: {
|
|
|
256
271
|
);
|
|
257
272
|
}
|
|
258
273
|
|
|
274
|
+
parts.push(
|
|
275
|
+
"",
|
|
276
|
+
"## Recovery",
|
|
277
|
+
"",
|
|
278
|
+
"A provider may close this stream before you finish. If the harness retries",
|
|
279
|
+
"this review on another model, start again from the plan below; do not assume",
|
|
280
|
+
"the previous model's transcript is available. Submit the complete rewrite as",
|
|
281
|
+
"soon as it is ready — do not hold it back to add more polish.",
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
if (chunked) {
|
|
285
|
+
parts.push(
|
|
286
|
+
"",
|
|
287
|
+
"## Chunked submission (required for this plan)",
|
|
288
|
+
"",
|
|
289
|
+
`This plan is long enough that a single response may be cut off. Send the`,
|
|
290
|
+
`rewrite in ordered chunks of at most ${chunkChars} characters:`,
|
|
291
|
+
"",
|
|
292
|
+
"1. `review_plan_begin` (optionally with `totalChunks`).",
|
|
293
|
+
"2. `review_plan_chunk` with `index` 1, 2, 3, … — never skip or repeat. The",
|
|
294
|
+
" tool echoes the next expected index; if a chunk is rejected, resend that",
|
|
295
|
+
" index and continue (accepted chunks are kept).",
|
|
296
|
+
"3. `review_plan_finalize` with your findings, verdict and summary. Nothing is",
|
|
297
|
+
" written before this call succeeds.",
|
|
298
|
+
"",
|
|
299
|
+
"Do not call `review_submit_plan` with a full `planMarkdown` in this round, and",
|
|
300
|
+
"do not repeat earlier chunks. Split on heading boundaries where possible.",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
259
304
|
parts.push(
|
|
260
305
|
"",
|
|
261
306
|
"## Required output",
|
|
262
307
|
"",
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
308
|
+
...(chunked
|
|
309
|
+
? [
|
|
310
|
+
"1. `review_plan_begin`, then `review_plan_chunk` for every part of the",
|
|
311
|
+
" COMPLETE revised plan (readable markdown, original structure preserved,",
|
|
312
|
+
" no `Review changes` section — it is generated).",
|
|
313
|
+
"2. `review_plan_finalize` with your findings, verdict and summary.",
|
|
314
|
+
"3. `review_pass_done` with the same findings.",
|
|
315
|
+
]
|
|
316
|
+
: [
|
|
317
|
+
"1. `review_submit_plan` with the COMPLETE revised plan (readable markdown,",
|
|
318
|
+
" original structure preserved, no `Review changes` section — it is generated).",
|
|
319
|
+
"2. `review_pass_done` with your verdict and the same findings.",
|
|
320
|
+
]),
|
|
266
321
|
"",
|
|
267
322
|
"You may run read-only and test/lint/typecheck commands, but you cannot build or",
|
|
268
323
|
"write any file other than the plan. Say what you could not verify instead of",
|
|
@@ -339,6 +394,96 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
339
394
|
| { message: string; retries: number; at: number }
|
|
340
395
|
| undefined;
|
|
341
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Agent runs stopped by Review Mode's own abort (pass complete, pause,
|
|
399
|
+
* teardown). The SDK's setup path can report such a stop as
|
|
400
|
+
* `stopReason: "error"` with an abort-shaped message, so the subscriber
|
|
401
|
+
* pairs this expectation with `classifyAssistantStop` before suppressing it.
|
|
402
|
+
* Bounded by two anchors: reset before every pass and on every settle.
|
|
403
|
+
*/
|
|
404
|
+
let expectedAbortEnds = 0;
|
|
405
|
+
|
|
406
|
+
/** Record that the next child run end was requested by this runtime. */
|
|
407
|
+
const noteExpectedAbort = (): void => {
|
|
408
|
+
expectedAbortEnds += 1;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Consume one pending self-abort expectation; false when none is pending. */
|
|
412
|
+
const consumeExpectedAbort = (): boolean => {
|
|
413
|
+
if (expectedAbortEnds <= 0) return false;
|
|
414
|
+
expectedAbortEnds -= 1;
|
|
415
|
+
return true;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const resetExpectedAborts = (): void => {
|
|
419
|
+
expectedAbortEnds = 0;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/** Ordered plan chunks for the current pass; reset on submit/abort/switch. */
|
|
423
|
+
let planAssembler:
|
|
424
|
+
| { chunks: string[]; nextIndex: number; totalChunks?: number }
|
|
425
|
+
| undefined;
|
|
426
|
+
|
|
427
|
+
const resetPlanAssembler = (): void => {
|
|
428
|
+
planAssembler = undefined;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const beginPlan = (totalChunks?: number) => {
|
|
432
|
+
if (config().chunkedSubmit === "off") {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
error: "chunked submission is off; pass the complete plan to review_submit_plan",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
planAssembler = {
|
|
439
|
+
chunks: [],
|
|
440
|
+
nextIndex: 1,
|
|
441
|
+
...(totalChunks !== undefined ? { totalChunks } : {}),
|
|
442
|
+
};
|
|
443
|
+
return { ok: true, maxChunkChars: config().chunkChars };
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const appendPlanChunk = (index: number, content: string) => {
|
|
447
|
+
if (!planAssembler) {
|
|
448
|
+
return { ok: false, error: "call review_plan_begin first" };
|
|
449
|
+
}
|
|
450
|
+
if (index !== planAssembler.nextIndex) {
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
error: `expected chunk ${planAssembler.nextIndex}, got ${index}`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const maxChunkChars = config().chunkChars;
|
|
457
|
+
if (content.length > maxChunkChars) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
error: `chunk ${index} is ${content.length} characters; the limit is ${maxChunkChars}`,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
planAssembler.chunks.push(content);
|
|
464
|
+
planAssembler.nextIndex += 1;
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
received: planAssembler.chunks.length,
|
|
468
|
+
nextIndex: planAssembler.nextIndex,
|
|
469
|
+
totalChars: planAssembler.chunks.reduce(
|
|
470
|
+
(sum, chunk) => sum + chunk.length,
|
|
471
|
+
0,
|
|
472
|
+
),
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const takeAssembledPlan = (): string | undefined => {
|
|
477
|
+
if (!planAssembler || planAssembler.chunks.length === 0) return undefined;
|
|
478
|
+
if (
|
|
479
|
+
planAssembler.totalChunks !== undefined &&
|
|
480
|
+
planAssembler.chunks.length < planAssembler.totalChunks
|
|
481
|
+
) {
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
return planAssembler.chunks.join("");
|
|
485
|
+
};
|
|
486
|
+
|
|
342
487
|
/** Record one provider failure. The message arrives pre-decoded, non-empty. */
|
|
343
488
|
const noteProviderError = (message: string, retries: number): void => {
|
|
344
489
|
lastProviderError = {
|
|
@@ -346,11 +491,14 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
346
491
|
retries: Math.max(0, Math.trunc(retries)),
|
|
347
492
|
at: Date.now(),
|
|
348
493
|
};
|
|
494
|
+
if (state) state.providerError = message;
|
|
349
495
|
};
|
|
350
496
|
|
|
351
497
|
/** Drop the captured error when a new pass starts. */
|
|
352
498
|
const clearProviderError = (): void => {
|
|
353
499
|
lastProviderError = undefined;
|
|
500
|
+
// A pending self-abort expectation belongs to the run that already ended.
|
|
501
|
+
resetExpectedAborts();
|
|
354
502
|
};
|
|
355
503
|
|
|
356
504
|
const config = (): ReviewModeConfig => {
|
|
@@ -485,6 +633,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
485
633
|
/** Abort the reviewer's in-flight run. Best-effort; never throws. */
|
|
486
634
|
async function abortChild(): Promise<void> {
|
|
487
635
|
try {
|
|
636
|
+
noteExpectedAbort();
|
|
488
637
|
await session?.abort();
|
|
489
638
|
} catch {
|
|
490
639
|
/* cancellation is best-effort */
|
|
@@ -590,9 +739,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
590
739
|
}
|
|
591
740
|
|
|
592
741
|
/** Release the child session and its round state (step 10). Never throws. */
|
|
593
|
-
async function disposeSession(): Promise<void> {
|
|
742
|
+
async function disposeSession(options: { keepRoundController?: boolean } = {}): Promise<void> {
|
|
594
743
|
try {
|
|
595
|
-
abortController?.abort();
|
|
744
|
+
if (options.keepRoundController !== true) abortController?.abort();
|
|
745
|
+
noteExpectedAbort();
|
|
596
746
|
await session?.abort();
|
|
597
747
|
} catch {
|
|
598
748
|
/* ignore */
|
|
@@ -607,7 +757,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
607
757
|
transcript = undefined;
|
|
608
758
|
sessionModelKey = undefined;
|
|
609
759
|
gate = createReviewRoundGate();
|
|
610
|
-
|
|
760
|
+
resetPlanAssembler();
|
|
761
|
+
if (options.keepRoundController !== true) abortController = undefined;
|
|
611
762
|
try {
|
|
612
763
|
deps.onState?.(undefined);
|
|
613
764
|
} catch {
|
|
@@ -626,8 +777,12 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
626
777
|
* for a new child session, which is what makes the transcript's run label
|
|
627
778
|
* truthful (fresh boot vs warm resume/next round).
|
|
628
779
|
*/
|
|
629
|
-
async function ensureSession(
|
|
630
|
-
|
|
780
|
+
async function ensureSession(modelRefOverride?: {
|
|
781
|
+
provider: string;
|
|
782
|
+
id: string;
|
|
783
|
+
thinking: string;
|
|
784
|
+
}): Promise<EnsureSessionResult> {
|
|
785
|
+
const modelRef = modelRefOverride ?? deps.reviewerModel();
|
|
631
786
|
const modelKey = modelRef
|
|
632
787
|
? `${modelRef.provider}/${modelRef.id}/${modelRef.thinking}`
|
|
633
788
|
: "";
|
|
@@ -639,7 +794,9 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
639
794
|
"reviewer model changed — restarting the reviewer session.",
|
|
640
795
|
"info",
|
|
641
796
|
);
|
|
642
|
-
|
|
797
|
+
// A recovery switch is part of the SAME round: keep its controller so
|
|
798
|
+
// the loop can start the next attempt instead of seeing an abort.
|
|
799
|
+
await disposeSession({ keepRoundController: true });
|
|
643
800
|
} else {
|
|
644
801
|
return { ok: true, created: false };
|
|
645
802
|
}
|
|
@@ -702,6 +859,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
702
859
|
gate.notifySubmitted(payload);
|
|
703
860
|
return { ok: true, path: written.path };
|
|
704
861
|
},
|
|
862
|
+
beginPlan,
|
|
863
|
+
appendPlanChunk,
|
|
864
|
+
takeAssembledPlan,
|
|
865
|
+
resetPlan: resetPlanAssembler,
|
|
866
|
+
maxChunkChars: config().chunkChars,
|
|
705
867
|
log: deps.log,
|
|
706
868
|
},
|
|
707
869
|
passDone: {
|
|
@@ -744,6 +906,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
744
906
|
findModel: deps.findModel,
|
|
745
907
|
reviewPrompt: deps.promptText(),
|
|
746
908
|
customTools: built.tools,
|
|
909
|
+
providerRetries: cfg.providerRetries,
|
|
747
910
|
// Reuse the already-resolved module instead of importing twice.
|
|
748
911
|
loadSdk: async () => sdk,
|
|
749
912
|
};
|
|
@@ -788,44 +951,68 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
788
951
|
ensureTranscript();
|
|
789
952
|
const child = created.session;
|
|
790
953
|
session.subscribe((event) => {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
954
|
+
// D4: a round ends at `review_pass_done`/settle, but the child may
|
|
955
|
+
// still stream a closing tail. Once the round is inactive its events
|
|
956
|
+
// must never reach the main transcript (that tail used to appear
|
|
957
|
+
// while Build/Plan had already started).
|
|
958
|
+
if (!roundActive) return;
|
|
959
|
+
const type = readEventType(event);
|
|
960
|
+
// Classify BEFORE ingest/capture: a stop the runtime requested itself
|
|
961
|
+
// must never reach the transcript or the provider-error slot. The
|
|
962
|
+
// SDK can label that stop `error` with an abort-shaped message, so
|
|
963
|
+
// the expectation and the classification are both required.
|
|
964
|
+
const stop =
|
|
965
|
+
type === "agent_end"
|
|
966
|
+
? classifyAssistantStop(
|
|
967
|
+
(event as { messages?: unknown } | undefined)?.messages,
|
|
968
|
+
)
|
|
969
|
+
: undefined;
|
|
970
|
+
// Consume on EVERY agent_end so a real error after a silent abort
|
|
971
|
+
// cannot leave a stale expectation behind.
|
|
972
|
+
const expectedAbort = type === "agent_end" && consumeExpectedAbort();
|
|
973
|
+
const selfAbort = expectedAbort && stop?.kind === "aborted";
|
|
974
|
+
if (!selfAbort) {
|
|
975
|
+
let ops: ReviewTranscriptOp[] = [];
|
|
798
976
|
try {
|
|
799
|
-
|
|
977
|
+
ops = ensureTranscript().ingest(event);
|
|
800
978
|
} catch {
|
|
801
|
-
/*
|
|
979
|
+
/* a malformed event must not break the review */
|
|
802
980
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
failure.
|
|
818
|
-
)
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
981
|
+
if (ops.length > 0) {
|
|
982
|
+
try {
|
|
983
|
+
deps.onTranscriptOps?.(ops);
|
|
984
|
+
} catch {
|
|
985
|
+
/* streaming to the main transcript is display-only: never fatal */
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
// Capture provider failures as they happen, so a pass that ends
|
|
989
|
+
// without a submission can be diagnosed with the reviewer's real
|
|
990
|
+
// API error instead of a generic "no plan" message (see runRound).
|
|
991
|
+
// `aborted` is deliberately not captured: it is our own cancel.
|
|
992
|
+
try {
|
|
993
|
+
const failure = readProviderErrorEvent(event);
|
|
994
|
+
if (failure.kind === "retry") {
|
|
995
|
+
noteProviderError(failure.message, failure.retries);
|
|
996
|
+
} else if (failure.kind === "exhaust") {
|
|
997
|
+
noteProviderError(
|
|
998
|
+
failure.message ||
|
|
999
|
+
lastProviderError?.message ||
|
|
1000
|
+
"provider error (no detail)",
|
|
1001
|
+
failure.retries || lastProviderError?.retries || 0,
|
|
1002
|
+
);
|
|
1003
|
+
} else if (failure.kind === "stop") {
|
|
1004
|
+
noteProviderError(
|
|
1005
|
+
failure.message,
|
|
1006
|
+
lastProviderError?.retries ?? 0,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
} catch {
|
|
1010
|
+
/* error capture is diagnostics only: never break the review */
|
|
824
1011
|
}
|
|
825
|
-
} catch {
|
|
826
|
-
/* error capture is diagnostics only: never break the review */
|
|
827
1012
|
}
|
|
828
1013
|
if (type === "agent_settled") {
|
|
1014
|
+
// The abort (if any) has landed; no expectation may outlive it.
|
|
1015
|
+
resetExpectedAborts();
|
|
829
1016
|
noteSettled();
|
|
830
1017
|
} else if (type === "agent_end") {
|
|
831
1018
|
// Fallback for a runtime without `agent_settled`: a retry or a
|
|
@@ -865,15 +1052,23 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
865
1052
|
// visible in `finally` (block scoping), and the external listener must
|
|
866
1053
|
// always be detached when the round ends.
|
|
867
1054
|
let detachExternal: (() => void) | undefined;
|
|
1055
|
+
const recoveryAttempts: string[] = [];
|
|
868
1056
|
|
|
869
1057
|
const fail = (
|
|
870
1058
|
reason: string,
|
|
871
1059
|
planText = originalPlan,
|
|
872
1060
|
): ReviewRoundResult => {
|
|
1061
|
+
const elapsedSeconds = Math.max(
|
|
1062
|
+
1,
|
|
1063
|
+
Math.round((Date.now() - started) / 1000),
|
|
1064
|
+
);
|
|
1065
|
+
const detail = `${reason} (after ${elapsedSeconds}s)`;
|
|
873
1066
|
if (state) {
|
|
874
1067
|
state.phase = "failed";
|
|
875
|
-
state.error =
|
|
1068
|
+
state.error = detail;
|
|
876
1069
|
state.finishedAt = Date.now();
|
|
1070
|
+
state.recoveryAttempts = [...recoveryAttempts];
|
|
1071
|
+
if (lastProviderError) state.providerError = lastProviderError.message;
|
|
877
1072
|
}
|
|
878
1073
|
try {
|
|
879
1074
|
deps.onState?.(state);
|
|
@@ -893,12 +1088,14 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
893
1088
|
modelLabel: "unavailable",
|
|
894
1089
|
}),
|
|
895
1090
|
}),
|
|
896
|
-
reason,
|
|
1091
|
+
reason: detail,
|
|
897
1092
|
};
|
|
898
1093
|
};
|
|
899
1094
|
|
|
900
1095
|
try {
|
|
901
1096
|
roundActive = true;
|
|
1097
|
+
// Chunks from an earlier round must never leak into this one.
|
|
1098
|
+
resetPlanAssembler();
|
|
902
1099
|
// A review with nowhere to submit can only fail: do not spend a run on it.
|
|
903
1100
|
if (!input.planPath) {
|
|
904
1101
|
return fail("no plan path — the reviewer has nowhere to submit");
|
|
@@ -945,25 +1142,36 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
945
1142
|
};
|
|
946
1143
|
}
|
|
947
1144
|
|
|
948
|
-
const
|
|
949
|
-
if (!
|
|
950
|
-
|
|
1145
|
+
const baseModel = deps.reviewerModel();
|
|
1146
|
+
if (!baseModel) {
|
|
1147
|
+
deps.log(
|
|
1148
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
1149
|
+
"warning",
|
|
1150
|
+
);
|
|
1151
|
+
return fail(
|
|
1152
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
1153
|
+
);
|
|
951
1154
|
}
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1155
|
+
const attempts =
|
|
1156
|
+
cfg.providerRecovery === "fallback"
|
|
1157
|
+
? buildReviewerAttempts({
|
|
1158
|
+
base: baseModel,
|
|
1159
|
+
explicitFallback: cfg.fallbackModel,
|
|
1160
|
+
fallbacks: deps.fallbackReviewerModels?.() ?? [],
|
|
1161
|
+
max: 4,
|
|
1162
|
+
})
|
|
1163
|
+
: [{ ...baseModel }];
|
|
1164
|
+
if (attempts.length === 0) {
|
|
1165
|
+
return fail(
|
|
1166
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
1167
|
+
);
|
|
963
1168
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1169
|
+
const attemptLabel = (model: {
|
|
1170
|
+
provider: string;
|
|
1171
|
+
id: string;
|
|
1172
|
+
thinking: string;
|
|
1173
|
+
}): string => `${model.provider}/${model.id} (${model.thinking})`;
|
|
1174
|
+
let lastFailure = "reviewer session unavailable";
|
|
967
1175
|
|
|
968
1176
|
abortController = new AbortController();
|
|
969
1177
|
const rc = abortController;
|
|
@@ -994,6 +1202,74 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
994
1202
|
// settle race below still ends a silent pass quickly either way.
|
|
995
1203
|
const limitMs = cfg.timeoutMs > 0 ? cfg.timeoutMs : undefined;
|
|
996
1204
|
const deadline = limitMs !== undefined ? started + limitMs : undefined;
|
|
1205
|
+
const recoveryDeadline =
|
|
1206
|
+
cfg.recoveryBudgetMs > 0
|
|
1207
|
+
? Math.min(
|
|
1208
|
+
deadline ?? Number.POSITIVE_INFINITY,
|
|
1209
|
+
started + cfg.recoveryBudgetMs,
|
|
1210
|
+
)
|
|
1211
|
+
: deadline;
|
|
1212
|
+
|
|
1213
|
+
for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex += 1) {
|
|
1214
|
+
const attempt = attempts[attemptIndex]!;
|
|
1215
|
+
if (rc.signal.aborted) {
|
|
1216
|
+
if (!state) {
|
|
1217
|
+
state = createReviewState({
|
|
1218
|
+
planHash: hash,
|
|
1219
|
+
planPath: input.planPath,
|
|
1220
|
+
modelLabel: attemptLabel(attempt),
|
|
1221
|
+
round: input.round,
|
|
1222
|
+
now: started,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
state.phase = "aborted";
|
|
1226
|
+
return {
|
|
1227
|
+
ok: false,
|
|
1228
|
+
fallback: true,
|
|
1229
|
+
planText: state.rewrittenPlan ?? originalPlan,
|
|
1230
|
+
planHash: hash,
|
|
1231
|
+
state,
|
|
1232
|
+
reason: "aborted",
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
if (
|
|
1236
|
+
recoveryDeadline !== undefined &&
|
|
1237
|
+
Date.now() >= recoveryDeadline
|
|
1238
|
+
) {
|
|
1239
|
+
lastFailure = `provider recovery budget expired before ${attemptLabel(attempt)}`;
|
|
1240
|
+
break;
|
|
1241
|
+
}
|
|
1242
|
+
recoveryAttempts.push(attemptLabel(attempt));
|
|
1243
|
+
if (attemptIndex > 0) {
|
|
1244
|
+
deps.log(
|
|
1245
|
+
`reviewer recovering on ${attemptLabel(attempt)} (attempt ${attemptIndex + 1}/${attempts.length}).`,
|
|
1246
|
+
"warning",
|
|
1247
|
+
);
|
|
1248
|
+
pushStatus(
|
|
1249
|
+
`reviewer recovering — ${attemptLabel(attempt)} (attempt ${attemptIndex + 1}/${attempts.length})`,
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
const start = await ensureSession(attempt);
|
|
1253
|
+
if (!start.ok || !session) {
|
|
1254
|
+
lastFailure = `reviewer session unavailable for ${attemptLabel(attempt)}`;
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const created = start.created;
|
|
1258
|
+
const child = session;
|
|
1259
|
+
|
|
1260
|
+
if (!state || state.planHash !== hash || state.phase === "done") {
|
|
1261
|
+
state = createReviewState({
|
|
1262
|
+
planHash: hash,
|
|
1263
|
+
planPath: input.planPath,
|
|
1264
|
+
modelLabel: child.modelLabel,
|
|
1265
|
+
round: input.round,
|
|
1266
|
+
now: started,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
state.round = input.round;
|
|
1270
|
+
state.planPath = input.planPath;
|
|
1271
|
+
state.recoveryAttempts = [...recoveryAttempts];
|
|
1272
|
+
setPhase("reviewing");
|
|
997
1273
|
|
|
998
1274
|
// ── Pass 1 ──────────────────────────────────────────────────────
|
|
999
1275
|
await settleInFlight(deadline, rc);
|
|
@@ -1013,6 +1289,9 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1013
1289
|
round: input.round,
|
|
1014
1290
|
totalRounds: cfg.rounds,
|
|
1015
1291
|
verify: cfg.verify && cfg.passes > 1,
|
|
1292
|
+
// `chunkedSubmit: "off"` is the coarse switch; `chunkChars: 0` is the
|
|
1293
|
+
// numeric one. Both mean "ask for the one-shot submission".
|
|
1294
|
+
chunkChars: cfg.chunkedSubmit === "off" ? 0 : cfg.chunkChars,
|
|
1016
1295
|
});
|
|
1017
1296
|
|
|
1018
1297
|
const p1 = await runPass(1, deadline, rc, () => {
|
|
@@ -1024,19 +1303,9 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1024
1303
|
return child.prompt(pass1Prompt);
|
|
1025
1304
|
});
|
|
1026
1305
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
// the bare "No API key found for <provider>." gains the actionable
|
|
1031
|
-
// hint ONLY when the failing provider is the reviewer's own.
|
|
1032
|
-
return fail(
|
|
1033
|
-
`could not prompt the reviewer: ${appendProviderAuthHint(
|
|
1034
|
-
errorText(p1.error),
|
|
1035
|
-
deps.reviewerModel()?.provider,
|
|
1036
|
-
path.join(deps.agentDir, "models.json"),
|
|
1037
|
-
)}`,
|
|
1038
|
-
);
|
|
1039
|
-
}
|
|
1306
|
+
// A round aborted by ESC / the host signal must take the abort path even
|
|
1307
|
+
// when the in-flight send rejected first: the rejection is a symptom of
|
|
1308
|
+
// the abort, not a provider/send failure to recover from.
|
|
1040
1309
|
if (p1.stop === "aborted" || rc.signal.aborted) {
|
|
1041
1310
|
await abortChild();
|
|
1042
1311
|
if (state) state.phase = "aborted";
|
|
@@ -1049,29 +1318,79 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1049
1318
|
reason: "aborted",
|
|
1050
1319
|
};
|
|
1051
1320
|
}
|
|
1321
|
+
if (p1.stop === "error") {
|
|
1322
|
+
await abortChild();
|
|
1323
|
+
const sendMessage = errorText(p1.error);
|
|
1324
|
+
const providerMessage = lastProviderError?.message ?? sendMessage;
|
|
1325
|
+
// Safety net for any provider-source gap the preflight could not see:
|
|
1326
|
+
// the bare "No API key found for <provider>." gains the actionable
|
|
1327
|
+
// hint ONLY when the failing provider is the reviewer's own.
|
|
1328
|
+
lastFailure = `could not prompt the reviewer on ${attemptLabel(
|
|
1329
|
+
attempt,
|
|
1330
|
+
)}: ${appendProviderAuthHint(
|
|
1331
|
+
sendMessage,
|
|
1332
|
+
attempt.provider,
|
|
1333
|
+
path.join(deps.agentDir, "models.json"),
|
|
1334
|
+
)}`;
|
|
1335
|
+
const nextExists = attemptIndex + 1 < attempts.length;
|
|
1336
|
+
const budgetExpired =
|
|
1337
|
+
nextExists &&
|
|
1338
|
+
recoveryDeadline !== undefined &&
|
|
1339
|
+
Date.now() >= recoveryDeadline;
|
|
1340
|
+
const canFallback =
|
|
1341
|
+
nextExists && !rc.signal.aborted && !budgetExpired;
|
|
1342
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1343
|
+
if (canFallback && isRecoverableProviderFailure(providerMessage)) {
|
|
1344
|
+
deps.log(
|
|
1345
|
+
`reviewer attempt failed on ${attemptLabel(attempt)} — ${truncateErrorText(providerMessage)}; trying the next model.`,
|
|
1346
|
+
"warning",
|
|
1347
|
+
);
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
return fail(lastFailure);
|
|
1351
|
+
}
|
|
1052
1352
|
if (!p1.submitted) {
|
|
1053
1353
|
// The run is over (or out of time) and nothing reached the plan file.
|
|
1054
|
-
// Fail now — waiting longer cannot produce a submission.
|
|
1055
1354
|
await abortChild();
|
|
1355
|
+
const nextExists = attemptIndex + 1 < attempts.length;
|
|
1356
|
+
const budgetExpired =
|
|
1357
|
+
nextExists &&
|
|
1358
|
+
recoveryDeadline !== undefined &&
|
|
1359
|
+
Date.now() >= recoveryDeadline;
|
|
1360
|
+
const canFallback =
|
|
1361
|
+
nextExists && !rc.signal.aborted && !budgetExpired;
|
|
1056
1362
|
if (p1.stop === "timeout") {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
);
|
|
1363
|
+
lastFailure = `the reviewer did not finish within ${Math.round(
|
|
1364
|
+
(limitMs ?? 0) / 1000,
|
|
1365
|
+
)}s and submitted no plan on ${attemptLabel(attempt)}`;
|
|
1366
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1367
|
+
if (canFallback) continue;
|
|
1368
|
+
return fail(lastFailure, originalPlan);
|
|
1061
1369
|
}
|
|
1062
1370
|
// A provider failure settles the run without a submission — name the
|
|
1063
1371
|
// real cause (with the provider's own message) instead of blaming the
|
|
1064
1372
|
// write path. The slot only holds errors captured during THIS pass.
|
|
1065
1373
|
if (lastProviderError) {
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1374
|
+
lastFailure = `the reviewer's provider failed after ${
|
|
1375
|
+
lastProviderError.retries
|
|
1376
|
+
} retry attempt(s) on ${attemptLabel(attempt)}: ${truncateErrorText(
|
|
1377
|
+
lastProviderError.message,
|
|
1378
|
+
)}`;
|
|
1379
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1380
|
+
if (
|
|
1381
|
+
canFallback &&
|
|
1382
|
+
isRecoverableProviderFailure(lastProviderError.message)
|
|
1383
|
+
) {
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
return fail(lastFailure, originalPlan);
|
|
1070
1387
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
);
|
|
1388
|
+
lastFailure = `the reviewer finished without submitting a plan on ${attemptLabel(
|
|
1389
|
+
attempt,
|
|
1390
|
+
)} — it has no write path unless review_submit_plan succeeds`;
|
|
1391
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1392
|
+
if (canFallback) continue;
|
|
1393
|
+
return fail(lastFailure, originalPlan);
|
|
1075
1394
|
}
|
|
1076
1395
|
if (p1.stop === "timeout") {
|
|
1077
1396
|
// The protocol completed, the run did not: stop paying for it, but keep
|
|
@@ -1091,6 +1410,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1091
1410
|
cfg.passes > 1 &&
|
|
1092
1411
|
reviewNeedsVerification(state?.findings ?? []);
|
|
1093
1412
|
|
|
1413
|
+
// Pass 1's protocol submission is final; stop a tail that is still
|
|
1414
|
+
// streaming before deciding whether pass 2 runs. This is what keeps the
|
|
1415
|
+
// reviewer quiescent when the handoff opens.
|
|
1416
|
+
if (!wantVerify && session?.isStreaming()) {
|
|
1417
|
+
await abortChild();
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1094
1420
|
if (wantVerify) {
|
|
1095
1421
|
setPhase("verifying");
|
|
1096
1422
|
await settleInFlight(deadline, rc);
|
|
@@ -1108,16 +1434,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1108
1434
|
});
|
|
1109
1435
|
// Name the provider when the verification pass died on one; pass 2
|
|
1110
1436
|
// stays best-effort either way — pass 1's plan is never lost to it.
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1437
|
+
// A round abort (or an abort-shaped stop) is not provider health.
|
|
1438
|
+
const p2Send = p2.error === undefined ? "" : errorText(p2.error);
|
|
1439
|
+
const p2Aborted =
|
|
1440
|
+
rc.signal.aborted ||
|
|
1441
|
+
isAbortStopText(p2Send) ||
|
|
1442
|
+
isAbortStopText(lastProviderError?.message);
|
|
1443
|
+
const p2Provider =
|
|
1444
|
+
!p2Aborted && lastProviderError
|
|
1445
|
+
? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
|
|
1446
|
+
: undefined;
|
|
1114
1447
|
if (p2.error !== undefined) {
|
|
1115
1448
|
// Verification is an enhancement: never lose pass 1's plan over it.
|
|
1449
|
+
let failureText: string;
|
|
1450
|
+
if (p2Aborted) {
|
|
1451
|
+
failureText = "verification pass aborted";
|
|
1452
|
+
} else if (p2Provider) {
|
|
1453
|
+
failureText = `verification pass failed (${p2Provider}; send error: ${p2Send})`;
|
|
1454
|
+
} else {
|
|
1455
|
+
failureText = `verification pass failed (${p2Send})`;
|
|
1456
|
+
}
|
|
1116
1457
|
deps.log(
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
: `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
|
|
1120
|
-
"warning",
|
|
1458
|
+
`${failureText} — keeping pass 1's plan.`,
|
|
1459
|
+
p2Aborted ? "info" : "warning",
|
|
1121
1460
|
);
|
|
1122
1461
|
} else if (p2.submitted) {
|
|
1123
1462
|
finalPlan = p2.submitted.planText;
|
|
@@ -1136,6 +1475,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1136
1475
|
state.phase = "done";
|
|
1137
1476
|
state.rewrittenPlan = finalPlan;
|
|
1138
1477
|
state.finishedAt = Date.now();
|
|
1478
|
+
state.recoveryAttempts = [...recoveryAttempts];
|
|
1139
1479
|
}
|
|
1140
1480
|
try {
|
|
1141
1481
|
deps.onState?.(state);
|
|
@@ -1150,6 +1490,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1150
1490
|
planHash: planHash(finalPlan),
|
|
1151
1491
|
state: state as ReviewState,
|
|
1152
1492
|
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
return fail(
|
|
1496
|
+
`${lastFailure} (attempts: ${attempts.map(attemptLabel).join(" → ")})`,
|
|
1497
|
+
);
|
|
1153
1498
|
} catch (e: unknown) {
|
|
1154
1499
|
return fail(
|
|
1155
1500
|
`review round failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -1160,8 +1505,19 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1160
1505
|
} catch {
|
|
1161
1506
|
/* listener cleanup is best-effort */
|
|
1162
1507
|
}
|
|
1163
|
-
|
|
1508
|
+
// D4: no terminal path may return while the reviewer child is still
|
|
1509
|
+
// streaming. `review_pass_done` ends the protocol, not the SDK run; the
|
|
1510
|
+
// remaining tail could still call review tools or write the plan while
|
|
1511
|
+
// the parent has moved to Build/Plan. The submission is already captured.
|
|
1512
|
+
// Mark the round inactive FIRST: the abort's own `agent_end` is teardown
|
|
1513
|
+
// noise, not a run result, and must never reach the transcript.
|
|
1164
1514
|
roundActive = false;
|
|
1515
|
+
try {
|
|
1516
|
+
if (session?.isStreaming()) await session.abort();
|
|
1517
|
+
} catch {
|
|
1518
|
+
/* cancellation is best-effort */
|
|
1519
|
+
}
|
|
1520
|
+
abortController = undefined;
|
|
1165
1521
|
}
|
|
1166
1522
|
}
|
|
1167
1523
|
|
|
@@ -1181,10 +1537,14 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1181
1537
|
async abortRound() {
|
|
1182
1538
|
try {
|
|
1183
1539
|
abortController?.abort();
|
|
1540
|
+
noteExpectedAbort();
|
|
1184
1541
|
await session?.abort();
|
|
1185
1542
|
} catch {
|
|
1186
1543
|
/* cancellation is best-effort */
|
|
1187
1544
|
}
|
|
1545
|
+
// An aborted round cannot be finalised: drop partial chunks so a retry
|
|
1546
|
+
// starts a clean submission instead of silently stitching two rewrites.
|
|
1547
|
+
resetPlanAssembler();
|
|
1188
1548
|
if (state && state.phase !== "done") state.phase = "aborted";
|
|
1189
1549
|
},
|
|
1190
1550
|
async sendUserMessage(text: string, images?: unknown[]): Promise<boolean> {
|