@dieulc/workflow 0.9.1 → 0.10.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 +82 -15
- package/edit-policy.ts +11 -64
- package/index.ts +1136 -200
- package/package.json +10 -6
- package/permissions.ts +393 -93
- package/provider-retarget.ts +258 -0
- package/review-handoff.ts +125 -0
- package/review-recovery.ts +65 -0
- package/review-runtime.ts +318 -48
- package/review-tools.ts +303 -6
- package/review.ts +321 -4
- package/shell-text.ts +542 -0
- package/utils.ts +42 -0
package/review-runtime.ts
CHANGED
|
@@ -26,6 +26,7 @@ import * as path from "node:path";
|
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
DEFAULT_REVIEW_MODE_CONFIG,
|
|
29
|
+
buildReviewerAttempts,
|
|
29
30
|
createReviewRoundGate,
|
|
30
31
|
createReviewSession,
|
|
31
32
|
createReviewState,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
type CreateReviewSessionOptions,
|
|
38
39
|
type CreateReviewSessionResult,
|
|
39
40
|
type ReviewChildSession,
|
|
41
|
+
type ReviewFallbackModel,
|
|
40
42
|
type ReviewModeConfig,
|
|
41
43
|
type ReviewPassResult,
|
|
42
44
|
type ReviewPhase,
|
|
@@ -50,6 +52,7 @@ import {
|
|
|
50
52
|
ReviewTranscript as Transcript,
|
|
51
53
|
} from "./review.ts";
|
|
52
54
|
import { appendProviderAuthHint } from "./review-providers.ts";
|
|
55
|
+
import { isRecoverableProviderFailure } from "./review-recovery.ts";
|
|
53
56
|
import { createReviewTools } from "./review-tools.ts";
|
|
54
57
|
import { buildPlanDiff, renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
|
|
55
58
|
|
|
@@ -75,6 +78,8 @@ export interface ReviewRuntimeDeps {
|
|
|
75
78
|
reviewerModel: () =>
|
|
76
79
|
| { provider: string; id: string; thinking: string }
|
|
77
80
|
| undefined;
|
|
81
|
+
/** Automatic fallback models for provider recovery (parent, base reviewer, base planner). */
|
|
82
|
+
fallbackReviewerModels?: () => readonly ReviewFallbackModel[];
|
|
78
83
|
/** Resolve a model from the parent's registry. */
|
|
79
84
|
findModel: (provider: string, id: string) => unknown;
|
|
80
85
|
/**
|
|
@@ -193,7 +198,15 @@ export function buildPass1Prompt(input: {
|
|
|
193
198
|
round: number;
|
|
194
199
|
totalRounds: number;
|
|
195
200
|
verify: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* Max characters per submitted chunk (step 18). `0` disables chunked mode.
|
|
203
|
+
* When set and the plan is longer than two chunks, pass 1 asks for the
|
|
204
|
+
* chunked protocol instead of one oversized response.
|
|
205
|
+
*/
|
|
206
|
+
chunkChars?: number;
|
|
196
207
|
}): string {
|
|
208
|
+
const chunkChars = input.chunkChars ?? 0;
|
|
209
|
+
const chunked = chunkChars > 0 && input.planText.length > chunkChars * 2;
|
|
197
210
|
const parts: string[] = [
|
|
198
211
|
`[REVIEW MODE — PASS 1 of ${input.verify ? 2 : 1} — round ${input.round}/${input.totalRounds}]`,
|
|
199
212
|
"",
|
|
@@ -256,13 +269,53 @@ export function buildPass1Prompt(input: {
|
|
|
256
269
|
);
|
|
257
270
|
}
|
|
258
271
|
|
|
272
|
+
parts.push(
|
|
273
|
+
"",
|
|
274
|
+
"## Recovery",
|
|
275
|
+
"",
|
|
276
|
+
"A provider may close this stream before you finish. If the harness retries",
|
|
277
|
+
"this review on another model, start again from the plan below; do not assume",
|
|
278
|
+
"the previous model's transcript is available. Submit the complete rewrite as",
|
|
279
|
+
"soon as it is ready — do not hold it back to add more polish.",
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
if (chunked) {
|
|
283
|
+
parts.push(
|
|
284
|
+
"",
|
|
285
|
+
"## Chunked submission (required for this plan)",
|
|
286
|
+
"",
|
|
287
|
+
`This plan is long enough that a single response may be cut off. Send the`,
|
|
288
|
+
`rewrite in ordered chunks of at most ${chunkChars} characters:`,
|
|
289
|
+
"",
|
|
290
|
+
"1. `review_plan_begin` (optionally with `totalChunks`).",
|
|
291
|
+
"2. `review_plan_chunk` with `index` 1, 2, 3, … — never skip or repeat. The",
|
|
292
|
+
" tool echoes the next expected index; if a chunk is rejected, resend that",
|
|
293
|
+
" index and continue (accepted chunks are kept).",
|
|
294
|
+
"3. `review_plan_finalize` with your findings, verdict and summary. Nothing is",
|
|
295
|
+
" written before this call succeeds.",
|
|
296
|
+
"",
|
|
297
|
+
"Do not call `review_submit_plan` with a full `planMarkdown` in this round, and",
|
|
298
|
+
"do not repeat earlier chunks. Split on heading boundaries where possible.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
259
302
|
parts.push(
|
|
260
303
|
"",
|
|
261
304
|
"## Required output",
|
|
262
305
|
"",
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
306
|
+
...(chunked
|
|
307
|
+
? [
|
|
308
|
+
"1. `review_plan_begin`, then `review_plan_chunk` for every part of the",
|
|
309
|
+
" COMPLETE revised plan (readable markdown, original structure preserved,",
|
|
310
|
+
" no `Review changes` section — it is generated).",
|
|
311
|
+
"2. `review_plan_finalize` with your findings, verdict and summary.",
|
|
312
|
+
"3. `review_pass_done` with the same findings.",
|
|
313
|
+
]
|
|
314
|
+
: [
|
|
315
|
+
"1. `review_submit_plan` with the COMPLETE revised plan (readable markdown,",
|
|
316
|
+
" original structure preserved, no `Review changes` section — it is generated).",
|
|
317
|
+
"2. `review_pass_done` with your verdict and the same findings.",
|
|
318
|
+
]),
|
|
266
319
|
"",
|
|
267
320
|
"You may run read-only and test/lint/typecheck commands, but you cannot build or",
|
|
268
321
|
"write any file other than the plan. Say what you could not verify instead of",
|
|
@@ -339,6 +392,71 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
339
392
|
| { message: string; retries: number; at: number }
|
|
340
393
|
| undefined;
|
|
341
394
|
|
|
395
|
+
/** Ordered plan chunks for the current pass; reset on submit/abort/switch. */
|
|
396
|
+
let planAssembler:
|
|
397
|
+
| { chunks: string[]; nextIndex: number; totalChunks?: number }
|
|
398
|
+
| undefined;
|
|
399
|
+
|
|
400
|
+
const resetPlanAssembler = (): void => {
|
|
401
|
+
planAssembler = undefined;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const beginPlan = (totalChunks?: number) => {
|
|
405
|
+
if (config().chunkedSubmit === "off") {
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
error: "chunked submission is off; pass the complete plan to review_submit_plan",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
planAssembler = {
|
|
412
|
+
chunks: [],
|
|
413
|
+
nextIndex: 1,
|
|
414
|
+
...(totalChunks !== undefined ? { totalChunks } : {}),
|
|
415
|
+
};
|
|
416
|
+
return { ok: true, maxChunkChars: config().chunkChars };
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const appendPlanChunk = (index: number, content: string) => {
|
|
420
|
+
if (!planAssembler) {
|
|
421
|
+
return { ok: false, error: "call review_plan_begin first" };
|
|
422
|
+
}
|
|
423
|
+
if (index !== planAssembler.nextIndex) {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
error: `expected chunk ${planAssembler.nextIndex}, got ${index}`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
const maxChunkChars = config().chunkChars;
|
|
430
|
+
if (content.length > maxChunkChars) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
error: `chunk ${index} is ${content.length} characters; the limit is ${maxChunkChars}`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
planAssembler.chunks.push(content);
|
|
437
|
+
planAssembler.nextIndex += 1;
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
received: planAssembler.chunks.length,
|
|
441
|
+
nextIndex: planAssembler.nextIndex,
|
|
442
|
+
totalChars: planAssembler.chunks.reduce(
|
|
443
|
+
(sum, chunk) => sum + chunk.length,
|
|
444
|
+
0,
|
|
445
|
+
),
|
|
446
|
+
};
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const takeAssembledPlan = (): string | undefined => {
|
|
450
|
+
if (!planAssembler || planAssembler.chunks.length === 0) return undefined;
|
|
451
|
+
if (
|
|
452
|
+
planAssembler.totalChunks !== undefined &&
|
|
453
|
+
planAssembler.chunks.length < planAssembler.totalChunks
|
|
454
|
+
) {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
return planAssembler.chunks.join("");
|
|
458
|
+
};
|
|
459
|
+
|
|
342
460
|
/** Record one provider failure. The message arrives pre-decoded, non-empty. */
|
|
343
461
|
const noteProviderError = (message: string, retries: number): void => {
|
|
344
462
|
lastProviderError = {
|
|
@@ -346,6 +464,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
346
464
|
retries: Math.max(0, Math.trunc(retries)),
|
|
347
465
|
at: Date.now(),
|
|
348
466
|
};
|
|
467
|
+
if (state) state.providerError = message;
|
|
349
468
|
};
|
|
350
469
|
|
|
351
470
|
/** Drop the captured error when a new pass starts. */
|
|
@@ -590,9 +709,9 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
590
709
|
}
|
|
591
710
|
|
|
592
711
|
/** Release the child session and its round state (step 10). Never throws. */
|
|
593
|
-
async function disposeSession(): Promise<void> {
|
|
712
|
+
async function disposeSession(options: { keepRoundController?: boolean } = {}): Promise<void> {
|
|
594
713
|
try {
|
|
595
|
-
abortController?.abort();
|
|
714
|
+
if (options.keepRoundController !== true) abortController?.abort();
|
|
596
715
|
await session?.abort();
|
|
597
716
|
} catch {
|
|
598
717
|
/* ignore */
|
|
@@ -607,7 +726,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
607
726
|
transcript = undefined;
|
|
608
727
|
sessionModelKey = undefined;
|
|
609
728
|
gate = createReviewRoundGate();
|
|
610
|
-
|
|
729
|
+
resetPlanAssembler();
|
|
730
|
+
if (options.keepRoundController !== true) abortController = undefined;
|
|
611
731
|
try {
|
|
612
732
|
deps.onState?.(undefined);
|
|
613
733
|
} catch {
|
|
@@ -626,8 +746,12 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
626
746
|
* for a new child session, which is what makes the transcript's run label
|
|
627
747
|
* truthful (fresh boot vs warm resume/next round).
|
|
628
748
|
*/
|
|
629
|
-
async function ensureSession(
|
|
630
|
-
|
|
749
|
+
async function ensureSession(modelRefOverride?: {
|
|
750
|
+
provider: string;
|
|
751
|
+
id: string;
|
|
752
|
+
thinking: string;
|
|
753
|
+
}): Promise<EnsureSessionResult> {
|
|
754
|
+
const modelRef = modelRefOverride ?? deps.reviewerModel();
|
|
631
755
|
const modelKey = modelRef
|
|
632
756
|
? `${modelRef.provider}/${modelRef.id}/${modelRef.thinking}`
|
|
633
757
|
: "";
|
|
@@ -639,7 +763,9 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
639
763
|
"reviewer model changed — restarting the reviewer session.",
|
|
640
764
|
"info",
|
|
641
765
|
);
|
|
642
|
-
|
|
766
|
+
// A recovery switch is part of the SAME round: keep its controller so
|
|
767
|
+
// the loop can start the next attempt instead of seeing an abort.
|
|
768
|
+
await disposeSession({ keepRoundController: true });
|
|
643
769
|
} else {
|
|
644
770
|
return { ok: true, created: false };
|
|
645
771
|
}
|
|
@@ -702,6 +828,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
702
828
|
gate.notifySubmitted(payload);
|
|
703
829
|
return { ok: true, path: written.path };
|
|
704
830
|
},
|
|
831
|
+
beginPlan,
|
|
832
|
+
appendPlanChunk,
|
|
833
|
+
takeAssembledPlan,
|
|
834
|
+
resetPlan: resetPlanAssembler,
|
|
835
|
+
maxChunkChars: config().chunkChars,
|
|
705
836
|
log: deps.log,
|
|
706
837
|
},
|
|
707
838
|
passDone: {
|
|
@@ -744,6 +875,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
744
875
|
findModel: deps.findModel,
|
|
745
876
|
reviewPrompt: deps.promptText(),
|
|
746
877
|
customTools: built.tools,
|
|
878
|
+
providerRetries: cfg.providerRetries,
|
|
747
879
|
// Reuse the already-resolved module instead of importing twice.
|
|
748
880
|
loadSdk: async () => sdk,
|
|
749
881
|
};
|
|
@@ -865,15 +997,23 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
865
997
|
// visible in `finally` (block scoping), and the external listener must
|
|
866
998
|
// always be detached when the round ends.
|
|
867
999
|
let detachExternal: (() => void) | undefined;
|
|
1000
|
+
const recoveryAttempts: string[] = [];
|
|
868
1001
|
|
|
869
1002
|
const fail = (
|
|
870
1003
|
reason: string,
|
|
871
1004
|
planText = originalPlan,
|
|
872
1005
|
): ReviewRoundResult => {
|
|
1006
|
+
const elapsedSeconds = Math.max(
|
|
1007
|
+
1,
|
|
1008
|
+
Math.round((Date.now() - started) / 1000),
|
|
1009
|
+
);
|
|
1010
|
+
const detail = `${reason} (after ${elapsedSeconds}s)`;
|
|
873
1011
|
if (state) {
|
|
874
1012
|
state.phase = "failed";
|
|
875
|
-
state.error =
|
|
1013
|
+
state.error = detail;
|
|
876
1014
|
state.finishedAt = Date.now();
|
|
1015
|
+
state.recoveryAttempts = [...recoveryAttempts];
|
|
1016
|
+
if (lastProviderError) state.providerError = lastProviderError.message;
|
|
877
1017
|
}
|
|
878
1018
|
try {
|
|
879
1019
|
deps.onState?.(state);
|
|
@@ -893,12 +1033,14 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
893
1033
|
modelLabel: "unavailable",
|
|
894
1034
|
}),
|
|
895
1035
|
}),
|
|
896
|
-
reason,
|
|
1036
|
+
reason: detail,
|
|
897
1037
|
};
|
|
898
1038
|
};
|
|
899
1039
|
|
|
900
1040
|
try {
|
|
901
1041
|
roundActive = true;
|
|
1042
|
+
// Chunks from an earlier round must never leak into this one.
|
|
1043
|
+
resetPlanAssembler();
|
|
902
1044
|
// A review with nowhere to submit can only fail: do not spend a run on it.
|
|
903
1045
|
if (!input.planPath) {
|
|
904
1046
|
return fail("no plan path — the reviewer has nowhere to submit");
|
|
@@ -945,25 +1087,36 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
945
1087
|
};
|
|
946
1088
|
}
|
|
947
1089
|
|
|
948
|
-
const
|
|
949
|
-
if (!
|
|
950
|
-
|
|
1090
|
+
const baseModel = deps.reviewerModel();
|
|
1091
|
+
if (!baseModel) {
|
|
1092
|
+
deps.log(
|
|
1093
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
1094
|
+
"warning",
|
|
1095
|
+
);
|
|
1096
|
+
return fail(
|
|
1097
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
1098
|
+
);
|
|
951
1099
|
}
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1100
|
+
const attempts =
|
|
1101
|
+
cfg.providerRecovery === "fallback"
|
|
1102
|
+
? buildReviewerAttempts({
|
|
1103
|
+
base: baseModel,
|
|
1104
|
+
explicitFallback: cfg.fallbackModel,
|
|
1105
|
+
fallbacks: deps.fallbackReviewerModels?.() ?? [],
|
|
1106
|
+
max: 4,
|
|
1107
|
+
})
|
|
1108
|
+
: [{ ...baseModel }];
|
|
1109
|
+
if (attempts.length === 0) {
|
|
1110
|
+
return fail(
|
|
1111
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
1112
|
+
);
|
|
963
1113
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1114
|
+
const attemptLabel = (model: {
|
|
1115
|
+
provider: string;
|
|
1116
|
+
id: string;
|
|
1117
|
+
thinking: string;
|
|
1118
|
+
}): string => `${model.provider}/${model.id} (${model.thinking})`;
|
|
1119
|
+
let lastFailure = "reviewer session unavailable";
|
|
967
1120
|
|
|
968
1121
|
abortController = new AbortController();
|
|
969
1122
|
const rc = abortController;
|
|
@@ -994,6 +1147,74 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
994
1147
|
// settle race below still ends a silent pass quickly either way.
|
|
995
1148
|
const limitMs = cfg.timeoutMs > 0 ? cfg.timeoutMs : undefined;
|
|
996
1149
|
const deadline = limitMs !== undefined ? started + limitMs : undefined;
|
|
1150
|
+
const recoveryDeadline =
|
|
1151
|
+
cfg.recoveryBudgetMs > 0
|
|
1152
|
+
? Math.min(
|
|
1153
|
+
deadline ?? Number.POSITIVE_INFINITY,
|
|
1154
|
+
started + cfg.recoveryBudgetMs,
|
|
1155
|
+
)
|
|
1156
|
+
: deadline;
|
|
1157
|
+
|
|
1158
|
+
for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex += 1) {
|
|
1159
|
+
const attempt = attempts[attemptIndex]!;
|
|
1160
|
+
if (rc.signal.aborted) {
|
|
1161
|
+
if (!state) {
|
|
1162
|
+
state = createReviewState({
|
|
1163
|
+
planHash: hash,
|
|
1164
|
+
planPath: input.planPath,
|
|
1165
|
+
modelLabel: attemptLabel(attempt),
|
|
1166
|
+
round: input.round,
|
|
1167
|
+
now: started,
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
state.phase = "aborted";
|
|
1171
|
+
return {
|
|
1172
|
+
ok: false,
|
|
1173
|
+
fallback: true,
|
|
1174
|
+
planText: state.rewrittenPlan ?? originalPlan,
|
|
1175
|
+
planHash: hash,
|
|
1176
|
+
state,
|
|
1177
|
+
reason: "aborted",
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (
|
|
1181
|
+
recoveryDeadline !== undefined &&
|
|
1182
|
+
Date.now() >= recoveryDeadline
|
|
1183
|
+
) {
|
|
1184
|
+
lastFailure = `provider recovery budget expired before ${attemptLabel(attempt)}`;
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
recoveryAttempts.push(attemptLabel(attempt));
|
|
1188
|
+
if (attemptIndex > 0) {
|
|
1189
|
+
deps.log(
|
|
1190
|
+
`reviewer recovering on ${attemptLabel(attempt)} (attempt ${attemptIndex + 1}/${attempts.length}).`,
|
|
1191
|
+
"warning",
|
|
1192
|
+
);
|
|
1193
|
+
pushStatus(
|
|
1194
|
+
`reviewer recovering — ${attemptLabel(attempt)} (attempt ${attemptIndex + 1}/${attempts.length})`,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
const start = await ensureSession(attempt);
|
|
1198
|
+
if (!start.ok || !session) {
|
|
1199
|
+
lastFailure = `reviewer session unavailable for ${attemptLabel(attempt)}`;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
const created = start.created;
|
|
1203
|
+
const child = session;
|
|
1204
|
+
|
|
1205
|
+
if (!state || state.planHash !== hash || state.phase === "done") {
|
|
1206
|
+
state = createReviewState({
|
|
1207
|
+
planHash: hash,
|
|
1208
|
+
planPath: input.planPath,
|
|
1209
|
+
modelLabel: child.modelLabel,
|
|
1210
|
+
round: input.round,
|
|
1211
|
+
now: started,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
state.round = input.round;
|
|
1215
|
+
state.planPath = input.planPath;
|
|
1216
|
+
state.recoveryAttempts = [...recoveryAttempts];
|
|
1217
|
+
setPhase("reviewing");
|
|
997
1218
|
|
|
998
1219
|
// ── Pass 1 ──────────────────────────────────────────────────────
|
|
999
1220
|
await settleInFlight(deadline, rc);
|
|
@@ -1013,6 +1234,9 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1013
1234
|
round: input.round,
|
|
1014
1235
|
totalRounds: cfg.rounds,
|
|
1015
1236
|
verify: cfg.verify && cfg.passes > 1,
|
|
1237
|
+
// `chunkedSubmit: "off"` is the coarse switch; `chunkChars: 0` is the
|
|
1238
|
+
// numeric one. Both mean "ask for the one-shot submission".
|
|
1239
|
+
chunkChars: cfg.chunkedSubmit === "off" ? 0 : cfg.chunkChars,
|
|
1016
1240
|
});
|
|
1017
1241
|
|
|
1018
1242
|
const p1 = await runPass(1, deadline, rc, () => {
|
|
@@ -1026,16 +1250,34 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1026
1250
|
|
|
1027
1251
|
if (p1.stop === "error") {
|
|
1028
1252
|
await abortChild();
|
|
1253
|
+
const sendMessage = errorText(p1.error);
|
|
1254
|
+
const providerMessage = lastProviderError?.message ?? sendMessage;
|
|
1029
1255
|
// Safety net for any provider-source gap the preflight could not see:
|
|
1030
1256
|
// the bare "No API key found for <provider>." gains the actionable
|
|
1031
1257
|
// hint ONLY when the failing provider is the reviewer's own.
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
)
|
|
1038
|
-
)
|
|
1258
|
+
lastFailure = `could not prompt the reviewer on ${attemptLabel(
|
|
1259
|
+
attempt,
|
|
1260
|
+
)}: ${appendProviderAuthHint(
|
|
1261
|
+
sendMessage,
|
|
1262
|
+
attempt.provider,
|
|
1263
|
+
path.join(deps.agentDir, "models.json"),
|
|
1264
|
+
)}`;
|
|
1265
|
+
const nextExists = attemptIndex + 1 < attempts.length;
|
|
1266
|
+
const budgetExpired =
|
|
1267
|
+
nextExists &&
|
|
1268
|
+
recoveryDeadline !== undefined &&
|
|
1269
|
+
Date.now() >= recoveryDeadline;
|
|
1270
|
+
const canFallback =
|
|
1271
|
+
nextExists && !rc.signal.aborted && !budgetExpired;
|
|
1272
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1273
|
+
if (canFallback && isRecoverableProviderFailure(providerMessage)) {
|
|
1274
|
+
deps.log(
|
|
1275
|
+
`reviewer attempt failed on ${attemptLabel(attempt)} — ${truncateErrorText(providerMessage)}; trying the next model.`,
|
|
1276
|
+
"warning",
|
|
1277
|
+
);
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
return fail(lastFailure);
|
|
1039
1281
|
}
|
|
1040
1282
|
if (p1.stop === "aborted" || rc.signal.aborted) {
|
|
1041
1283
|
await abortChild();
|
|
@@ -1051,27 +1293,46 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1051
1293
|
}
|
|
1052
1294
|
if (!p1.submitted) {
|
|
1053
1295
|
// The run is over (or out of time) and nothing reached the plan file.
|
|
1054
|
-
// Fail now — waiting longer cannot produce a submission.
|
|
1055
1296
|
await abortChild();
|
|
1297
|
+
const nextExists = attemptIndex + 1 < attempts.length;
|
|
1298
|
+
const budgetExpired =
|
|
1299
|
+
nextExists &&
|
|
1300
|
+
recoveryDeadline !== undefined &&
|
|
1301
|
+
Date.now() >= recoveryDeadline;
|
|
1302
|
+
const canFallback =
|
|
1303
|
+
nextExists && !rc.signal.aborted && !budgetExpired;
|
|
1056
1304
|
if (p1.stop === "timeout") {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
);
|
|
1305
|
+
lastFailure = `the reviewer did not finish within ${Math.round(
|
|
1306
|
+
(limitMs ?? 0) / 1000,
|
|
1307
|
+
)}s and submitted no plan on ${attemptLabel(attempt)}`;
|
|
1308
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1309
|
+
if (canFallback) continue;
|
|
1310
|
+
return fail(lastFailure, originalPlan);
|
|
1061
1311
|
}
|
|
1062
1312
|
// A provider failure settles the run without a submission — name the
|
|
1063
1313
|
// real cause (with the provider's own message) instead of blaming the
|
|
1064
1314
|
// write path. The slot only holds errors captured during THIS pass.
|
|
1065
1315
|
if (lastProviderError) {
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1316
|
+
lastFailure = `the reviewer's provider failed after ${
|
|
1317
|
+
lastProviderError.retries
|
|
1318
|
+
} retry attempt(s) on ${attemptLabel(attempt)}: ${truncateErrorText(
|
|
1319
|
+
lastProviderError.message,
|
|
1320
|
+
)}`;
|
|
1321
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1322
|
+
if (
|
|
1323
|
+
canFallback &&
|
|
1324
|
+
isRecoverableProviderFailure(lastProviderError.message)
|
|
1325
|
+
) {
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
return fail(lastFailure, originalPlan);
|
|
1070
1329
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
);
|
|
1330
|
+
lastFailure = `the reviewer finished without submitting a plan on ${attemptLabel(
|
|
1331
|
+
attempt,
|
|
1332
|
+
)} — it has no write path unless review_submit_plan succeeds`;
|
|
1333
|
+
if (budgetExpired) lastFailure += " — provider recovery budget expired";
|
|
1334
|
+
if (canFallback) continue;
|
|
1335
|
+
return fail(lastFailure, originalPlan);
|
|
1075
1336
|
}
|
|
1076
1337
|
if (p1.stop === "timeout") {
|
|
1077
1338
|
// The protocol completed, the run did not: stop paying for it, but keep
|
|
@@ -1136,6 +1397,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1136
1397
|
state.phase = "done";
|
|
1137
1398
|
state.rewrittenPlan = finalPlan;
|
|
1138
1399
|
state.finishedAt = Date.now();
|
|
1400
|
+
state.recoveryAttempts = [...recoveryAttempts];
|
|
1139
1401
|
}
|
|
1140
1402
|
try {
|
|
1141
1403
|
deps.onState?.(state);
|
|
@@ -1150,6 +1412,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1150
1412
|
planHash: planHash(finalPlan),
|
|
1151
1413
|
state: state as ReviewState,
|
|
1152
1414
|
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
return fail(
|
|
1418
|
+
`${lastFailure} (attempts: ${attempts.map(attemptLabel).join(" → ")})`,
|
|
1419
|
+
);
|
|
1153
1420
|
} catch (e: unknown) {
|
|
1154
1421
|
return fail(
|
|
1155
1422
|
`review round failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -1185,6 +1452,9 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1185
1452
|
} catch {
|
|
1186
1453
|
/* cancellation is best-effort */
|
|
1187
1454
|
}
|
|
1455
|
+
// An aborted round cannot be finalised: drop partial chunks so a retry
|
|
1456
|
+
// starts a clean submission instead of silently stitching two rewrites.
|
|
1457
|
+
resetPlanAssembler();
|
|
1188
1458
|
if (state && state.phase !== "done") state.phase = "aborted";
|
|
1189
1459
|
},
|
|
1190
1460
|
async sendUserMessage(text: string, images?: unknown[]): Promise<boolean> {
|