@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/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
- "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.",
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
- abortController = undefined;
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(): Promise<EnsureSessionResult> {
630
- const modelRef = deps.reviewerModel();
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
- await disposeSession();
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
- let ops: ReviewTranscriptOp[] = [];
792
- try {
793
- ops = ensureTranscript().ingest(event);
794
- } catch {
795
- /* a malformed event must not break the review */
796
- }
797
- if (ops.length > 0) {
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
- deps.onTranscriptOps?.(ops);
977
+ ops = ensureTranscript().ingest(event);
800
978
  } catch {
801
- /* streaming to the main transcript is display-only: never fatal */
979
+ /* a malformed event must not break the review */
802
980
  }
803
- }
804
- const type = readEventType(event);
805
- // Capture provider failures as they happen, so a pass that ends
806
- // without a submission can be diagnosed with the reviewer's real
807
- // API error instead of a generic "no plan" message (see runRound).
808
- try {
809
- const failure = readProviderErrorEvent(event);
810
- if (failure.kind === "retry") {
811
- noteProviderError(failure.message, failure.retries);
812
- } else if (failure.kind === "exhaust") {
813
- noteProviderError(
814
- failure.message ||
815
- lastProviderError?.message ||
816
- "provider error (no detail)",
817
- failure.retries || lastProviderError?.retries || 0,
818
- );
819
- } else if (failure.kind === "stop") {
820
- noteProviderError(
821
- failure.message,
822
- lastProviderError?.retries ?? 0,
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 = reason;
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 start = await ensureSession();
949
- if (!start.ok || !session) {
950
- return fail("reviewer session unavailable");
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 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
- });
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
- state.round = input.round;
965
- state.planPath = input.planPath;
966
- setPhase("reviewing");
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
- if (p1.stop === "error") {
1028
- await abortChild();
1029
- // Safety net for any provider-source gap the preflight could not see:
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
- return fail(
1058
- `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`,
1059
- originalPlan,
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
- return fail(
1067
- `the reviewer's provider failed after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`,
1068
- originalPlan,
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
- return fail(
1072
- "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
1073
- originalPlan,
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
- const p2Provider = lastProviderError
1112
- ? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
1113
- : undefined;
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
- p2Provider
1118
- ? `verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
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
- abortController = undefined;
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> {