Compare commits
4 Commits
dd61249e8d
...
7253e51fb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 7253e51fb9 | |||
| 284b3720af | |||
| d7c3962bc8 | |||
| 6223ac84a5 |
@@ -23,6 +23,7 @@ import { extractReflection } from "./reflection";
|
||||
import {
|
||||
extractReview,
|
||||
saveReviewToFile as saveReviewJson,
|
||||
loadReview as loadReviewJson,
|
||||
verdictGlyph,
|
||||
verdictSummary,
|
||||
} from "./review";
|
||||
@@ -813,6 +814,27 @@ async function executeTask(
|
||||
? captureGitHead(worktreeDir)
|
||||
: 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
|
||||
const result = await runTask(
|
||||
task,
|
||||
@@ -825,6 +847,7 @@ async function executeTask(
|
||||
parallelState,
|
||||
currentModel,
|
||||
batchRender,
|
||||
priorReview,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
@@ -1014,24 +1037,42 @@ async function executeTask(
|
||||
`↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`,
|
||||
);
|
||||
|
||||
// Re-execute the task with review feedback injected.
|
||||
const fixResult = await runTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
depReflections,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
worktreeDir,
|
||||
parallelState,
|
||||
currentModel,
|
||||
batchRender,
|
||||
review ?? undefined,
|
||||
);
|
||||
// Re-execute the task with review feedback injected, cycling
|
||||
// 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,
|
||||
project,
|
||||
config,
|
||||
depReflections,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
worktreeDir,
|
||||
parallelState,
|
||||
fixModel,
|
||||
batchRender,
|
||||
review ?? undefined,
|
||||
);
|
||||
if (fixResult.success) break;
|
||||
// Connection/error failover — try the next model.
|
||||
if (fixAttempt < fixModels.length - 1) {
|
||||
sendChatMessage?.(
|
||||
`~ re-execution for ${task.id} · ${task.title} — cycling to model ${fixAttempt + 2}/${fixModels.length} (previous: ${fixResult.error})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixResult.success) {
|
||||
if (!fixResult || !fixResult.success) {
|
||||
sendChatMessage?.(
|
||||
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult.error}`,
|
||||
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult?.error}`,
|
||||
);
|
||||
break; // proceed with what we have
|
||||
}
|
||||
|
||||
107
src/worktree.ts
107
src/worktree.ts
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { ensureDir } from "./utils";
|
||||
import { ensureDir, hasUncommittedChanges } from "./utils";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -375,3 +376,107 @@ export function cleanupStaleWorktrees(
|
||||
git("worktree prune", mainDir);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Result of attempting to finalize a single in-progress worktree on resume. */
|
||||
export interface FinalizeResult {
|
||||
/** Task IDs whose committed branch was merged into main and cleaned up. */
|
||||
finalized: string[];
|
||||
/** Task IDs left to re-run (no worktree, dirty tree, nothing committed,
|
||||
* or merge conflict — work is preserved for re-execution). */
|
||||
rerun: string[];
|
||||
/** Task IDs that hit a merge conflict; their committed branch + worktree
|
||||
* are left intact for manual resolution. Excluded from `rerun` so the
|
||||
* scheduler does not blindly re-execute conflicting work. */
|
||||
conflicts: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize in-progress tasks whose worktrees already hold committed, clean
|
||||
* work that was never merged into main (typically because the loop was
|
||||
* interrupted between the task commit and the merge/finalize step).
|
||||
*
|
||||
* For each task ID:
|
||||
* - If no worktree exists / is registered → re-run (fresh worktree later).
|
||||
* - If the worktree working tree is dirty (uncommitted edits) → re-run,
|
||||
* preserving the worktree so `createWorktree` reuses it and the agent
|
||||
* continues where it left off.
|
||||
* - If the worktree is clean but has no commits ahead of main → re-run.
|
||||
* - If the worktree is clean AND has ≥1 commit ahead of main → merge the
|
||||
* branch into main (`--no-ff`), remove the worktree, and report finalized.
|
||||
* On merge conflict the merge is aborted (main left clean), the worktree
|
||||
* is preserved, and the task is reported in `conflicts`.
|
||||
*
|
||||
* This is the self-healing path for an interrupted review-gated loop:
|
||||
* tasks that finished (commit + review already saved) but never got their
|
||||
* merge are completed here, so `/ralpi-resume` does not wastefully re-run
|
||||
* finished work.
|
||||
*/
|
||||
export function finalizeCommittedWorktrees(
|
||||
mainDir: string,
|
||||
stateDir: string,
|
||||
prdKey: string,
|
||||
taskIds: string[],
|
||||
): FinalizeResult {
|
||||
const result: FinalizeResult = { finalized: [], rerun: [], conflicts: {} };
|
||||
|
||||
const mainHead = getGitHead(mainDir);
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId);
|
||||
|
||||
// No worktree directory on disk → nothing to finalize.
|
||||
if (!fs.existsSync(wtDir)) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Confirm the worktree is actually registered with git (not a leftover
|
||||
// dir from a half-cleaned-up run). If registered but broken, drop its
|
||||
// metadata so a fresh worktree can be created on re-run.
|
||||
const list = git("worktree list --porcelain", mainDir) ?? "";
|
||||
if (!list.includes(`worktree ${wtDir}`)) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Broken checkout → re-run (createWorktree will recreate it).
|
||||
const branch = getCurrentBranch(wtDir);
|
||||
if (!branch || branch === "HEAD" || branch === "detached") {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dirty working tree (uncommitted edits, e.g. an interrupted agent) →
|
||||
// re-run, keeping the worktree so the agent resumes in place.
|
||||
if (hasUncommittedChanges(wtDir)) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clean tree but nothing committed ahead of main → nothing to merge.
|
||||
const aheadStr =
|
||||
mainHead !== null
|
||||
? git(`rev-list --count ${mainHead}..HEAD`, wtDir)
|
||||
: null;
|
||||
const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0;
|
||||
if (Number.isNaN(ahead) || ahead <= 0) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Committed + clean → finalize. mergeWorktree aborts on conflict,
|
||||
// leaving main's working tree clean.
|
||||
const merge = mergeWorktree(mainDir, branch);
|
||||
if (merge.success) {
|
||||
removeWorktree(mainDir, { dir: wtDir, branch, mainDir });
|
||||
result.finalized.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Conflict — preserve the worktree for manual resolution and report.
|
||||
result.conflicts[taskId] = merge.conflicts;
|
||||
}
|
||||
|
||||
git("worktree prune", mainDir);
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user