feat: use stored review on resume if interrupted

This commit is contained in:
2026-07-31 09:57:51 -04:00
parent 284b3720af
commit 7253e51fb9
2 changed files with 984 additions and 939 deletions

View File

@@ -161,9 +161,9 @@ async function selectLoopOptions(
saveReviews = config.execution.saveReviews; saveReviews = config.execution.saveReviews;
} else { } else {
const saveChoice = await ctx.ui.select( const saveChoice = await ctx.ui.select(
"Save full review output to disk?", "Save full review output to disk? (recommended — enables review feedback recovery when resuming interrupted loops)",
[ [
"Yes — write each review to .ralpi/reviews/<loop>/<task>.md", "Yes — write each review to .ralpi/reviews/<loop>/<task>.json",
"No — keep reviews in-chat only", "No — keep reviews in-chat only",
], ],
); );
@@ -223,7 +223,9 @@ async function selectPRDToResume(
).length; ).length;
const relPath = path.relative(ctx.cwd, entry.prd.sourcePath); const relPath = path.relative(ctx.cwd, entry.prd.sourcePath);
const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString(); const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString();
return `${relPath}${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`; return `${relPath}${completed}/${total} done${
failed ? `, ${failed} failed` : ""
} · ${updated}`;
}); });
const selected = await ctx.ui.select( const selected = await ctx.ui.select(
@@ -929,7 +931,9 @@ async function resumeLoop(
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`) .map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
.join("; "); .join("; ");
sendChatMessage?.( sendChatMessage?.(
`${conflictIds.join(", ")} — merge conflict on resume-finalize; re-running (${detail})`, `${conflictIds.join(
", ",
)} — merge conflict on resume-finalize; re-running (${detail})`,
); );
} }
} }

View File

@@ -23,6 +23,7 @@ import { extractReflection } from "./reflection";
import { import {
extractReview, extractReview,
saveReviewToFile as saveReviewJson, saveReviewToFile as saveReviewJson,
loadReview as loadReviewJson,
verdictGlyph, verdictGlyph,
verdictSummary, verdictSummary,
} from "./review"; } from "./review";
@@ -813,6 +814,27 @@ async function executeTask(
? captureGitHead(worktreeDir) ? captureGitHead(worktreeDir)
: undefined; : undefined;
// Load a prior review from disk when resuming an interrupted loop.
// If the previous run's review rejected the task (verdict 'fail') and the
// re-execution was lost to a crash/connection error, the findings would
// otherwise be orphaned. Injecting them here gives the fresh run the
// reviewer's feedback so it doesn't reintroduce the same blockers.
let priorReview: ReviewResult | undefined;
if (config.execution.autoReview) {
const loaded = loadReviewJson(
projectDir,
config.paths.reviewsDir,
task.id,
progress.getKey(),
);
if (loaded && loaded.verdict === "fail") {
priorReview = loaded;
sendChatMessage?.(
`${task.id} · ${task.title} — resuming with prior review feedback (${loaded.findings.length} findings)`,
);
}
}
// Run the task // Run the task
const result = await runTask( const result = await runTask(
task, task,
@@ -825,6 +847,7 @@ async function executeTask(
parallelState, parallelState,
currentModel, currentModel,
batchRender, batchRender,
priorReview,
); );
if (result.success) { if (result.success) {
@@ -1014,8 +1037,18 @@ async function executeTask(
`↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`, `↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`,
); );
// Re-execute the task with review feedback injected. // Re-execute the task with review feedback injected, cycling
const fixResult = await runTask( // through failover models on connection errors so a flaky
// provider doesn't waste the review-fix attempt.
const fixModels = buildFailoverModels(currentModel, roundRobin);
let fixResult: Awaited<ReturnType<typeof runTask>> | undefined;
for (
let fixAttempt = 0;
fixAttempt < fixModels.length;
fixAttempt++
) {
const fixModel = fixModels[fixAttempt];
fixResult = await runTask(
task, task,
project, project,
config, config,
@@ -1024,14 +1057,22 @@ async function executeTask(
sendChatMessage, sendChatMessage,
worktreeDir, worktreeDir,
parallelState, parallelState,
currentModel, fixModel,
batchRender, batchRender,
review ?? undefined, review ?? undefined,
); );
if (fixResult.success) break;
if (!fixResult.success) { // Connection/error failover — try the next model.
if (fixAttempt < fixModels.length - 1) {
sendChatMessage?.( sendChatMessage?.(
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult.error}`, `~ re-execution for ${task.id} · ${task.title} — cycling to model ${fixAttempt + 2}/${fixModels.length} (previous: ${fixResult.error})`,
);
}
}
if (!fixResult || !fixResult.success) {
sendChatMessage?.(
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult?.error}`,
); );
break; // proceed with what we have break; // proceed with what we have
} }