Add loop-active marker, YAML task file support, and auto-updating PRD checkboxes

- Persist loop-active state for widget re-instantiation after session reload
- Add YAML task file parsing and update support via yaml library
- Auto-update PRD source file checkboxes on task status changes
- Add batchRender callback for real-time parallel widget animation
- Normalize tabs-to-spaces indentation across source files
- Use padStart(2, '0') for ID formatting instead of hardcoded prefix
- Enable parallel execution for single-task DAG batches
This commit is contained in:
2026-05-31 11:44:47 -04:00
parent 30f177b4d9
commit 424e2fa885
5 changed files with 1360 additions and 703 deletions

1237
index.ts

File diff suppressed because it is too large Load Diff

View File

@@ -7,25 +7,25 @@ import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
* Returns a Set of blocked task IDs. * Returns a Set of blocked task IDs.
*/ */
export function getBlockedTasks( export function getBlockedTasks(
pendingTasks: Task[], pendingTasks: Task[],
failedTaskIds: Set<string>, failedTaskIds: Set<string>,
): Set<string> { ): Set<string> {
const blocked = new Set<string>(); const blocked = new Set<string>();
let changed = true; let changed = true;
while (changed) { while (changed) {
changed = false; changed = false;
for (const task of pendingTasks) { for (const task of pendingTasks) {
if (blocked.has(task.id)) continue; if (blocked.has(task.id)) continue;
const deps = task.dependencies || []; const deps = task.dependencies || [];
if (deps.some((dep) => failedTaskIds.has(dep))) { if (deps.some((dep) => failedTaskIds.has(dep))) {
blocked.add(task.id); blocked.add(task.id);
changed = true; changed = true;
} }
} }
} }
return blocked; return blocked;
} }
// ─── Main Entry ────────────────────────────────────────────────────────────── // ─── Main Entry ──────────────────────────────────────────────────────────────
@@ -35,29 +35,29 @@ export function getBlockedTasks(
* Returns ordered batches of parallelizable tasks. * Returns ordered batches of parallelizable tasks.
*/ */
export function buildExecutionPlan( export function buildExecutionPlan(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
parallelGroup?: number, parallelGroup?: number,
failedTaskIds: Set<string> = new Set(), failedTaskIds: Set<string> = new Set(),
): ExecutionPlan { ): ExecutionPlan {
// Filter out already completed tasks // Filter out already completed tasks
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
// If parallel_group is explicitly set, use group-based batching // If parallel_group is explicitly set, use group-based batching
if (parallelGroup !== undefined) { if (parallelGroup !== undefined) {
return { return {
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds), batches: buildParallelGroupBatches(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length, totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)), skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
}; };
} }
// Use dependency-based Kahn's algorithm // Use dependency-based Kahn's algorithm
return { return {
batches: buildBatches(pendingTasks, failedTaskIds), batches: buildBatches(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length, totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)), skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
}; };
} }
// ─── Sequential Plan ───────────────────────────────────────────────────────── // ─── Sequential Plan ─────────────────────────────────────────────────────────
@@ -66,77 +66,77 @@ export function buildExecutionPlan(
* Build a sequential execution plan (one task per batch) * Build a sequential execution plan (one task per batch)
*/ */
export function buildSequentialPlan( export function buildSequentialPlan(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
failedTaskIds: Set<string> = new Set(), failedTaskIds: Set<string> = new Set(),
): ExecutionPlan { ): ExecutionPlan {
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
// Mark tasks with failed dependencies as skipped // Mark tasks with failed dependencies as skipped
const blocked = getBlockedTasks(pendingTasks, failedTaskIds); const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const skippedTasks = project.tasks.filter( const skippedTasks = project.tasks.filter(
(t) => completed.has(t.id) || blocked.has(t.id), (t) => completed.has(t.id) || blocked.has(t.id),
); );
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({ const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
tasks: [task], tasks: [task],
batchIndex: i, batchIndex: i,
})); }));
return { return {
batches, batches,
totalTasks: pendingTasks.length, totalTasks: pendingTasks.length,
skippedTasks, skippedTasks,
}; };
} }
// ─── Kahn's Algorithm (Dependency-Based Batching) ──────────────────────────── // ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
function buildBatches( function buildBatches(
pendingTasks: Task[], pendingTasks: Task[],
failedTaskIds: Set<string>, failedTaskIds: Set<string>,
): ExecutionBatch[] { ): ExecutionBatch[] {
const batches: ExecutionBatch[] = []; const batches: ExecutionBatch[] = [];
const done = new Set<string>(); const done = new Set<string>();
const blocked = getBlockedTasks(pendingTasks, failedTaskIds); const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const pendingSet = new Set(pendingTasks.map((t) => t.id)); const pendingSet = new Set(pendingTasks.map((t) => t.id));
const remaining = new Set( const remaining = new Set(
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id), pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
); );
while (remaining.size > 0) { while (remaining.size > 0) {
// Find tasks whose dependencies are all satisfied // Find tasks whose dependencies are all satisfied
const ready: Task[] = []; const ready: Task[] = [];
for (const task of pendingTasks) { for (const task of pendingTasks) {
if (!remaining.has(task.id)) continue; if (!remaining.has(task.id)) continue;
const deps = task.dependencies || []; const deps = task.dependencies || [];
const depsSatisfied = deps.every( const depsSatisfied = deps.every(
(dep) => done.has(dep) || !pendingSet.has(dep), (dep) => done.has(dep) || !pendingSet.has(dep),
); );
if (depsSatisfied) { if (depsSatisfied) {
ready.push(task); ready.push(task);
} }
} }
// Cycle detection: no tasks ready but some remain // Cycle detection: no tasks ready but some remain
if (ready.length === 0) { if (ready.length === 0) {
const cycleTasks = Array.from(remaining); const cycleTasks = Array.from(remaining);
throw new Error( throw new Error(
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`, `Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
); );
} }
batches.push({ tasks: ready, batchIndex: batches.length }); batches.push({ tasks: ready, batchIndex: batches.length });
for (const task of ready) { for (const task of ready) {
done.add(task.id); done.add(task.id);
remaining.delete(task.id); remaining.delete(task.id);
} }
} }
return batches; return batches;
} }
// ─── Parallel Group Batching ───────────────────────────────────────────────── // ─── Parallel Group Batching ─────────────────────────────────────────────────
@@ -146,26 +146,26 @@ function buildBatches(
* Groups execute in ascending order; tasks within a group run concurrently. * Groups execute in ascending order; tasks within a group run concurrently.
*/ */
function buildParallelGroupBatches( function buildParallelGroupBatches(
pendingTasks: Task[], pendingTasks: Task[],
failedTaskIds: Set<string>, failedTaskIds: Set<string>,
): ExecutionBatch[] { ): ExecutionBatch[] {
const blocked = getBlockedTasks(pendingTasks, failedTaskIds); const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const groups = new Map<number, Task[]>(); const groups = new Map<number, Task[]>();
for (const task of activeTasks) { for (const task of activeTasks) {
const group = task.parallelGroup ?? 0; const group = task.parallelGroup ?? 0;
if (!groups.has(group)) groups.set(group, []); if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(task); groups.get(group)!.push(task);
} }
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]); const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
return sortedGroups.map(([_groupNum, tasks], i) => ({ return sortedGroups.map(([_groupNum, tasks], i) => ({
tasks, tasks,
batchIndex: i, batchIndex: i,
})); }));
} }
// ─── Cycle Detection ───────────────────────────────────────────────────────── // ─── Cycle Detection ─────────────────────────────────────────────────────────
@@ -174,51 +174,51 @@ function buildParallelGroupBatches(
* Detect cycles in the task dependency graph * Detect cycles in the task dependency graph
*/ */
export function detectCycles(project: Project): string[] { export function detectCycles(project: Project): string[] {
const adj = new Map<string, string[]>(); const adj = new Map<string, string[]>();
for (const task of project.tasks) { for (const task of project.tasks) {
adj.set(task.id, task.dependencies || []); adj.set(task.id, task.dependencies || []);
} }
const WHITE = 0; const WHITE = 0;
const GRAY = 1; const GRAY = 1;
const BLACK = 2; const BLACK = 2;
const color = new Map<string, number>(); const color = new Map<string, number>();
for (const task of project.tasks) { for (const task of project.tasks) {
color.set(task.id, WHITE); color.set(task.id, WHITE);
} }
const cycleNodes: string[] = []; const cycleNodes: string[] = [];
function dfs(node: string): boolean { function dfs(node: string): boolean {
color.set(node, GRAY); color.set(node, GRAY);
const deps = adj.get(node) || []; const deps = adj.get(node) || [];
for (const dep of deps) { for (const dep of deps) {
if (!adj.has(dep)) continue; if (!adj.has(dep)) continue;
const depColor = color.get(dep); const depColor = color.get(dep);
if (depColor === GRAY) { if (depColor === GRAY) {
cycleNodes.push(dep); cycleNodes.push(dep);
return true; return true;
} }
if (depColor === WHITE && dfs(dep)) { if (depColor === WHITE && dfs(dep)) {
cycleNodes.push(node); cycleNodes.push(node);
return true; return true;
} }
} }
color.set(node, BLACK); color.set(node, BLACK);
return false; return false;
} }
for (const task of project.tasks) { for (const task of project.tasks) {
if (color.get(task.id) === WHITE) { if (color.get(task.id) === WHITE) {
dfs(task.id); dfs(task.id);
} }
} }
return [...new Set(cycleNodes)]; return [...new Set(cycleNodes)];
} }
// ─── Ready Tasks ───────────────────────────────────────────────────────────── // ─── Ready Tasks ─────────────────────────────────────────────────────────────
@@ -227,14 +227,14 @@ export function detectCycles(project: Project): string[] {
* Get tasks that are ready to execute (all dependencies completed) * Get tasks that are ready to execute (all dependencies completed)
*/ */
export function getReadyTasks( export function getReadyTasks(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
): Task[] { ): Task[] {
return project.tasks.filter((task) => { return project.tasks.filter((task) => {
if (completed.has(task.id)) return false; if (completed.has(task.id)) return false;
const deps = task.dependencies || []; const deps = task.dependencies || [];
return deps.every((dep) => completed.has(dep)); return deps.every((dep) => completed.has(dep));
}); });
} }
// ─── Critical Path ─────────────────────────────────────────────────────────── // ─── Critical Path ───────────────────────────────────────────────────────────
@@ -243,70 +243,159 @@ export function getReadyTasks(
* Calculate the critical path (longest path through the DAG) * Calculate the critical path (longest path through the DAG)
*/ */
export function getCriticalPath(project: Project): Task[] { export function getCriticalPath(project: Project): Task[] {
const taskMap = new Map(project.tasks.map((t) => [t.id, t])); const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const dist = new Map<string, number>(); const dist = new Map<string, number>();
const prev = new Map<string, string | null>(); const prev = new Map<string, string | null>();
// Initialize // Initialize
for (const task of project.tasks) { for (const task of project.tasks) {
dist.set(task.id, 1); dist.set(task.id, 1);
prev.set(task.id, null); prev.set(task.id, null);
} }
// Topological sort // Topological sort
const sorted: Task[] = []; const sorted: Task[] = [];
const visited = new Set<string>(); const visited = new Set<string>();
function visit(id: string) { function visit(id: string) {
if (visited.has(id)) return; if (visited.has(id)) return;
visited.add(id); visited.add(id);
const task = taskMap.get(id); const task = taskMap.get(id);
if (!task) return; if (!task) return;
for (const dep of task.dependencies || []) { for (const dep of task.dependencies || []) {
visit(dep); visit(dep);
} }
sorted.push(task); sorted.push(task);
} }
for (const task of project.tasks) { for (const task of project.tasks) {
visit(task.id); visit(task.id);
} }
// Relax edges // Relax edges
for (const task of sorted) { for (const task of sorted) {
for (const dep of task.dependencies || []) { for (const dep of task.dependencies || []) {
const depDist = dist.get(dep); const depDist = dist.get(dep);
if (depDist === undefined) continue; if (depDist === undefined) continue;
const newDist = depDist + 1; const newDist = depDist + 1;
const currentDist = dist.get(task.id) ?? 0; const currentDist = dist.get(task.id) ?? 0;
if (newDist > currentDist) { if (newDist > currentDist) {
dist.set(task.id, newDist); dist.set(task.id, newDist);
prev.set(task.id, dep); prev.set(task.id, dep);
} }
} }
} }
// Trace back from the longest path end // Trace back from the longest path end
let maxTask = project.tasks[0]; let maxTask = project.tasks[0];
for (const task of project.tasks) { for (const task of project.tasks) {
const taskDist = dist.get(task.id) ?? 0; const taskDist = dist.get(task.id) ?? 0;
const maxDist = dist.get(maxTask.id) ?? 0; const maxDist = dist.get(maxTask.id) ?? 0;
if (taskDist > maxDist) { if (taskDist > maxDist) {
maxTask = task; maxTask = task;
} }
} }
const path: Task[] = []; const path: Task[] = [];
let current: string | null = maxTask.id; let current: string | null = maxTask.id;
while (current) { while (current) {
const task = taskMap.get(current); const task = taskMap.get(current);
if (task) path.unshift(task); if (task) path.unshift(task);
current = prev.get(current) || null; current = prev.get(current) || null;
} }
return path; return path;
}
// ─── Format Dependency Chain ─────────────────────────────────────────────────
/**
* Format the dependency DAG as a tree for display.
* Rooted at tasks with no dependencies, showing what depends on what.
*/
export function formatDependencyChain(project: Project): string {
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const lines: string[] = [];
lines.push("## Dependency Chain");
lines.push("");
if (project.tasks.length === 0) {
lines.push("(no tasks)");
return lines.join("\n");
}
// Build reverse dependency map: taskId → [dependent taskIds]
const dependents = new Map<string, string[]>();
for (const task of project.tasks) {
dependents.set(task.id, []);
}
for (const task of project.tasks) {
for (const dep of task.dependencies) {
if (dependents.has(dep)) {
dependents.get(dep)!.push(task.id);
}
}
}
// Root tasks: those with no dependencies
const roots = project.tasks.filter((t) => t.dependencies.length === 0);
const rendered = new Set<string>();
function renderNode(taskId: string, prefix: string, isLast: boolean): void {
const task = taskMap.get(taskId);
if (!task) return;
const alreadyRendered = rendered.has(taskId);
rendered.add(taskId);
const connector = prefix ? (isLast ? "└── " : "├── ") : "";
if (alreadyRendered) {
lines.push(`${prefix}${connector}${task.id} · ${task.title}`);
return;
}
const deps =
task.dependencies.length > 0
? ` ← needs ${task.dependencies.join(", ")}`
: " (root)";
lines.push(
`${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`,
);
const children = (dependents.get(taskId) || [])
.filter((c) => c !== taskId)
.sort();
for (let i = 0; i < children.length; i++) {
const childPrefix = prefix + (isLast ? " " : "│ ");
renderNode(children[i], childPrefix, i === children.length - 1);
}
}
for (let i = 0; i < roots.length; i++) {
renderNode(roots[i].id, "", i === roots.length - 1);
}
// Tasks not reached from any root (have deps but no root-traversable path)
const unreached = project.tasks.filter((t) => !rendered.has(t.id));
if (unreached.length > 0) {
lines.push("");
lines.push("Orphan tasks (dependencies not in task list):");
for (const t of unreached) {
const deps =
t.dependencies.length > 0
? ` ← needs ${t.dependencies.join(", ")}`
: "";
lines.push(` ${t.id} · ${t.title}${deps}`);
}
}
return lines.join("\n");
} }
// ─── Format Execution Plan ─────────────────────────────────────────────────── // ─── Format Execution Plan ───────────────────────────────────────────────────
@@ -315,26 +404,26 @@ export function getCriticalPath(project: Project): Task[] {
* Format the execution plan for display * Format the execution plan for display
*/ */
export function formatExecutionPlan(plan: ExecutionPlan): string { export function formatExecutionPlan(plan: ExecutionPlan): string {
const lines: string[] = []; const lines: string[] = [];
lines.push("## Execution Plan"); lines.push("## Execution Plan");
lines.push(""); lines.push("");
lines.push(`Total tasks: ${plan.totalTasks}`); lines.push(`Total tasks: ${plan.totalTasks}`);
lines.push(`Batches: ${plan.batches.length}`); lines.push(`Batches: ${plan.batches.length}`);
if (plan.skippedTasks.length > 0) { if (plan.skippedTasks.length > 0) {
lines.push( lines.push(
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`, `Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
); );
} }
lines.push(""); lines.push("");
for (const batch of plan.batches) { for (const batch of plan.batches) {
lines.push(`### Batch ${batch.batchIndex + 1}`); lines.push(`### Batch ${batch.batchIndex + 1}`);
for (const task of batch.tasks) { for (const task of batch.tasks) {
lines.push(`- ${task.id}: ${task.title}`); lines.push(`- ${task.id}: ${task.title}`);
} }
lines.push(""); lines.push("");
} }
return lines.join("\n"); return lines.join("\n");
} }

View File

@@ -13,6 +13,7 @@ import {
captureGitCommits, captureGitCommits,
formatDuration, formatDuration,
} from "./utils"; } from "./utils";
import { updateTaskInFile } from "./parser";
/** Optional callback to post a progress message into the chat history. */ /** Optional callback to post a progress message into the chat history. */
export type SendChatMessage = ( export type SendChatMessage = (
@@ -33,7 +34,18 @@ export interface ToolCallEntry {
* messages rendered by registerMessageRenderer). */ * messages rendered by registerMessageRenderer). */
const MAX_COLLAPSED = 3; const MAX_COLLAPSED = 3;
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; export const SPINNER_FRAMES = [
"⠋",
"⠙",
"⠹",
"⠸",
"⠼",
"⠴",
"⠦",
"⠧",
"⠇",
"⠏",
];
// ─── Model Round-Robin ───────────────────────────────────────────────────── // ─── Model Round-Robin ─────────────────────────────────────────────────────
@@ -135,6 +147,7 @@ export async function runTask(
projectDir: string = project.sourceDir, projectDir: string = project.sourceDir,
parallelState?: ParallelWidgetState, parallelState?: ParallelWidgetState,
assignedModel?: unknown, assignedModel?: unknown,
batchRender?: () => void,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
reflection?: Reflection; reflection?: Reflection;
@@ -271,8 +284,10 @@ export async function runTask(
if (entry) { if (entry) {
entry.toolCalls.push({ name: event.toolName, label }); entry.toolCalls.push({ name: event.toolName, label });
} }
batchRender?.();
} else {
requestRender();
} }
requestRender();
} }
}, },
undefined, // no abort signal undefined, // no abort signal
@@ -291,6 +306,7 @@ export async function runTask(
entry.done = true; entry.done = true;
entry.success = output.success; entry.success = output.success;
} }
batchRender?.();
} else { } else {
ctx.ui.setWidget(widgetKey, undefined); ctx.ui.setWidget(widgetKey, undefined);
} }
@@ -393,9 +409,12 @@ export async function executeBatch(
} }
} }
// Check if we should run parallel // Check if we should run parallel.
// Use the parallel path whenever the user selected parallel mode,
// even for single-task batches produced by DAG dependency chains.
// Only sequential mode should inherit the parent session model.
const shouldParallel = const shouldParallel =
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0; options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
if (shouldParallel) { if (shouldParallel) {
await executeBatchParallel( await executeBatchParallel(
@@ -429,6 +448,12 @@ export async function executeBatch(
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg); progress.markFailed(task.id, errorMsg);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`); sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
break; break;
@@ -518,14 +543,18 @@ async function executeBatchParallel(
}; };
}); });
// Single spinner timer drives all tasks in the batch // Batch-render trigger: re-render on spinner ticks AND content changes.
// Spinner animation requires requestRender() on every tick; without it,
// spinner frames advance in memory but the display never updates.
const requestBatchRender = () => widgetTui?.requestRender();
const spinnerTimer = setInterval(() => { const spinnerTimer = setInterval(() => {
for (const entry of sharedState.values()) { for (const entry of sharedState.values()) {
if (!entry.done) { if (!entry.done) {
entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length; entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length;
} }
} }
widgetTui?.requestRender(); requestBatchRender();
}, 100); }, 100);
const results: Array<{ task: Task; result: Promise<any> }> = []; const results: Array<{ task: Task; result: Promise<any> }> = [];
@@ -545,13 +574,21 @@ async function executeBatchParallel(
sharedState, sharedState,
assignedModel, assignedModel,
roundRobin, roundRobin,
requestBatchRender,
).catch((error) => { ).catch((error) => {
// Safety net: one task failure should never crash the batch. // Safety net: one task failure should never crash the batch.
// executeTask already marks failed and notifies, but catch as // executeTask already marks failed and notifies, but catch as
// a last resort so the error doesn't propagate and crash pi. // a last resort so the error doesn't propagate and crash pi.
roundRobin?.release(task.id); roundRobin?.release(task.id);
requestBatchRender();
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg); progress.markFailed(task.id, errorMsg);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`); sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
}), }),
@@ -586,6 +623,7 @@ async function executeTask(
parallelState?: ParallelWidgetState, parallelState?: ParallelWidgetState,
assignedModel?: unknown, assignedModel?: unknown,
roundRobin?: ModelRoundRobin | null, roundRobin?: ModelRoundRobin | null,
batchRender?: () => void,
): Promise<void> { ): Promise<void> {
const maxRetries = config.execution.maxRetries; const maxRetries = config.execution.maxRetries;
@@ -609,6 +647,12 @@ async function executeTask(
try { try {
// Mark as in progress // Mark as in progress
progress.markInProgress(task.id); progress.markInProgress(task.id);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "in_progress");
} catch {
// Best-effort: don't fail the task over a checkbox update
}
// Get dependency reflections // Get dependency reflections
const depReflections = progress.getDependencyReflections( const depReflections = progress.getDependencyReflections(
@@ -626,6 +670,7 @@ async function executeTask(
projectDir, projectDir,
parallelState, parallelState,
currentModel, currentModel,
batchRender,
); );
if (result.success) { if (result.success) {
@@ -645,6 +690,12 @@ async function executeTask(
result.commitMessages, result.commitMessages,
result.commitSummary, result.commitSummary,
); );
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "completed");
} catch {
// Best-effort: don't fail the task over a checkbox update
}
roundRobin?.release(task.id); roundRobin?.release(task.id);
return; return;
} }
@@ -675,6 +726,7 @@ async function executeTask(
} else { } else {
// Max retries exceeded // Max retries exceeded
progress.markFailed(task.id, result.error || "Unknown error"); progress.markFailed(task.id, result.error || "Unknown error");
// Don't update PRD — retry exhaustion is transient, not terminal
sendChatMessage?.(`${task.id} · ${task.title}${result.error}`); sendChatMessage?.(`${task.id} · ${task.title}${result.error}`);
ctx.ui.notify( ctx.ui.notify(
`Task ${task.id} failed after ${maxRetries} retries: ${ `Task ${task.id} failed after ${maxRetries} retries: ${
@@ -686,8 +738,15 @@ async function executeTask(
} }
} catch (error) { } catch (error) {
roundRobin?.release(task.id); roundRobin?.release(task.id);
batchRender?.();
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg); progress.markFailed(task.id, errorMsg);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`); sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return; return;
@@ -700,7 +759,9 @@ async function executeTask(
// All models exhausted — release the slot // All models exhausted — release the slot
roundRobin?.release(task.id); roundRobin?.release(task.id);
batchRender?.();
progress.markFailed(task.id, "All configured models exhausted"); progress.markFailed(task.id, "All configured models exhausted");
// Don't update PRD — model exhaustion is transient, not terminal
sendChatMessage?.( sendChatMessage?.(
`${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`, `${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
); );

View File

@@ -2,6 +2,20 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import type { Task, Project } from "./types"; import type { Task, Project } from "./types";
// Lazy-loaded yaml package
let YAML_module: typeof import("yaml") | undefined;
function loadYaml(): typeof import("yaml") {
if (YAML_module) return YAML_module;
try {
YAML_module = require("yaml");
} catch {
throw new Error(
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
);
}
return YAML_module!;
}
// ─── Main Entry ────────────────────────────────────────────────────────────── // ─── Main Entry ──────────────────────────────────────────────────────────────
/** /**
@@ -75,7 +89,7 @@ function parseFioFormat(
const [, status, id, title, file] = match; const [, status, id, title, file] = match;
const timeoutMs = parseTimeoutFromLine(line); const timeoutMs = parseTimeoutFromLine(line);
tasks.push({ tasks.push({
id: `0${id}`, id: id.padStart(2, "0"),
title: title.trim(), title: title.trim(),
description: undefined, description: undefined,
file: file || undefined, file: file || undefined,
@@ -96,12 +110,12 @@ function parseFioFormat(
); );
if (arrowMatch) { if (arrowMatch) {
const [, from, targets] = arrowMatch; const [, from, targets] = arrowMatch;
const fromId = `0${from}`; const fromId = from.padStart(2, "0");
const targetIds = targets const targetIds = targets
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t) .filter((t) => t)
.map((t) => `0${t}`); .map((t) => t.padStart(2, "0"));
// Each target depends on the source // Each target depends on the source
for (const toId of targetIds) { for (const toId of targetIds) {
@@ -117,12 +131,12 @@ function parseFioFormat(
); );
if (dependsMatch) { if (dependsMatch) {
const [, taskId, depsList] = dependsMatch; const [, taskId, depsList] = dependsMatch;
const taskIdPadded = `0${taskId}`; const taskIdPadded = taskId.padStart(2, "0");
const depIds = depsList const depIds = depsList
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t) .filter((t) => t)
.map((t) => `0${t}`); .map((t) => t.padStart(2, "0"));
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
dependencies[taskIdPadded].push(...depIds); dependencies[taskIdPadded].push(...depIds);
@@ -134,7 +148,7 @@ function parseFioFormat(
); );
if (metaMatch) { if (metaMatch) {
const [, taskId, value, unit] = metaMatch; const [, taskId, value, unit] = metaMatch;
const task = tasks.find((t) => t.id === `0${taskId}`); const task = tasks.find((t) => t.id === taskId.padStart(2, "0"));
if (task) { if (task) {
task.timeoutMs = parseTimeoutValue(Number(value), unit); task.timeoutMs = parseTimeoutValue(Number(value), unit);
} }
@@ -210,16 +224,7 @@ function parseYaml(
sourcePath: string, sourcePath: string,
sourceDir: string, sourceDir: string,
): Project { ): Project {
// Lazy-load yaml (may not be installed) const YAML = loadYaml();
let YAML: typeof import("yaml");
try {
YAML = require("yaml");
} catch {
throw new Error(
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
);
}
const doc = YAML.parse(content); const doc = YAML.parse(content);
const tasks: Task[] = []; const tasks: Task[] = [];
@@ -263,35 +268,108 @@ export function readTaskSpec(taskDir: string, taskFile: string): string {
// ─── Task File Updater ─────────────────────────────────────────────────────── // ─── Task File Updater ───────────────────────────────────────────────────────
/** /**
* Update task status in the source markdown file * Update task status in the source file (markdown or YAML).
*
* Handles three formats:
* 1. Fio numbered format: `- [ ] 01 Title` — matches by task number in the file
* 2. Simple checkbox: `- [ ] Title` — matches by checkbox position (index)
* 3. YAML: uses `yaml` library to parse, update, and stringify
*/ */
export function updateTaskInFile( export function updateTaskInFile(
filePath: string, filePath: string,
taskId: string, taskId: string,
status: Task["status"], status: Task["status"],
): void { ): void {
let content = fs.readFileSync(filePath, "utf-8"); const ext = path.extname(filePath).toLowerCase();
const char = statusToChar(status);
// Try Fio numbered format first // Handle YAML format
const fioPattern = new RegExp( if (ext === ".yaml" || ext === ".yml") {
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`, updateTaskInYaml(filePath, taskId, status);
"m",
);
if (fioPattern.test(content)) {
content = content.replace(fioPattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
return; return;
} }
// Try simple checkbox format let content = fs.readFileSync(filePath, "utf-8");
const simplePattern = new RegExp( const char = statusToChar(status);
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
"m", // Strategy 1: Fio numbered format — match by explicit task ID in the file
); // Try both padded (01) and raw (1) variations
if (simplePattern.test(content)) { const rawId = parseInt(taskId, 10).toString();
content = content.replace(simplePattern, `$1${char}$3`); const idPatterns = new Set([escapeRegex(taskId), escapeRegex(rawId)]);
fs.writeFileSync(filePath, content, "utf-8");
for (const idPattern of idPatterns) {
const fioRegex = new RegExp(
`(^-\\s+\\[)(.)(\\]\\s+${idPattern}\\s*[—–:-])`,
"m",
);
const match = content.match(fioRegex);
if (match) {
content = content.replace(fioRegex, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
return;
}
}
// Strategy 2: Simple checkbox by position (task IDs are zero-padded indices)
const targetIndex = parseInt(taskId, 10);
if (!isNaN(targetIndex)) {
const lines = content.split("\n");
let checkboxIdx = 0;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\s*-+\s+\[)(.)(\].*)$/);
if (m) {
if (checkboxIdx === targetIndex) {
lines[i] = m[1] + char + m[3];
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
return;
}
checkboxIdx++;
}
}
}
}
/**
* Update task status in a YAML task file using the yaml library's
* Document API, which preserves comments and formatting.
*
* Matches by explicit `id` field first, then falls back to
* position-based matching (for files without explicit IDs).
*/
function updateTaskInYaml(
filePath: string,
taskId: string,
status: Task["status"],
): void {
const YAML = loadYaml();
const content = fs.readFileSync(filePath, "utf-8");
const doc = YAML.parseDocument(content);
const tasks = doc.get("tasks");
if (!tasks || !YAML.isSeq(tasks)) return;
const rawId = parseInt(taskId, 10).toString();
// Strategy 1: Match by explicit id field
for (const item of tasks.items) {
if (!YAML.isMap(item)) continue;
const idVal = item.get("id");
if (idVal === undefined || idVal === null) continue;
const idStr = String(idVal);
if (idStr === taskId || idStr === rawId) {
item.set("status", status);
fs.writeFileSync(filePath, String(doc), "utf-8");
return;
}
}
// Strategy 2: Fall back to position-based matching
// (for YAML files without explicit id fields)
const targetIndex = parseInt(taskId, 10);
if (!isNaN(targetIndex) && targetIndex < tasks.items.length) {
const item = tasks.items[targetIndex];
if (YAML.isMap(item)) {
item.set("status", status);
fs.writeFileSync(filePath, String(doc), "utf-8");
}
} }
} }

View File

@@ -34,6 +34,78 @@ export function writeFileSafe(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, "utf-8"); fs.writeFileSync(filePath, content, "utf-8");
} }
// ─── Loop-Active State ──────────────────────────────────────────────────────
/**
* State persisted to disk when a ralpi execution loop is active.
* Used to re-instantiate widgets after a session reload.
*/
export interface LoopActiveState {
taskFile: string;
mode: "parallel" | "sequential";
startedAt: string;
taskIds: string[];
prdKey: string;
}
/**
* Path (relative to projectDir) where the loop-active marker is stored.
*/
const LOOP_ACTIVE_FILE = ".ralpi/loop-active.json";
/**
* Write the loop-active marker, indicating an execution loop is running.
*/
export function writeLoopActive(
projectDir: string,
state: LoopActiveState,
): void {
writeFileSafe(
path.join(projectDir, LOOP_ACTIVE_FILE),
JSON.stringify(state, null, 2),
);
}
/**
* Read the loop-active marker, if present.
*/
export function readLoopActive(projectDir: string): LoopActiveState | null {
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
try {
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) as LoopActiveState;
} catch {
return null;
}
}
/**
* Delete the loop-active marker.
*/
export function deleteLoopActive(projectDir: string): void {
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
try {
fs.unlinkSync(filePath);
} catch {
// Ignore if already gone
}
}
/**
* Discover the project directory by walking up to find `.ralpi/`.
*/
export function findRalpiDir(startDir: string): string | null {
let current = path.resolve(startDir);
const root = path.parse(current).root;
while (current !== root) {
if (fs.existsSync(path.join(current, ".ralpi"))) {
return current;
}
current = path.dirname(current);
}
return null;
}
// ─── Async Agent Session ──────────────────────────────────────────────────── // ─── Async Agent Session ────────────────────────────────────────────────────
// ─── Progress Discovery ───────────────────────────────────────────────────── // ─── Progress Discovery ─────────────────────────────────────────────────────