Compare commits
4 Commits
dd61249e8d
...
7253e51fb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 7253e51fb9 | |||
| 284b3720af | |||
| d7c3962bc8 | |||
| 6223ac84a5 |
277
index.ts
277
index.ts
@@ -18,7 +18,10 @@ import { formatReflections } from "./src/reflection";
|
|||||||
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
||||||
import type { ReviewResult } from "./src/types";
|
import type { ReviewResult } from "./src/types";
|
||||||
import { executeBatch, type SendChatMessage } from "./src/executor";
|
import { executeBatch, type SendChatMessage } from "./src/executor";
|
||||||
import { cleanupStaleWorktrees } from "./src/worktree";
|
import {
|
||||||
|
cleanupStaleWorktrees,
|
||||||
|
finalizeCommittedWorktrees,
|
||||||
|
} from "./src/worktree";
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
resolveTaskArg,
|
resolveTaskArg,
|
||||||
@@ -32,8 +35,6 @@ import {
|
|||||||
formatDuration,
|
formatDuration,
|
||||||
} from "./src/utils";
|
} from "./src/utils";
|
||||||
|
|
||||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
|
||||||
|
|
||||||
type ExecutionMode = "parallel" | "sequential";
|
type ExecutionMode = "parallel" | "sequential";
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -160,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",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -220,9 +221,11 @@ async function selectPRDToResume(
|
|||||||
const failed = Object.values(tasks).filter(
|
const failed = Object.values(tasks).filter(
|
||||||
(t) => t.status === "failed",
|
(t) => t.status === "failed",
|
||||||
).length;
|
).length;
|
||||||
const relPath = path.relative(process.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(
|
||||||
@@ -390,6 +393,31 @@ async function executePlanBatches(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Shared Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a sendProgress closure that posts ralpi progress messages into the
|
||||||
|
* chat history for the expandable tool-call-tree renderer.
|
||||||
|
*
|
||||||
|
* Used by every registered command so they share one rendering path.
|
||||||
|
*/
|
||||||
|
function makeSendProgress(pi: ExtensionAPI): SendChatMessage {
|
||||||
|
return (content, meta) => {
|
||||||
|
pi.sendMessage({
|
||||||
|
customType: "ralpi-progress",
|
||||||
|
content,
|
||||||
|
display: true,
|
||||||
|
details: {
|
||||||
|
phase: "progress",
|
||||||
|
toolCalls: meta?.toolCalls,
|
||||||
|
reviewText: meta?.reviewText,
|
||||||
|
reviewPath: meta?.reviewPath,
|
||||||
|
reviewResult: meta?.reviewResult,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Extension Entry ────────────────────────────────────────────────────────
|
// ─── Extension Entry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||||
@@ -618,35 +646,7 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
"Execute tasks from a task file using DAG-based dependency resolution",
|
"Execute tasks from a task file using DAG-based dependency resolution",
|
||||||
handler: async (args: string, ctx: ExtensionContext) => {
|
handler: async (args: string, ctx: ExtensionContext) => {
|
||||||
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
const sendProgress = makeSendProgress(pi);
|
||||||
// Wraps pi.sendMessage() for posting status to the chat history.
|
|
||||||
// Uses "ralpi-progress" customType with a "progress" phase so the
|
|
||||||
// renderer omits the label prefix entirely (no [INFO] etc.).
|
|
||||||
// Accepts an optional meta object with toolCalls for the expandable view,
|
|
||||||
// and reviewText/reviewPath/reviewResult for review messages so the expanded
|
|
||||||
// (Ctrl+O) view can render the full review body without truncation.
|
|
||||||
const sendProgress: SendChatMessage = (
|
|
||||||
content: string,
|
|
||||||
meta?: {
|
|
||||||
toolCalls?: Array<{ name: string; label: string }>;
|
|
||||||
reviewText?: string;
|
|
||||||
reviewPath?: string;
|
|
||||||
reviewResult?: ReviewResult;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
pi.sendMessage({
|
|
||||||
customType: "ralpi-progress",
|
|
||||||
content,
|
|
||||||
display: true,
|
|
||||||
details: {
|
|
||||||
phase: "progress",
|
|
||||||
toolCalls: meta?.toolCalls,
|
|
||||||
reviewText: meta?.reviewText,
|
|
||||||
reviewPath: meta?.reviewPath,
|
|
||||||
reviewResult: meta?.reviewResult,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// If no args, show plan. If first token looks like a path (@path, /path, ./path),
|
// If no args, show plan. If first token looks like a path (@path, /path, ./path),
|
||||||
// route to run so the execution mode prompt fires.
|
// route to run so the execution mode prompt fires.
|
||||||
@@ -663,50 +663,64 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = parts[0];
|
// Subcommands (run/plan/resume/reset) are handled by the dash commands
|
||||||
switch (command) {
|
// below — /ralpi only dispatches no-args → plan and path → run.
|
||||||
case "run":
|
|
||||||
return handleRun(
|
|
||||||
ctx,
|
|
||||||
parts.slice(1),
|
|
||||||
sendProgress,
|
|
||||||
ctx.model,
|
|
||||||
pi.getThinkingLevel(),
|
|
||||||
);
|
|
||||||
case "plan":
|
|
||||||
pi.sendUserMessage("@task-manager");
|
|
||||||
ctx.ui.notify("Opening Task Manager...", "info");
|
|
||||||
return;
|
|
||||||
case "resume":
|
|
||||||
return handleResume(
|
|
||||||
ctx,
|
|
||||||
parts.slice(1),
|
|
||||||
sendProgress,
|
|
||||||
ctx.model,
|
|
||||||
pi.getThinkingLevel(),
|
|
||||||
);
|
|
||||||
case "reset":
|
|
||||||
return handleReset(ctx, parts.slice(1));
|
|
||||||
default: {
|
|
||||||
// Auto-discover progress and offer resume
|
|
||||||
const found = findProgressFile(process.cwd());
|
|
||||||
if (found) {
|
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`Unknown command: ${command}\n\nFound existing progress in ${
|
`Unknown: ${parts[0]}. Use /ralpi-run, /ralpi-plan, /ralpi-resume, or /ralpi-reset`,
|
||||||
found.path
|
|
||||||
}\nUse /ralpi resume to continue.\n\nAvailable: ${COMMANDS.join(
|
|
||||||
", ",
|
|
||||||
)}`,
|
|
||||||
"warning",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ctx.ui.notify(
|
|
||||||
`Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`,
|
|
||||||
"error",
|
"error",
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
// ─── Dedicated subcommands (dash namespace) ──────────────────────────
|
||||||
|
//
|
||||||
|
// Each subcommand is registered as its own top-level Pi command so the
|
||||||
|
// slash-menu autocompletes it directly (`/ralpi-run`, `/ralpi-resume`, …)
|
||||||
|
// instead of requiring the user to type `/ralpi <subcommand>` and rely on
|
||||||
|
// raw-string dispatch. `/ralpi` above remains as a back-compat dispatcher.
|
||||||
|
pi.registerCommand("ralpi-run", {
|
||||||
|
description: "Run tasks from a task file (DAG-based execution)",
|
||||||
|
handler: async (args: string, ctx: ExtensionContext) => {
|
||||||
|
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
return handleRun(
|
||||||
|
ctx,
|
||||||
|
parts,
|
||||||
|
makeSendProgress(pi),
|
||||||
|
ctx.model,
|
||||||
|
pi.getThinkingLevel(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ralpi-plan", {
|
||||||
|
description: "Open the Task Manager to plan a ralpi run",
|
||||||
|
handler: async (args: string, ctx: ExtensionContext) => {
|
||||||
|
const prompt = (args || "").trim();
|
||||||
|
const message = prompt ? `@task-manager\n\n${prompt}` : "@task-manager";
|
||||||
|
pi.sendUserMessage(message);
|
||||||
|
ctx.ui.notify("Opening Task Manager...", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ralpi-resume", {
|
||||||
|
description: "Resume an interrupted ralpi loop from persisted progress",
|
||||||
|
handler: async (args: string, ctx: ExtensionContext) => {
|
||||||
|
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
return handleResume(
|
||||||
|
ctx,
|
||||||
|
parts,
|
||||||
|
makeSendProgress(pi),
|
||||||
|
ctx.model,
|
||||||
|
pi.getThinkingLevel(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ralpi-reset", {
|
||||||
|
description: "Reset ralpi progress for a task file",
|
||||||
|
handler: async (args: string, ctx: ExtensionContext) => {
|
||||||
|
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
return handleReset(ctx, parts);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -717,7 +731,7 @@ async function handlePlan(
|
|||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
args: string[],
|
args: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||||
const project = parseTaskFile(taskFile);
|
const project = parseTaskFile(taskFile);
|
||||||
if (!Array.isArray(project.tasks)) {
|
if (!Array.isArray(project.tasks)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -741,11 +755,11 @@ async function handleRun(
|
|||||||
parentModel?: unknown,
|
parentModel?: unknown,
|
||||||
parentThinkingLevel?: unknown,
|
parentThinkingLevel?: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||||
|
|
||||||
// If targeting a specific task file and there's existing progress for it,
|
// If targeting a specific task file and there's existing progress for it,
|
||||||
// auto-resume instead of starting fresh
|
// auto-resume instead of starting fresh
|
||||||
const existingProgress = findProgressFile(process.cwd(), taskFile);
|
const existingProgress = findProgressFile(ctx.cwd, taskFile);
|
||||||
if (existingProgress) {
|
if (existingProgress) {
|
||||||
return handleResume(
|
return handleResume(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -757,7 +771,7 @@ async function handleRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No existing progress for this task — check for any progress at all
|
// No existing progress for this task — check for any progress at all
|
||||||
const found = findProgressFile(process.cwd());
|
const found = findProgressFile(ctx.cwd);
|
||||||
if (found && !args[0]) {
|
if (found && !args[0]) {
|
||||||
// Offer to resume instead of starting fresh
|
// Offer to resume instead of starting fresh
|
||||||
const shouldResume = await ctx.ui.select(
|
const shouldResume = await ctx.ui.select(
|
||||||
@@ -776,9 +790,7 @@ async function handleRun(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectDir = found
|
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||||
? path.dirname(path.dirname(found.path))
|
|
||||||
: process.cwd();
|
|
||||||
|
|
||||||
const project = parseTaskFile(taskFile);
|
const project = parseTaskFile(taskFile);
|
||||||
const config = loadConfig(projectDir);
|
const config = loadConfig(projectDir);
|
||||||
@@ -880,11 +892,57 @@ async function resumeLoop(
|
|||||||
|
|
||||||
progress.setPaused(false);
|
progress.setPaused(false);
|
||||||
|
|
||||||
// Any task left `in_progress` died with the previous session (ralpi runs
|
// ── Self-heal: finalize tasks that finished but were never merged ──
|
||||||
// agents in-process). Reset them to `pending` so the DAG re-schedules
|
//
|
||||||
// them cleanly. Without this they'd still be re-run (they're not in the
|
// A review-gated task whose agent committed + reviewed successfully still
|
||||||
// completed set), but the progress.json would carry a stale in_progress
|
// needs a final merge into main + worktree removal to be "done". If the
|
||||||
// state during the rebuild window.
|
// loop was interrupted between that commit and the merge, the task is left
|
||||||
|
// `in_progress` with a clean, committed worktree branch. Resuming without
|
||||||
|
// finalizing would wastefully re-run finished work.
|
||||||
|
//
|
||||||
|
// finalizeCommittedWorktrees merges those branches into main now; the
|
||||||
|
// rest (dirty trees, nothing committed, conflicts) are left in_progress
|
||||||
|
// and reset to pending below for a real re-run.
|
||||||
|
const prdKeyForFinalize = progress.getKey();
|
||||||
|
const inProgressIds = Object.entries(progress.getState().tasks)
|
||||||
|
.filter(([, t]) => t.status === "in_progress")
|
||||||
|
.map(([id]) => id);
|
||||||
|
if (inProgressIds.length > 0) {
|
||||||
|
const fin = finalizeCommittedWorktrees(
|
||||||
|
projectDir,
|
||||||
|
config.paths.stateDir,
|
||||||
|
prdKeyForFinalize,
|
||||||
|
inProgressIds,
|
||||||
|
);
|
||||||
|
for (const id of fin.finalized) {
|
||||||
|
progress.markCompleted(id, 0);
|
||||||
|
try {
|
||||||
|
updateTaskInFile(taskFile, id, "completed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort — progress.json is the source of truth for scheduling.
|
||||||
|
}
|
||||||
|
sendChatMessage?.(
|
||||||
|
`✓ ${id} — finalized on resume (committed branch merged into main)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const conflictIds = Object.keys(fin.conflicts);
|
||||||
|
if (conflictIds.length > 0) {
|
||||||
|
const detail = conflictIds
|
||||||
|
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
|
||||||
|
.join("; ");
|
||||||
|
sendChatMessage?.(
|
||||||
|
`⚠ ${conflictIds.join(
|
||||||
|
", ",
|
||||||
|
)} — merge conflict on resume-finalize; re-running (${detail})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any task still `in_progress` (those NOT finalized above) died with the
|
||||||
|
// previous session (ralpi runs agents in-process). Reset them to `pending`
|
||||||
|
// so the DAG re-schedules them cleanly. Without this they'd still be
|
||||||
|
// re-run (they're not in the completed set), but the progress.json would
|
||||||
|
// carry a stale in_progress state during the rebuild window.
|
||||||
const resetIds = progress.resetInProgressToPending();
|
const resetIds = progress.resetInProgressToPending();
|
||||||
if (resetIds.length > 0) {
|
if (resetIds.length > 0) {
|
||||||
// Keep the source-file checkboxes in sync so a later parse sees these
|
// Keep the source-file checkboxes in sync so a later parse sees these
|
||||||
@@ -970,8 +1028,8 @@ async function handleResume(
|
|||||||
let prdKey: string | undefined;
|
let prdKey: string | undefined;
|
||||||
|
|
||||||
if (args[0]) {
|
if (args[0]) {
|
||||||
taskFile = resolveTaskArg(args[0], process.cwd());
|
taskFile = resolveTaskArg(args[0], ctx.cwd);
|
||||||
const found = findProgressFile(process.cwd(), taskFile);
|
const found = findProgressFile(ctx.cwd, taskFile);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
||||||
@@ -982,7 +1040,7 @@ async function handleResume(
|
|||||||
projectDir = path.dirname(path.dirname(found.path));
|
projectDir = path.dirname(path.dirname(found.path));
|
||||||
prdKey = found.prdKey;
|
prdKey = found.prdKey;
|
||||||
} else {
|
} else {
|
||||||
const found = findProgressFile(process.cwd());
|
const found = findProgressFile(ctx.cwd);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||||
@@ -1003,6 +1061,30 @@ async function handleResume(
|
|||||||
prdKey = selected.prdKey;
|
prdKey = selected.prdKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
||||||
|
// persisted when the loop started, so an interrupted loop resumes
|
||||||
|
// non-interactively — matching the auto-resume-on-reload path. Only fall
|
||||||
|
// back to interactive prompts when no snapshot is present.
|
||||||
|
const snapshot = readLoopActive(projectDir);
|
||||||
|
const loopOpts = (() => {
|
||||||
|
if (
|
||||||
|
snapshot &&
|
||||||
|
snapshot.prdKey === prdKey &&
|
||||||
|
snapshot.mode &&
|
||||||
|
snapshot.autoCommit !== undefined &&
|
||||||
|
snapshot.autoReview !== undefined &&
|
||||||
|
snapshot.saveReviews !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
mode: snapshot.mode as ExecutionMode,
|
||||||
|
autoCommit: snapshot.autoCommit,
|
||||||
|
autoReview: snapshot.autoReview,
|
||||||
|
saveReviews: snapshot.saveReviews,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
await resumeLoop(
|
await resumeLoop(
|
||||||
ctx,
|
ctx,
|
||||||
taskFile,
|
taskFile,
|
||||||
@@ -1011,6 +1093,7 @@ async function handleResume(
|
|||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
parentModel,
|
parentModel,
|
||||||
parentThinkingLevel,
|
parentThinkingLevel,
|
||||||
|
loopOpts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,15 +1107,13 @@ async function handleReset(
|
|||||||
args: string[],
|
args: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (args[0]) {
|
if (args[0]) {
|
||||||
const taskFile = resolveTaskArg(args[0], process.cwd());
|
const taskFile = resolveTaskArg(args[0], ctx.cwd);
|
||||||
const found = findProgressFile(process.cwd(), taskFile);
|
const found = findProgressFile(ctx.cwd, taskFile);
|
||||||
const projectDir = found
|
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||||
? path.dirname(path.dirname(found.path))
|
|
||||||
: process.cwd();
|
|
||||||
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
|
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
|
||||||
progress.reset();
|
progress.reset();
|
||||||
} else {
|
} else {
|
||||||
const found = findProgressFile(process.cwd());
|
const found = findProgressFile(ctx.cwd);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
"No .ralpi/progress.json found. Start with /ralpi run [task-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
|
||||||
}
|
}
|
||||||
|
|||||||
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 * 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user