@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/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
- "1. `review_submit_plan` with the COMPLETE revised plan (readable markdown,",
264
- " original structure preserved, no `Review changes` section — it is generated).",
265
- "2. `review_pass_done` with your verdict and the same findings.",
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
- abortController = undefined;
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(): Promise<EnsureSessionResult> {
630
- const modelRef = deps.reviewerModel();
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
- await disposeSession();
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 = reason;
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 start = await ensureSession();
949
- if (!start.ok || !session) {
950
- return fail("reviewer session unavailable");
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 created = start.created;
953
- const child = session;
954
-
955
- if (!state || state.planHash !== hash || state.phase === "done") {
956
- state = createReviewState({
957
- planHash: hash,
958
- planPath: input.planPath,
959
- modelLabel: child.modelLabel,
960
- round: input.round,
961
- now: started,
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
- state.round = input.round;
965
- state.planPath = input.planPath;
966
- setPhase("reviewing");
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
- 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
- );
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
- return fail(
1058
- `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`,
1059
- originalPlan,
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
- return fail(
1067
- `the reviewer's provider failed after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`,
1068
- originalPlan,
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
- return fail(
1072
- "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
1073
- originalPlan,
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> {