From 7253e51fb9992f1e201c8cf59c6ca9db7cb39cea Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 31 Jul 2026 09:57:51 -0400 Subject: [PATCH] feat: use stored review on resume if interrupted --- index.ts | 1850 ++++++++++++++++++++++++----------------------- src/executor.ts | 73 +- 2 files changed, 984 insertions(+), 939 deletions(-) diff --git a/index.ts b/index.ts index 9dca410..95cf86f 100644 --- a/index.ts +++ b/index.ts @@ -1,16 +1,16 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { - ExtensionAPI, - ExtensionContext, + ExtensionAPI, + ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Box, Text } from "@earendil-works/pi-tui"; import { parseTaskFile, updateTaskInFile } from "./src/parser"; import { - buildExecutionPlan, - buildSequentialPlan, - formatDependencyChain, - formatExecutionPlan, + buildExecutionPlan, + buildSequentialPlan, + formatDependencyChain, + formatExecutionPlan, } from "./src/dag"; import { ProgressTracker } from "./src/progress"; import { buildPlanPrompt } from "./src/prompts"; @@ -19,20 +19,20 @@ import { verdictGlyph, verdictSummary, formatFindings } from "./src/review"; import type { ReviewResult } from "./src/types"; import { executeBatch, type SendChatMessage } from "./src/executor"; import { - cleanupStaleWorktrees, - finalizeCommittedWorktrees, + cleanupStaleWorktrees, + finalizeCommittedWorktrees, } from "./src/worktree"; import { - loadConfig, - resolveTaskArg, - formatProgressStatus, - findProgressFile, - writeLoopActive, - deleteLoopActive, - readLoopActive, - findRalpiDir, - listPRDsSorted, - formatDuration, + loadConfig, + resolveTaskArg, + formatProgressStatus, + findProgressFile, + writeLoopActive, + deleteLoopActive, + readLoopActive, + findRalpiDir, + listPRDsSorted, + formatDuration, } from "./src/utils"; type ExecutionMode = "parallel" | "sequential"; @@ -44,82 +44,82 @@ type ExecutionMode = "parallel" | "sequential"; * Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml */ function looksLikePath(token: string): boolean { - return ( - token.startsWith("@") || - token.startsWith("/") || - token.startsWith("./") || - token.startsWith("../") || - token.includes("/") || - token.endsWith(".md") || - token.endsWith(".yaml") || - token.endsWith(".yml") - ); + return ( + token.startsWith("@") || + token.startsWith("/") || + token.startsWith("./") || + token.startsWith("../") || + token.includes("/") || + token.endsWith(".md") || + token.endsWith(".yaml") || + token.endsWith(".yml") + ); } /** Build the set of completed tasks from progress tracker and PRD checkboxes. */ function buildCompletedSet( - progress: ProgressTracker, - project: import("./src/types").Project, + progress: ProgressTracker, + project: import("./src/types").Project, ): Set { - const completed = new Set(progress.getCompletedTaskIds()); - for (const task of project.tasks) { - if (task.status === "completed") { - completed.add(task.id); - } - } - return completed; + const completed = new Set(progress.getCompletedTaskIds()); + for (const task of project.tasks) { + if (task.status === "completed") { + completed.add(task.id); + } + } + return completed; } /** Prompt user to select an execution mode with dependency validation. */ async function selectExecutionMode( - ctx: ExtensionContext, - project: import("./src/types").Project, - taskFile: string, - config: import("./src/types").RalpiConfig, + ctx: ExtensionContext, + project: import("./src/types").Project, + taskFile: string, + config: import("./src/types").RalpiConfig, ): Promise { - const mode = await ctx.ui.select("Execution mode for this run?", [ - `Parallel (where dependencies allow)[${config.execution.maxParallel} max]`, - "Sequential (one at a time)", - ]); - const isParallel = mode?.startsWith("Parallel") ?? false; + const mode = await ctx.ui.select("Execution mode for this run?", [ + `Parallel (where dependencies allow)[${config.execution.maxParallel} max]`, + "Sequential (one at a time)", + ]); + const isParallel = mode?.startsWith("Parallel") ?? false; - if (!isParallel) return "sequential"; + if (!isParallel) return "sequential"; - // Validate dependency graph for parallel mode - if (Object.keys(project.dependencies).length === 0) { - const hasDepsSection = await fs.promises - .readFile(taskFile, "utf-8") - .then((content) => /^##\s+Dependencies\s*$/m.test(content)) - .catch(() => false); + // Validate dependency graph for parallel mode + if (Object.keys(project.dependencies).length === 0) { + const hasDepsSection = await fs.promises + .readFile(taskFile, "utf-8") + .then((content) => /^##\s+Dependencies\s*$/m.test(content)) + .catch(() => false); - if (hasDepsSection) { - const choice = await ctx.ui.select( - "Found ## Dependencies section but no valid dependencies were parsed.\n\n" + - "This may be due to unsupported format. Parallel mode requires explicit dependencies.\n\n" + - "See README.md for supported dependency formats:\n" + - "- Arrow notation: `1 -> 2,3,4`\n" + - "- Natural language: `13 depends on 17, 18, 19, 20`\n\n" + - "Fall back to sequential mode?", - ["Yes, use sequential", "No, continue with parallel"], - ); - if (choice?.startsWith("Yes")) { - return "sequential"; - } - } - } + if (hasDepsSection) { + const choice = await ctx.ui.select( + "Found ## Dependencies section but no valid dependencies were parsed.\n\n" + + "This may be due to unsupported format. Parallel mode requires explicit dependencies.\n\n" + + "See README.md for supported dependency formats:\n" + + "- Arrow notation: `1 -> 2,3,4`\n" + + "- Natural language: `13 depends on 17, 18, 19, 20`\n\n" + + "Fall back to sequential mode?", + ["Yes, use sequential", "No, continue with parallel"], + ); + if (choice?.startsWith("Yes")) { + return "sequential"; + } + } + } - return "parallel"; + return "parallel"; } /** Build an execution plan based on the selected mode. */ function buildPlanByMode( - mode: ExecutionMode, - project: Parameters[0], - completed: Set, + mode: ExecutionMode, + project: Parameters[0], + completed: Set, ) { - return mode === "parallel" - ? buildExecutionPlan(project, completed) - : buildSequentialPlan(project, completed); + return mode === "parallel" + ? buildExecutionPlan(project, completed) + : buildSequentialPlan(project, completed); } /** @@ -131,68 +131,68 @@ function buildPlanByMode( * Returns the selected options (or config defaults if cancelled). */ async function selectLoopOptions( - ctx: ExtensionContext, - config: import("./src/types").RalpiConfig, + ctx: ExtensionContext, + config: import("./src/types").RalpiConfig, ): Promise<{ autoCommit: boolean; autoReview: boolean; saveReviews: boolean }> { - const explicit = config.execution.explicitKeys; + const explicit = config.execution.explicitKeys; - // ── 1. Auto-review (asked FIRST) ── - // When enabled, a commit is mandated before review (the task agent's - // changes are committed, then the complete diff is reviewed). On 'fail' - // the task is re-executed with review feedback (looping until pass or - // maxReviewRetries exhausted). On pass the worktree merges. - let autoReview: boolean; - if (explicit?.has("autoReview")) { - autoReview = config.execution.autoReview; - } else { - const reviewChoice = await ctx.ui.select("Auto-review after each task?", [ - "Yes — review the task commit and loop on failures (re-execute until pass)", - "No — skip review", - ]); - autoReview = reviewChoice - ? reviewChoice.startsWith("Yes") - : config.execution.autoReview; - } + // ── 1. Auto-review (asked FIRST) ── + // When enabled, a commit is mandated before review (the task agent's + // changes are committed, then the complete diff is reviewed). On 'fail' + // the task is re-executed with review feedback (looping until pass or + // maxReviewRetries exhausted). On pass the worktree merges. + let autoReview: boolean; + if (explicit?.has("autoReview")) { + autoReview = config.execution.autoReview; + } else { + const reviewChoice = await ctx.ui.select("Auto-review after each task?", [ + "Yes — review the task commit and loop on failures (re-execute until pass)", + "No — skip review", + ]); + autoReview = reviewChoice + ? reviewChoice.startsWith("Yes") + : config.execution.autoReview; + } - // ── 2. Save full review output to disk (only when review is enabled) ── - let saveReviews = false; - if (autoReview) { - if (explicit?.has("saveReviews")) { - saveReviews = config.execution.saveReviews; - } else { - const saveChoice = await ctx.ui.select( - "Save full review output to disk?", - [ - "Yes — write each review to .ralpi/reviews//.md", - "No — keep reviews in-chat only", - ], - ); - saveReviews = saveChoice - ? saveChoice.startsWith("Yes") - : config.execution.saveReviews; - } - } + // ── 2. Save full review output to disk (only when review is enabled) ── + let saveReviews = false; + if (autoReview) { + if (explicit?.has("saveReviews")) { + saveReviews = config.execution.saveReviews; + } else { + const saveChoice = await ctx.ui.select( + "Save full review output to disk? (recommended — enables review feedback recovery when resuming interrupted loops)", + [ + "Yes — write each review to .ralpi/reviews//.json", + "No — keep reviews in-chat only", + ], + ); + saveReviews = saveChoice + ? saveChoice.startsWith("Yes") + : config.execution.saveReviews; + } + } - // ── 3. Auto-commit ── - // When autoReview is on, commit is always mandated (it happens before the - // review). autoCommit is forced true and not asked about. When review is - // disabled, autoCommit is asked as a stand-alone "commit per task" toggle. - let autoCommit: boolean; - if (autoReview) { - autoCommit = true; // mandated by the review-gated flow - } else if (explicit?.has("autoCommit")) { - autoCommit = config.execution.autoCommit; - } else { - const commitChoice = await ctx.ui.select("Auto-commit after each task?", [ - "Yes — stage and commit changes automatically", - "No — skip auto-commit", - ]); - autoCommit = commitChoice - ? commitChoice.startsWith("Yes") - : config.execution.autoCommit; - } + // ── 3. Auto-commit ── + // When autoReview is on, commit is always mandated (it happens before the + // review). autoCommit is forced true and not asked about. When review is + // disabled, autoCommit is asked as a stand-alone "commit per task" toggle. + let autoCommit: boolean; + if (autoReview) { + autoCommit = true; // mandated by the review-gated flow + } else if (explicit?.has("autoCommit")) { + autoCommit = config.execution.autoCommit; + } else { + const commitChoice = await ctx.ui.select("Auto-commit after each task?", [ + "Yes — stage and commit changes automatically", + "No — skip auto-commit", + ]); + autoCommit = commitChoice + ? commitChoice.startsWith("Yes") + : config.execution.autoCommit; + } - return { autoCommit, autoReview, saveReviews }; + return { autoCommit, autoReview, saveReviews }; } /** @@ -202,193 +202,195 @@ async function selectLoopOptions( * Returns null if no PRDs exist. */ async function selectPRDToResume( - ctx: ExtensionContext, - found: NonNullable>, + ctx: ExtensionContext, + found: NonNullable>, ): Promise<{ prdKey: string; sourcePath: string } | null> { - const prds = listPRDsSorted(found.state); - if (prds.length === 0) return null; - if (prds.length === 1) { - return { prdKey: prds[0].key, sourcePath: prds[0].prd.sourcePath }; - } + const prds = listPRDsSorted(found.state); + if (prds.length === 0) return null; + if (prds.length === 1) { + return { prdKey: prds[0].key, sourcePath: prds[0].prd.sourcePath }; + } - // Multiple PRDs — show selection sorted by most recent first - const options = prds.map((entry) => { - const tasks = entry.prd.tasks; - const total = Object.keys(tasks).length; - const completed = Object.values(tasks).filter( - (t) => t.status === "completed", - ).length; - const failed = Object.values(tasks).filter( - (t) => t.status === "failed", - ).length; - const relPath = path.relative(ctx.cwd, entry.prd.sourcePath); - const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString(); - return `${relPath} — ${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`; - }); + // Multiple PRDs — show selection sorted by most recent first + const options = prds.map((entry) => { + const tasks = entry.prd.tasks; + const total = Object.keys(tasks).length; + const completed = Object.values(tasks).filter( + (t) => t.status === "completed", + ).length; + const failed = Object.values(tasks).filter( + (t) => t.status === "failed", + ).length; + const relPath = path.relative(ctx.cwd, entry.prd.sourcePath); + const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString(); + return `${relPath} — ${completed}/${total} done${ + failed ? `, ${failed} failed` : "" + } · ${updated}`; + }); - const selected = await ctx.ui.select( - "Multiple loops found. Which to resume?", - options, - ); - if (!selected) return null; + const selected = await ctx.ui.select( + "Multiple loops found. Which to resume?", + options, + ); + if (!selected) return null; - const idx = options.indexOf(selected); - if (idx === -1) return null; - return { - prdKey: prds[idx].key, - sourcePath: prds[idx].prd.sourcePath, - }; + const idx = options.indexOf(selected); + if (idx === -1) return null; + return { + prdKey: prds[idx].key, + sourcePath: prds[idx].prd.sourcePath, + }; } /** Run all batches in a plan, updating the task file after each batch. */ async function executePlanBatches( - plan: ReturnType, - project: Parameters[0], - taskFile: string, - config: import("./src/types").RalpiConfig, - progress: ProgressTracker, - ctx: ExtensionContext, - mode: ExecutionMode, - sendChatMessage?: SendChatMessage, - projectDir?: string, - isResume?: boolean, + plan: ReturnType, + project: Parameters[0], + taskFile: string, + config: import("./src/types").RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + mode: ExecutionMode, + sendChatMessage?: SendChatMessage, + projectDir?: string, + isResume?: boolean, ): Promise { - // Write loop-active marker so a session reload can detect an interrupted - // loop and resume it (in-process agent sessions die on reload — the marker - // + progress.json in_progress tasks are the signal to re-run them). - if (projectDir) { - const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id)); - writeLoopActive(projectDir, { - taskFile, - mode, - startedAt: new Date().toISOString(), - taskIds: allTaskIds, - prdKey: progress.getKey(), - autoCommit: config.execution.autoCommit, - autoReview: config.execution.autoReview, - saveReviews: config.execution.saveReviews, - }); + // Write loop-active marker so a session reload can detect an interrupted + // loop and resume it (in-process agent sessions die on reload — the marker + // + progress.json in_progress tasks are the signal to re-run them). + if (projectDir) { + const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id)); + writeLoopActive(projectDir, { + taskFile, + mode, + startedAt: new Date().toISOString(), + taskIds: allTaskIds, + prdKey: progress.getKey(), + autoCommit: config.execution.autoCommit, + autoReview: config.execution.autoReview, + saveReviews: config.execution.saveReviews, + }); - // Clean up stale worktrees from interrupted runs before starting. - // On resume this MUST be skipped: an interrupted in-progress task's - // worktree still carries its committed branch, which createWorktree() - // reuses to continue the task rather than restarting from scratch. - // The stale-worktree sweep only runs for fresh loops so concurrent - // loops (other PRDs) are still scoped out via the prdKey filter above. - if (!isResume && config.execution.worktrees !== "never" && projectDir) { - const removed = cleanupStaleWorktrees( - projectDir, - config.paths.stateDir, - progress.getKey(), - ); - if (removed.length > 0) { - ctx.ui.notify( - `Cleaned up ${removed.length} stale worktree(s) from previous run.`, - "info", - ); - } - } - } + // Clean up stale worktrees from interrupted runs before starting. + // On resume this MUST be skipped: an interrupted in-progress task's + // worktree still carries its committed branch, which createWorktree() + // reuses to continue the task rather than restarting from scratch. + // The stale-worktree sweep only runs for fresh loops so concurrent + // loops (other PRDs) are still scoped out via the prdKey filter above. + if (!isResume && config.execution.worktrees !== "never" && projectDir) { + const removed = cleanupStaleWorktrees( + projectDir, + config.paths.stateDir, + progress.getKey(), + ); + if (removed.length > 0) { + ctx.ui.notify( + `Cleaned up ${removed.length} stale worktree(s) from previous run.`, + "info", + ); + } + } + } - // Track failed task IDs across batches to block downstream tasks - const failedTaskIds = new Set(progress.getFailedTaskIds()); + // Track failed task IDs across batches to block downstream tasks + const failedTaskIds = new Set(progress.getFailedTaskIds()); - // Loop-level execution timeout: stop starting new batches once elapsed. - // In-progress tasks finish naturally; we just skip remaining batches. - const loopStart = Date.now(); - const loopTimeoutMs = config.execution.loopTimeoutMs; - let loopTimedOut = false; + // Loop-level execution timeout: stop starting new batches once elapsed. + // In-progress tasks finish naturally; we just skip remaining batches. + const loopStart = Date.now(); + const loopTimeoutMs = config.execution.loopTimeoutMs; + let loopTimedOut = false; - try { - for (const batch of plan.batches) { - // Check loop timeout before starting a new batch - if (loopTimeoutMs > 0 && Date.now() - loopStart > loopTimeoutMs) { - loopTimedOut = true; - break; - } + try { + for (const batch of plan.batches) { + // Check loop timeout before starting a new batch + if (loopTimeoutMs > 0 && Date.now() - loopStart > loopTimeoutMs) { + loopTimedOut = true; + break; + } - if (progress.getState().paused) { - ctx.ui.notify( - "Execution paused. Use /ralpi resume to continue.", - "warning", - ); - return; - } + if (progress.getState().paused) { + ctx.ui.notify( + "Execution paused. Use /ralpi resume to continue.", + "warning", + ); + return; + } - if (!Array.isArray(batch.tasks)) { - throw new Error( - `Batch ${ - batch.batchIndex - } has invalid tasks: expected array, got ${typeof batch.tasks}`, - ); - } + if (!Array.isArray(batch.tasks)) { + throw new Error( + `Batch ${ + batch.batchIndex + } has invalid tasks: expected array, got ${typeof batch.tasks}`, + ); + } - await executeBatch( - batch.tasks, - project, - config, - progress, - ctx, - { parallel: mode === "parallel" }, - sendChatMessage, - projectDir, - ); + await executeBatch( + batch.tasks, + project, + config, + progress, + ctx, + { parallel: mode === "parallel" }, + sendChatMessage, + projectDir, + ); - for (const task of batch.tasks) { - const status = progress.getTaskStatus(task.id); - updateTaskInFile(taskFile, task.id, status); - } + for (const task of batch.tasks) { + const status = progress.getTaskStatus(task.id); + updateTaskInFile(taskFile, task.id, status); + } - // Update failed task IDs after batch completes - const newFailed = progress.getFailedTaskIds(); - for (const id of newFailed) { - failedTaskIds.add(id); - } + // Update failed task IDs after batch completes + const newFailed = progress.getFailedTaskIds(); + for (const id of newFailed) { + failedTaskIds.add(id); + } - // In sequential mode, stop after any failure - if (mode === "sequential" && failedTaskIds.size > 0) { - break; - } + // In sequential mode, stop after any failure + if (mode === "sequential" && failedTaskIds.size > 0) { + break; + } - // In parallel mode, rebuild the plan to filter out newly blocked tasks - if (mode === "parallel") { - // Use buildCompletedSet to include file-based [x] completions - // (progress.getCompletedTaskIds() only knows about tasks completed - // during THIS execution session — tasks that were already [x] in the - // file before the run started would be re-included and re-executed). - const completed = buildCompletedSet(progress, project); - const newPlan = buildExecutionPlan( - project, - completed, - undefined, - failedTaskIds, - ); + // In parallel mode, rebuild the plan to filter out newly blocked tasks + if (mode === "parallel") { + // Use buildCompletedSet to include file-based [x] completions + // (progress.getCompletedTaskIds() only knows about tasks completed + // during THIS execution session — tasks that were already [x] in the + // file before the run started would be re-included and re-executed). + const completed = buildCompletedSet(progress, project); + const newPlan = buildExecutionPlan( + project, + completed, + undefined, + failedTaskIds, + ); - // Keep processed batches (up to current batch), replace the rest - // with the fresh plan — its batchIndex restarts at 0, so filtering - // by batchIndex > currentIdx would incorrectly drop the next batch. - const processedCount = plan.batches.indexOf(batch) + 1; - plan.batches.length = processedCount; - plan.batches.push(...newPlan.batches); + // Keep processed batches (up to current batch), replace the rest + // with the fresh plan — its batchIndex restarts at 0, so filtering + // by batchIndex > currentIdx would incorrectly drop the next batch. + const processedCount = plan.batches.indexOf(batch) + 1; + plan.batches.length = processedCount; + plan.batches.push(...newPlan.batches); - // Skip if nothing remaining - if (plan.batches.length === processedCount) { - break; - } - } - } - } finally { - if (projectDir) { - deleteLoopActive(projectDir); - } - if (loopTimedOut) { - const elapsed = formatDuration(Date.now() - loopStart); - ctx.ui.notify( - `Loop execution timeout reached (${elapsed}). Remaining tasks skipped. Use /ralpi resume to continue.`, - "warning", - ); - } - } + // Skip if nothing remaining + if (plan.batches.length === processedCount) { + break; + } + } + } + } finally { + if (projectDir) { + deleteLoopActive(projectDir); + } + if (loopTimedOut) { + const elapsed = formatDuration(Date.now() - loopStart); + ctx.ui.notify( + `Loop execution timeout reached (${elapsed}). Remaining tasks skipped. Use /ralpi resume to continue.`, + "warning", + ); + } + } } // ─── Shared Helpers ───────────────────────────────────────────────────────── @@ -400,450 +402,450 @@ async function executePlanBatches( * 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, - }, - }); - }; + 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 ──────────────────────────────────────────────────────── export default function ralpiLoopExtension(pi: ExtensionAPI): void { - // Register custom message renderer for ralpi progress messages. - // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", - // expanded (Ctrl+O) shows every tool call. - pi.registerMessageRenderer( - "ralpi-progress", - (message, { expanded }, theme) => { - const details = message.details as - | { - phase?: string; - toolCalls?: Array<{ name: string; label: string }>; - reviewText?: string; - reviewPath?: string; - reviewResult?: ReviewResult; - } - | undefined; + // Register custom message renderer for ralpi progress messages. + // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", + // expanded (Ctrl+O) shows every tool call. + pi.registerMessageRenderer( + "ralpi-progress", + (message, { expanded }, theme) => { + const details = message.details as + | { + phase?: string; + toolCalls?: Array<{ name: string; label: string }>; + reviewText?: string; + reviewPath?: string; + reviewResult?: ReviewResult; + } + | undefined; - const MAX_COLLAPSED = 3; - const lines: string[] = []; + const MAX_COLLAPSED = 3; + const lines: string[] = []; - // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" - lines.push(String(message.content)); + // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" + lines.push(String(message.content)); - // Structured review: when we have a ReviewResult, render verdict + - // findings tree. In expanded mode show findings detail; collapsed - // shows the verdict summary + a hint to expand. - const hasReview = !!details?.reviewText || !!details?.reviewResult; - if (details?.reviewResult) { - const rv = details.reviewResult; - const glyph = verdictGlyph(rv.verdict); - const summary = verdictSummary(rv); - if (expanded) { - // Show verdict, summary, then findings tree, then raw text. - lines.push(` ${glyph} VERDICT: ${rv.verdict.toUpperCase()}`); - lines.push(` ${rv.summary}`); - if (rv.findings.length > 0) { - lines.push(` ${formatFindings(rv)}`); - } - if (details.reviewText) { - const body = details.reviewText.split("\n"); - for (const line of body) { - lines.push(` ${line}`); - } - } - } else { - const hint = details.reviewPath - ? `press Ctrl+O for full review · saved to ${details.reviewPath}` - : "press Ctrl+O for full review"; - lines.push(theme.fg("dim", ` ├── ${glyph} ${summary} · ${hint}`)); - } - } else if (hasReview && expanded && details!.reviewText) { - const body = details!.reviewText.split("\n"); - for (const line of body) { - lines.push(` ${line}`); - } - } else if (hasReview && !expanded) { - const hint = details?.reviewPath - ? `press Ctrl+O for full review · saved to ${details.reviewPath}` - : "press Ctrl+O for full review"; - lines.push(theme.fg("dim", ` ├── ${hint}`)); - } + // Structured review: when we have a ReviewResult, render verdict + + // findings tree. In expanded mode show findings detail; collapsed + // shows the verdict summary + a hint to expand. + const hasReview = !!details?.reviewText || !!details?.reviewResult; + if (details?.reviewResult) { + const rv = details.reviewResult; + const glyph = verdictGlyph(rv.verdict); + const summary = verdictSummary(rv); + if (expanded) { + // Show verdict, summary, then findings tree, then raw text. + lines.push(` ${glyph} VERDICT: ${rv.verdict.toUpperCase()}`); + lines.push(` ${rv.summary}`); + if (rv.findings.length > 0) { + lines.push(` ${formatFindings(rv)}`); + } + if (details.reviewText) { + const body = details.reviewText.split("\n"); + for (const line of body) { + lines.push(` ${line}`); + } + } + } else { + const hint = details.reviewPath + ? `press Ctrl+O for full review · saved to ${details.reviewPath}` + : "press Ctrl+O for full review"; + lines.push(theme.fg("dim", ` ├── ${glyph} ${summary} · ${hint}`)); + } + } else if (hasReview && expanded && details!.reviewText) { + const body = details!.reviewText.split("\n"); + for (const line of body) { + lines.push(` ${line}`); + } + } else if (hasReview && !expanded) { + const hint = details?.reviewPath + ? `press Ctrl+O for full review · saved to ${details.reviewPath}` + : "press Ctrl+O for full review"; + lines.push(theme.fg("dim", ` ├── ${hint}`)); + } - // Build tool-call tree - if (details?.toolCalls && details.toolCalls.length > 0) { - const all = details.toolCalls; + // Build tool-call tree + if (details?.toolCalls && details.toolCalls.length > 0) { + const all = details.toolCalls; - if (expanded) { - // Expanded: show ALL tool calls - for (let i = 0; i < all.length; i++) { - const entry = all[i]; - const isLast = i === all.length - 1; - const branch = isLast ? " └── " : " ├── "; - const tag = theme.fg("accent", `[${entry.name}]`); - lines.push(`${branch}${tag} ${entry.label}`); - } - } else { - // Collapsed: last N + "X more" - const shown = all.slice(-MAX_COLLAPSED); - const remaining = all.length - shown.length; + if (expanded) { + // Expanded: show ALL tool calls + for (let i = 0; i < all.length; i++) { + const entry = all[i]; + const isLast = i === all.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } else { + // Collapsed: last N + "X more" + const shown = all.slice(-MAX_COLLAPSED); + const remaining = all.length - shown.length; - if (remaining > 0) { - lines.push(theme.fg("dim", ` ├── ${remaining} more`)); - } + if (remaining > 0) { + lines.push(theme.fg("dim", ` ├── ${remaining} more`)); + } - for (let i = 0; i < shown.length; i++) { - const entry = shown[i]; - const isLast = i === shown.length - 1; - const branch = isLast ? " └── " : " ├── "; - const tag = theme.fg("accent", `[${entry.name}]`); - lines.push(`${branch}${tag} ${entry.label}`); - } - } - } + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } + } - const text = lines.join("\n"); - const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); - box.addChild(new Text(text, 0, 0)); - return box; - }, - ); + const text = lines.join("\n"); + const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + box.addChild(new Text(text, 0, 0)); + return box; + }, + ); - // ─── Reload detection: resume interrupted loops when session reloads ── - // - // ralpi runs task agent sessions in-process (createAgentSession), so they - // do NOT survive a /reload. When the new session starts, this handler - // reads the persisted loop-active marker + progress.json: if any task is - // still `in_progress`, the loop was interrupted mid-task and we resume it - // (resetting those tasks to pending so the DAG re-schedules them), using - // the mode + loop options snapshotted in loop-active.json so the resume is - // non-interactive. - pi.on("session_start", async (event, ctx) => { - if (event.reason !== "reload") return; + // ─── Reload detection: resume interrupted loops when session reloads ── + // + // ralpi runs task agent sessions in-process (createAgentSession), so they + // do NOT survive a /reload. When the new session starts, this handler + // reads the persisted loop-active marker + progress.json: if any task is + // still `in_progress`, the loop was interrupted mid-task and we resume it + // (resetting those tasks to pending so the DAG re-schedules them), using + // the mode + loop options snapshotted in loop-active.json so the resume is + // non-interactive. + pi.on("session_start", async (event, ctx) => { + if (event.reason !== "reload") return; - // Find the ralpi project directory - const projectDir = findRalpiDir(ctx.cwd); - if (!projectDir) return; + // Find the ralpi project directory + const projectDir = findRalpiDir(ctx.cwd); + if (!projectDir) return; - // Check if a task execution loop was active before the reload - const loopState = readLoopActive(projectDir); - if (!loopState) return; + // Check if a task execution loop was active before the reload + const loopState = readLoopActive(projectDir); + if (!loopState) return; - // Load progress state - const progressPath = path.join(projectDir, ".ralpi", "progress.json"); + // Load progress state + const progressPath = path.join(projectDir, ".ralpi", "progress.json"); - /** Re-read progress from disk. */ - const readTasks = (): Record | null => { - try { - const raw = fs.readFileSync(progressPath, "utf-8"); - const parsed = JSON.parse(raw) as Record; - return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null; - } catch { - return null; - } - }; + /** Re-read progress from disk. */ + const readTasks = (): Record | null => { + try { + const raw = fs.readFileSync(progressPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null; + } catch { + return null; + } + }; - // ralpi agent sessions run in-process (createAgentSession), so they do - // NOT survive a session reload. Any task left `in_progress` is therefore - // stalled — its agent died with the previous session. Detect that state - // and actively resume the loop instead of passively polling (which would - // spin forever waiting for a dead task to complete). - const initialTasks = readTasks(); - if (initialTasks) { - const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) => - t.status === "in_progress" ? [id] : [], - ); + // ralpi agent sessions run in-process (createAgentSession), so they do + // NOT survive a session reload. Any task left `in_progress` is therefore + // stalled — its agent died with the previous session. Detect that state + // and actively resume the loop instead of passively polling (which would + // spin forever waiting for a dead task to complete). + const initialTasks = readTasks(); + if (initialTasks) { + const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) => + t.status === "in_progress" ? [id] : [], + ); - if (inProgressIds.length === 0) { - // Nothing was mid-flight — loop either finished cleanly between - // the reload landing and this handler running, or was stopped - // between tasks. Clean up the stale marker and bail. - ctx.ui.notify( - "ralpi loop has no in-progress task to resume — marking complete.", - "info", - ); - deleteLoopActive(projectDir); - return; - } + if (inProgressIds.length === 0) { + // Nothing was mid-flight — loop either finished cleanly between + // the reload landing and this handler running, or was stopped + // between tasks. Clean up the stale marker and bail. + ctx.ui.notify( + "ralpi loop has no in-progress task to resume — marking complete.", + "info", + ); + deleteLoopActive(projectDir); + return; + } - const taskCount = loopState.taskIds.length; - ctx.ui.notify( - `ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` + - `Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`, - "info", - ); + const taskCount = loopState.taskIds.length; + ctx.ui.notify( + `ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` + + `Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`, + "info", + ); - // Build the sendProgress wrapper so resumed task messages render the - // same expandable tool-call tree as an interactive run. - 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, - }, - }); - }; + // Build the sendProgress wrapper so resumed task messages render the + // same expandable tool-call tree as an interactive run. + 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, + }, + }); + }; - // Load config from the project directory so model + thinking level - // resolve the same way the interactive command handler does. - const config = loadConfig(projectDir); + // Load config from the project directory so model + thinking level + // resolve the same way the interactive command handler does. + const config = loadConfig(projectDir); - try { - await resumeLoop( - ctx, - loopState.taskFile, - projectDir, - loopState.prdKey, - sendProgress, - config.model ?? ctx.model, - pi.getThinkingLevel(), - { - mode: loopState.mode, - autoCommit: loopState.autoCommit ?? config.execution.autoCommit, - autoReview: loopState.autoReview ?? config.execution.autoReview, - saveReviews: loopState.saveReviews ?? config.execution.saveReviews, - skipFinalStatus: false, - }, - ); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error"); - // Leave loop-active.json in place so the user can retry via - // /ralpi resume after addressing the underlying error. - } - return; - } - }); + try { + await resumeLoop( + ctx, + loopState.taskFile, + projectDir, + loopState.prdKey, + sendProgress, + config.model ?? ctx.model, + pi.getThinkingLevel(), + { + mode: loopState.mode, + autoCommit: loopState.autoCommit ?? config.execution.autoCommit, + autoReview: loopState.autoReview ?? config.execution.autoReview, + saveReviews: loopState.saveReviews ?? config.execution.saveReviews, + skipFinalStatus: false, + }, + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error"); + // Leave loop-active.json in place so the user can retry via + // /ralpi resume after addressing the underlying error. + } + return; + } + }); - pi.registerCommand("ralpi", { - description: - "Execute tasks from a task file using DAG-based dependency resolution", - handler: async (args: string, ctx: ExtensionContext) => { - const parts = (args || "").trim().split(/\s+/).filter(Boolean); - const sendProgress = makeSendProgress(pi); + pi.registerCommand("ralpi", { + description: + "Execute tasks from a task file using DAG-based dependency resolution", + handler: async (args: string, ctx: ExtensionContext) => { + const parts = (args || "").trim().split(/\s+/).filter(Boolean); + const sendProgress = makeSendProgress(pi); - // If no args, show plan. If first token looks like a path (@path, /path, ./path), - // route to run so the execution mode prompt fires. - if (parts.length === 0) { - return handlePlan(ctx, parts); - } - if (looksLikePath(parts[0])) { - return handleRun( - ctx, - parts, - sendProgress, - ctx.model, - pi.getThinkingLevel(), - ); - } + // If no args, show plan. If first token looks like a path (@path, /path, ./path), + // route to run so the execution mode prompt fires. + if (parts.length === 0) { + return handlePlan(ctx, parts); + } + if (looksLikePath(parts[0])) { + return handleRun( + ctx, + parts, + sendProgress, + ctx.model, + pi.getThinkingLevel(), + ); + } - // Subcommands (run/plan/resume/reset) are handled by the dash commands - // below — /ralpi only dispatches no-args → plan and path → run. - ctx.ui.notify( - `Unknown: ${parts[0]}. Use /ralpi-run, /ralpi-plan, /ralpi-resume, or /ralpi-reset`, - "error", - ); - }, - }); + // Subcommands (run/plan/resume/reset) are handled by the dash commands + // below — /ralpi only dispatches no-args → plan and path → run. + ctx.ui.notify( + `Unknown: ${parts[0]}. Use /ralpi-run, /ralpi-plan, /ralpi-resume, or /ralpi-reset`, + "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 ` 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(), - ); - }, - }); + // ─── 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 ` 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-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-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); - }, - }); + 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); + }, + }); } // ─── /ralpi plan ───────────────────────────────────────────────────────────── async function handlePlan( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); - const project = parseTaskFile(taskFile); - if (!Array.isArray(project.tasks)) { - throw new Error( - `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, - ); - } + const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); + const project = parseTaskFile(taskFile); + if (!Array.isArray(project.tasks)) { + throw new Error( + `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, + ); + } - const planPrompt = buildPlanPrompt(project); - const plan = buildExecutionPlan(project, new Set()); - const formatted = formatExecutionPlan(plan); + const planPrompt = buildPlanPrompt(project); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan); - ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); + ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); } // ─── /ralpi run ────────────────────────────────────────────────────────────── async function handleRun( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: SendChatMessage, - parentModel?: unknown, - parentThinkingLevel?: unknown, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, + parentModel?: unknown, + parentThinkingLevel?: unknown, ): Promise { - const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); + const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); - // If targeting a specific task file and there's existing progress for it, - // auto-resume instead of starting fresh - const existingProgress = findProgressFile(ctx.cwd, taskFile); - if (existingProgress) { - return handleResume( - ctx, - args.slice(0, 1), - sendChatMessage, - parentModel, - parentThinkingLevel, - ); - } + // If targeting a specific task file and there's existing progress for it, + // auto-resume instead of starting fresh + const existingProgress = findProgressFile(ctx.cwd, taskFile); + if (existingProgress) { + return handleResume( + ctx, + args.slice(0, 1), + sendChatMessage, + parentModel, + parentThinkingLevel, + ); + } - // No existing progress for this task — check for any progress at all - const found = findProgressFile(ctx.cwd); - if (found && !args[0]) { - // Offer to resume instead of starting fresh - const shouldResume = await ctx.ui.select( - "Found existing ralpi progress. Resume?", - ["Yes, resume", "No, start fresh"], - ); + // No existing progress for this task — check for any progress at all + const found = findProgressFile(ctx.cwd); + if (found && !args[0]) { + // Offer to resume instead of starting fresh + const shouldResume = await ctx.ui.select( + "Found existing ralpi progress. Resume?", + ["Yes, resume", "No, start fresh"], + ); - if (shouldResume?.startsWith("Yes")) { - return handleResume( - ctx, - [], - sendChatMessage, - parentModel, - parentThinkingLevel, - ); - } - } + if (shouldResume?.startsWith("Yes")) { + return handleResume( + ctx, + [], + sendChatMessage, + parentModel, + parentThinkingLevel, + ); + } + } - const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; + const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; - const project = parseTaskFile(taskFile); - const config = loadConfig(projectDir); - config.model = parentModel ?? ctx.model; - config.thinkingLevel = parentThinkingLevel; - const progress = new ProgressTracker(projectDir, taskFile); + const project = parseTaskFile(taskFile); + const config = loadConfig(projectDir); + config.model = parentModel ?? ctx.model; + config.thinkingLevel = parentThinkingLevel; + const progress = new ProgressTracker(projectDir, taskFile); - const completed = buildCompletedSet(progress, project); - const mode = await selectExecutionMode(ctx, project, taskFile, config); - const { autoCommit, autoReview, saveReviews } = await selectLoopOptions( - ctx, - config, - ); - config.execution.autoCommit = autoCommit; - config.execution.autoReview = autoReview; - config.execution.saveReviews = saveReviews; - const plan = buildPlanByMode(mode, project, completed); + const completed = buildCompletedSet(progress, project); + const mode = await selectExecutionMode(ctx, project, taskFile, config); + const { autoCommit, autoReview, saveReviews } = await selectLoopOptions( + ctx, + config, + ); + config.execution.autoCommit = autoCommit; + config.execution.autoReview = autoReview; + config.execution.saveReviews = saveReviews; + const plan = buildPlanByMode(mode, project, completed); - // Show dependency chain + execution plan before starting - const depChain = formatDependencyChain(project); - const formattedPlan = formatExecutionPlan(plan); - if (mode === "parallel") { - ctx.ui.notify( - `${depChain}\n\n${formattedPlan}\n\nStarting parallel execution...`, - "info", - ); - } else { - ctx.ui.notify( - `${formattedPlan}\n\nStarting sequential execution...`, - "info", - ); - } + // Show dependency chain + execution plan before starting + const depChain = formatDependencyChain(project); + const formattedPlan = formatExecutionPlan(plan); + if (mode === "parallel") { + ctx.ui.notify( + `${depChain}\n\n${formattedPlan}\n\nStarting parallel execution...`, + "info", + ); + } else { + ctx.ui.notify( + `${formattedPlan}\n\nStarting sequential execution...`, + "info", + ); + } - await executePlanBatches( - plan, - project, - taskFile, - config, - progress, - ctx, - mode, - sendChatMessage, - projectDir, - ); + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + ); - const state = progress.getState(); - const output = formatProgressStatus(state); + const state = progress.getState(); + const output = formatProgressStatus(state); - const reflections = progress.getAllReflections(); - if (reflections.length > 0) { - ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info"); - return; - } + const reflections = progress.getAllReflections(); + if (reflections.length > 0) { + ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info"); + return; + } - ctx.ui.notify(output, "info"); + ctx.ui.notify(output, "info"); } // ─── /ralpi status ─────────────────────────────────────────────────────────── @@ -862,235 +864,237 @@ async function handleRun( * When omitted, the user is prompted as usual. */ async function resumeLoop( - ctx: ExtensionContext, - taskFile: string, - projectDir: string, - prdKey: string | undefined, - sendChatMessage: SendChatMessage | undefined, - parentModel: unknown, - parentThinkingLevel: unknown, - options?: { - mode?: ExecutionMode; - autoCommit?: boolean; - autoReview?: boolean; - saveReviews?: boolean; - skipFinalStatus?: boolean; - }, + ctx: ExtensionContext, + taskFile: string, + projectDir: string, + prdKey: string | undefined, + sendChatMessage: SendChatMessage | undefined, + parentModel: unknown, + parentThinkingLevel: unknown, + options?: { + mode?: ExecutionMode; + autoCommit?: boolean; + autoReview?: boolean; + saveReviews?: boolean; + skipFinalStatus?: boolean; + }, ): Promise { - const project = parseTaskFile(taskFile); - if (!Array.isArray(project.tasks)) { - throw new Error( - `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, - ); - } - const config = loadConfig(projectDir); - config.model = parentModel ?? ctx.model; - config.thinkingLevel = parentThinkingLevel; - const progress = new ProgressTracker(projectDir, taskFile, prdKey); + const project = parseTaskFile(taskFile); + if (!Array.isArray(project.tasks)) { + throw new Error( + `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, + ); + } + const config = loadConfig(projectDir); + config.model = parentModel ?? ctx.model; + config.thinkingLevel = parentThinkingLevel; + const progress = new ProgressTracker(projectDir, taskFile, prdKey); - progress.setPaused(false); + progress.setPaused(false); - // ── Self-heal: finalize tasks that finished but were never merged ── - // - // A review-gated task whose agent committed + reviewed successfully still - // needs a final merge into main + worktree removal to be "done". If the - // 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})`, - ); - } - } + // ── Self-heal: finalize tasks that finished but were never merged ── + // + // A review-gated task whose agent committed + reviewed successfully still + // needs a final merge into main + worktree removal to be "done". If the + // 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(); - if (resetIds.length > 0) { - // Keep the source-file checkboxes in sync so a later parse sees these - // tasks as `pending` rather than `in_progress`. - for (const id of resetIds) { - try { - updateTaskInFile(taskFile, id, "pending"); - } catch { - // Best-effort — progress.json is the source of truth for scheduling. - } - } - ctx.ui.notify( - `Reset stalled in-progress task(s) to pending: ${resetIds.join(", ")}`, - "info", - ); - } + // 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(); + if (resetIds.length > 0) { + // Keep the source-file checkboxes in sync so a later parse sees these + // tasks as `pending` rather than `in_progress`. + for (const id of resetIds) { + try { + updateTaskInFile(taskFile, id, "pending"); + } catch { + // Best-effort — progress.json is the source of truth for scheduling. + } + } + ctx.ui.notify( + `Reset stalled in-progress task(s) to pending: ${resetIds.join(", ")}`, + "info", + ); + } - const completed = buildCompletedSet(progress, project); - const mode = - options?.mode ?? - (await selectExecutionMode(ctx, project, taskFile, config)); + const completed = buildCompletedSet(progress, project); + const mode = + options?.mode ?? + (await selectExecutionMode(ctx, project, taskFile, config)); - let autoCommit: boolean; - let autoReview: boolean; - let saveReviews: boolean; - if ( - options?.autoCommit !== undefined && - options?.autoReview !== undefined && - options?.saveReviews !== undefined - ) { - autoCommit = options.autoCommit; - autoReview = options.autoReview; - saveReviews = options.saveReviews; - } else { - const opt = await selectLoopOptions(ctx, config); - autoCommit = opt.autoCommit; - autoReview = opt.autoReview; - saveReviews = opt.saveReviews; - } - config.execution.autoCommit = autoCommit; - config.execution.autoReview = autoReview; - config.execution.saveReviews = saveReviews; - const plan = buildPlanByMode(mode, project, completed); + let autoCommit: boolean; + let autoReview: boolean; + let saveReviews: boolean; + if ( + options?.autoCommit !== undefined && + options?.autoReview !== undefined && + options?.saveReviews !== undefined + ) { + autoCommit = options.autoCommit; + autoReview = options.autoReview; + saveReviews = options.saveReviews; + } else { + const opt = await selectLoopOptions(ctx, config); + autoCommit = opt.autoCommit; + autoReview = opt.autoReview; + saveReviews = opt.saveReviews; + } + config.execution.autoCommit = autoCommit; + config.execution.autoReview = autoReview; + config.execution.saveReviews = saveReviews; + const plan = buildPlanByMode(mode, project, completed); - // Print remaining batches before executing - const formattedPlan = formatExecutionPlan(plan); - if (mode === "parallel") { - ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info"); - } else { - ctx.ui.notify( - `${formattedPlan}\n\nResuming sequential execution...`, - "info", - ); - } + // Print remaining batches before executing + const formattedPlan = formatExecutionPlan(plan); + if (mode === "parallel") { + ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info"); + } else { + ctx.ui.notify( + `${formattedPlan}\n\nResuming sequential execution...`, + "info", + ); + } - await executePlanBatches( - plan, - project, - taskFile, - config, - progress, - ctx, - mode, - sendChatMessage, - projectDir, - true, // isResume — preserve in-progress worktrees, continue them - ); + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + true, // isResume — preserve in-progress worktrees, continue them + ); - if (!options?.skipFinalStatus) { - ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); - } + if (!options?.skipFinalStatus) { + ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); + } } async function handleResume( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: SendChatMessage, - parentModel?: unknown, - parentThinkingLevel?: unknown, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, + parentModel?: unknown, + parentThinkingLevel?: unknown, ): Promise { - let taskFile: string; - let projectDir: string; - let prdKey: string | undefined; + let taskFile: string; + let projectDir: string; + let prdKey: string | undefined; - if (args[0]) { - taskFile = resolveTaskArg(args[0], ctx.cwd); - const found = findProgressFile(ctx.cwd, taskFile); - if (!found) { - ctx.ui.notify( - `No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`, - "warning", - ); - return; - } - projectDir = path.dirname(path.dirname(found.path)); - prdKey = found.prdKey; - } else { - const found = findProgressFile(ctx.cwd); - if (!found) { - ctx.ui.notify( - "No .ralpi/progress.json found. Start with /ralpi run [task-file]", - "warning", - ); - return; - } - projectDir = path.dirname(path.dirname(found.path)); + if (args[0]) { + taskFile = resolveTaskArg(args[0], ctx.cwd); + const found = findProgressFile(ctx.cwd, taskFile); + if (!found) { + ctx.ui.notify( + `No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`, + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + prdKey = found.prdKey; + } else { + const found = findProgressFile(ctx.cwd); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); - // When no specific task file is given, let the user select which loop - // to resume from multiple PRDs (sorted by most recent first). - const selected = await selectPRDToResume(ctx, found); - if (!selected) { - ctx.ui.notify("Resume cancelled.", "info"); - return; - } - taskFile = selected.sourcePath; - prdKey = selected.prdKey; - } + // When no specific task file is given, let the user select which loop + // to resume from multiple PRDs (sorted by most recent first). + const selected = await selectPRDToResume(ctx, found); + if (!selected) { + ctx.ui.notify("Resume cancelled.", "info"); + return; + } + taskFile = selected.sourcePath; + 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; - })(); + // 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( - ctx, - taskFile, - projectDir, - prdKey, - sendChatMessage, - parentModel, - parentThinkingLevel, - loopOpts, - ); + await resumeLoop( + ctx, + taskFile, + projectDir, + prdKey, + sendChatMessage, + parentModel, + parentThinkingLevel, + loopOpts, + ); } // ─── /ralpi next ───────────────────────────────────────────────────────────── @@ -1099,32 +1103,32 @@ async function handleResume( // ─── /ralpi reset ──────────────────────────────────────────────────────────── async function handleReset( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - if (args[0]) { - const taskFile = resolveTaskArg(args[0], ctx.cwd); - const found = findProgressFile(ctx.cwd, taskFile); - const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; - const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); - progress.reset(); - } else { - const found = findProgressFile(ctx.cwd); - if (!found) { - ctx.ui.notify( - "No .ralpi/progress.json found. Start with /ralpi run [task-file]", - "warning", - ); - return; - } - const projectDir = path.dirname(path.dirname(found.path)); - // Use the most recently updated PRD (first in sorted order) - const prds = listPRDsSorted(found.state); - const sourcePath = - prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath; - const progress = new ProgressTracker(projectDir, sourcePath); - progress.reset(); - } + if (args[0]) { + const taskFile = resolveTaskArg(args[0], ctx.cwd); + const found = findProgressFile(ctx.cwd, taskFile); + const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; + const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); + progress.reset(); + } else { + const found = findProgressFile(ctx.cwd); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } + const projectDir = path.dirname(path.dirname(found.path)); + // Use the most recently updated PRD (first in sorted order) + const prds = listPRDsSorted(found.state); + const sourcePath = + prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath; + const progress = new ProgressTracker(projectDir, sourcePath); + progress.reset(); + } - ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); + ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); } diff --git a/src/executor.ts b/src/executor.ts index 35a618b..ae9ab28 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -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> | 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 }