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

View File

@@ -13,6 +13,7 @@ import {
captureGitCommits,
formatDuration,
} from "./utils";
import { updateTaskInFile } from "./parser";
/** Optional callback to post a progress message into the chat history. */
export type SendChatMessage = (
@@ -33,7 +34,18 @@ export interface ToolCallEntry {
* messages rendered by registerMessageRenderer). */
const MAX_COLLAPSED = 3;
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export const SPINNER_FRAMES = [
"⠋",
"⠙",
"⠹",
"⠸",
"⠼",
"⠴",
"⠦",
"⠧",
"⠇",
"⠏",
];
// ─── Model Round-Robin ─────────────────────────────────────────────────────
@@ -135,6 +147,7 @@ export async function runTask(
projectDir: string = project.sourceDir,
parallelState?: ParallelWidgetState,
assignedModel?: unknown,
batchRender?: () => void,
): Promise<{
success: boolean;
reflection?: Reflection;
@@ -271,8 +284,10 @@ export async function runTask(
if (entry) {
entry.toolCalls.push({ name: event.toolName, label });
}
batchRender?.();
} else {
requestRender();
}
requestRender();
}
},
undefined, // no abort signal
@@ -291,6 +306,7 @@ export async function runTask(
entry.done = true;
entry.success = output.success;
}
batchRender?.();
} else {
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 =
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
if (shouldParallel) {
await executeBatchParallel(
@@ -429,6 +448,12 @@ export async function executeBatch(
const errorMsg = error instanceof Error ? error.message : String(error);
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}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
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(() => {
for (const entry of sharedState.values()) {
if (!entry.done) {
entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length;
}
}
widgetTui?.requestRender();
requestBatchRender();
}, 100);
const results: Array<{ task: Task; result: Promise<any> }> = [];
@@ -545,13 +574,21 @@ async function executeBatchParallel(
sharedState,
assignedModel,
roundRobin,
requestBatchRender,
).catch((error) => {
// Safety net: one task failure should never crash the batch.
// executeTask already marks failed and notifies, but catch as
// a last resort so the error doesn't propagate and crash pi.
roundRobin?.release(task.id);
requestBatchRender();
const errorMsg = error instanceof Error ? error.message : String(error);
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}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
}),
@@ -586,6 +623,7 @@ async function executeTask(
parallelState?: ParallelWidgetState,
assignedModel?: unknown,
roundRobin?: ModelRoundRobin | null,
batchRender?: () => void,
): Promise<void> {
const maxRetries = config.execution.maxRetries;
@@ -609,6 +647,12 @@ async function executeTask(
try {
// Mark as in progress
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
const depReflections = progress.getDependencyReflections(
@@ -626,6 +670,7 @@ async function executeTask(
projectDir,
parallelState,
currentModel,
batchRender,
);
if (result.success) {
@@ -645,6 +690,12 @@ async function executeTask(
result.commitMessages,
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);
return;
}
@@ -675,6 +726,7 @@ async function executeTask(
} else {
// Max retries exceeded
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}`);
ctx.ui.notify(
`Task ${task.id} failed after ${maxRetries} retries: ${
@@ -686,8 +738,15 @@ async function executeTask(
}
} catch (error) {
roundRobin?.release(task.id);
batchRender?.();
const errorMsg = error instanceof Error ? error.message : String(error);
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}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return;
@@ -700,7 +759,9 @@ async function executeTask(
// All models exhausted — release the slot
roundRobin?.release(task.id);
batchRender?.();
progress.markFailed(task.id, "All configured models exhausted");
// Don't update PRD — model exhaustion is transient, not terminal
sendChatMessage?.(
`${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 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 ──────────────────────────────────────────────────────────────
/**
@@ -75,7 +89,7 @@ function parseFioFormat(
const [, status, id, title, file] = match;
const timeoutMs = parseTimeoutFromLine(line);
tasks.push({
id: `0${id}`,
id: id.padStart(2, "0"),
title: title.trim(),
description: undefined,
file: file || undefined,
@@ -96,12 +110,12 @@ function parseFioFormat(
);
if (arrowMatch) {
const [, from, targets] = arrowMatch;
const fromId = `0${from}`;
const fromId = from.padStart(2, "0");
const targetIds = targets
.split(",")
.map((t) => t.trim())
.filter((t) => t)
.map((t) => `0${t}`);
.map((t) => t.padStart(2, "0"));
// Each target depends on the source
for (const toId of targetIds) {
@@ -117,12 +131,12 @@ function parseFioFormat(
);
if (dependsMatch) {
const [, taskId, depsList] = dependsMatch;
const taskIdPadded = `0${taskId}`;
const taskIdPadded = taskId.padStart(2, "0");
const depIds = depsList
.split(",")
.map((t) => t.trim())
.filter((t) => t)
.map((t) => `0${t}`);
.map((t) => t.padStart(2, "0"));
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
dependencies[taskIdPadded].push(...depIds);
@@ -134,7 +148,7 @@ function parseFioFormat(
);
if (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) {
task.timeoutMs = parseTimeoutValue(Number(value), unit);
}
@@ -210,16 +224,7 @@ function parseYaml(
sourcePath: string,
sourceDir: string,
): Project {
// Lazy-load yaml (may not be installed)
let YAML: typeof import("yaml");
try {
YAML = require("yaml");
} catch {
throw new Error(
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
);
}
const YAML = loadYaml();
const doc = YAML.parse(content);
const tasks: Task[] = [];
@@ -263,35 +268,108 @@ export function readTaskSpec(taskDir: string, taskFile: string): string {
// ─── 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(
filePath: string,
taskId: string,
status: Task["status"],
): void {
let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status);
const ext = path.extname(filePath).toLowerCase();
// Try Fio numbered format first
const fioPattern = new RegExp(
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
"m",
);
if (fioPattern.test(content)) {
content = content.replace(fioPattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
// Handle YAML format
if (ext === ".yaml" || ext === ".yml") {
updateTaskInYaml(filePath, taskId, status);
return;
}
// Try simple checkbox format
const simplePattern = new RegExp(
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
"m",
);
if (simplePattern.test(content)) {
content = content.replace(simplePattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status);
// Strategy 1: Fio numbered format — match by explicit task ID in the file
// Try both padded (01) and raw (1) variations
const rawId = parseInt(taskId, 10).toString();
const idPatterns = new Set([escapeRegex(taskId), escapeRegex(rawId)]);
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");
}
// ─── 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 ────────────────────────────────────────────────────
// ─── Progress Discovery ─────────────────────────────────────────────────────