Compare commits

..

4 Commits

Author SHA1 Message Date
7253e51fb9 feat: use stored review on resume if interrupted 2026-07-31 09:57:51 -04:00
284b3720af breaking:drop command part path 2026-07-31 09:57:40 -04:00
d7c3962bc8 fix: include prompt in ralpi-plan 2026-07-30 08:39:02 -04:00
6223ac84a5 feat: register subcommands, finalize-on-resume, reuse loop snapshot
- Register /ralpi-run, /ralpi-plan, /ralpi-resume, /ralpi-reset as
  separate Pi commands (dash namespace). /ralpi kept as back-compat
  dispatcher.
- /ralpi-resume now reuses mode + loop options from loop-active.json
  so interrupted loops resume non-interactively (only prompts when no
  snapshot exists).
- Add finalizeCommittedWorktrees in worktree.ts; resumeLoop calls it at
  start to merge already-completed task branches into main (the classic
  interrupted-between-commit-and-merge crash window), leaving dirty/
  conflicted worktrees for re-run.
- Switch process.cwd() -> ctx.cwd in all command handlers.
2026-07-28 15:14:11 -04:00
3 changed files with 1108 additions and 881 deletions

1809
index.ts

File diff suppressed because it is too large Load Diff

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,24 +1037,42 @@ 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
task, // provider doesn't waste the review-fix attempt.
project, const fixModels = buildFailoverModels(currentModel, roundRobin);
config, let fixResult: Awaited<ReturnType<typeof runTask>> | undefined;
depReflections, for (
ctx, let fixAttempt = 0;
sendChatMessage, fixAttempt < fixModels.length;
worktreeDir, fixAttempt++
parallelState, ) {
currentModel, const fixModel = fixModels[fixAttempt];
batchRender, fixResult = await runTask(
review ?? undefined, 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?.( 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 break; // proceed with what we have
} }

View File

@@ -1,5 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { ensureDir } from "./utils"; import { ensureDir, hasUncommittedChanges } from "./utils";
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -375,3 +376,107 @@ export function cleanupStaleWorktrees(
git("worktree prune", mainDir); git("worktree prune", mainDir);
return removed; 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;
}