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:
363
index.ts
363
index.ts
@@ -9,17 +9,26 @@ import { parseTaskFile, updateTaskInFile } from "./src/parser";
|
||||
import {
|
||||
buildExecutionPlan,
|
||||
buildSequentialPlan,
|
||||
formatDependencyChain,
|
||||
formatExecutionPlan,
|
||||
} from "./src/dag";
|
||||
import { ProgressTracker } from "./src/progress";
|
||||
import { buildPlanPrompt } from "./src/prompts";
|
||||
import { formatReflections } from "./src/reflection";
|
||||
import { executeBatch, type SendChatMessage } from "./src/executor";
|
||||
import {
|
||||
executeBatch,
|
||||
SPINNER_FRAMES,
|
||||
type SendChatMessage,
|
||||
} from "./src/executor";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveTaskArg,
|
||||
formatProgressStatus,
|
||||
findProgressFile,
|
||||
writeLoopActive,
|
||||
deleteLoopActive,
|
||||
readLoopActive,
|
||||
findRalpiDir,
|
||||
} from "./src/utils";
|
||||
|
||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
||||
@@ -123,9 +132,22 @@ async function executePlanBatches(
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
): Promise<void> {
|
||||
// Write loop-active marker so widgets can be re-instantiated after a reload
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Track failed task IDs across batches to block downstream tasks
|
||||
const failedTaskIds = new Set(progress.getFailedTaskIds());
|
||||
|
||||
try {
|
||||
for (const batch of plan.batches) {
|
||||
if (progress.getState().paused) {
|
||||
ctx.ui.notify(
|
||||
@@ -196,6 +218,11 @@ async function executePlanBatches(
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (projectDir) {
|
||||
deleteLoopActive(projectDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extension Entry ────────────────────────────────────────────────────────
|
||||
@@ -259,6 +286,325 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Reload detection: re-instantiate widgets when session reloads ──────
|
||||
//
|
||||
// When the user types /reload while ralpi tasks are executing, the old
|
||||
// ExtensionContext is torn down and widgets (created via ctx.ui.setWidget)
|
||||
// disappear. This handler detects the reload, reads the persisted loop-active
|
||||
// marker and progress.json, and re-creates live-status widgets that show
|
||||
// task progress with spinner animation and tool calls from session files.
|
||||
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;
|
||||
|
||||
// Check if a task execution loop was active before the reload
|
||||
const loopState = readLoopActive(projectDir);
|
||||
if (!loopState) return;
|
||||
|
||||
// Load progress state
|
||||
let abortPolling = false;
|
||||
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
||||
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
|
||||
|
||||
// Parse the task file to get task titles
|
||||
const titleMap = new Map<string, string>();
|
||||
try {
|
||||
const project = parseTaskFile(loopState.taskFile);
|
||||
for (const task of project.tasks) {
|
||||
titleMap.set(task.id, task.title);
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, just use IDs without titles
|
||||
}
|
||||
|
||||
/** Read recent tool calls from a task's session file. */
|
||||
const readRecentToolCalls = (
|
||||
taskId: string,
|
||||
maxLines = 30,
|
||||
): Array<{ name: string; label: string }> => {
|
||||
try {
|
||||
const files = fs
|
||||
.readdirSync(sessionsDir)
|
||||
.filter((f) => f.startsWith(taskId + "-"))
|
||||
.sort();
|
||||
if (files.length === 0) return [];
|
||||
const sessionPath = path.join(sessionsDir, files[files.length - 1]);
|
||||
const content = fs.readFileSync(sessionPath, "utf-8");
|
||||
const lines = content
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.slice(-maxLines);
|
||||
const calls: Array<{ name: string; label: string }> = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "tool_execution_start") {
|
||||
calls.push({
|
||||
name: event.toolName,
|
||||
label: formatToolLabel(event.toolName, event.args),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Format a tool call argument into a short label. */
|
||||
function formatToolLabel(name: string, args: unknown): string {
|
||||
const a = args as Record<string, unknown> | undefined;
|
||||
if (!a) return name;
|
||||
if (name === "bash") return String(a.command ?? "").slice(0, 70);
|
||||
if (name === "write" || name === "read" || name === "edit")
|
||||
return String(a.path ?? "").slice(0, 60);
|
||||
if (name === "grep")
|
||||
return `${a.pattern ?? "?"} — ${String(a.path ?? "").slice(0, 40)}`;
|
||||
if (name === "find") return `${a.path ?? "."} — ${a.glob ?? "*"}`;
|
||||
if (name === "ls") return String(a.path ?? ".").slice(0, 60);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Re-read progress from disk (old tasks still writing to it). */
|
||||
const readTasks = (): Record<string, { status: string }> | null => {
|
||||
try {
|
||||
const raw = fs.readFileSync(progressPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, any>;
|
||||
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Early exit: if all tasks already finished during the reload, just clean up
|
||||
const initialTasks = readTasks();
|
||||
if (initialTasks) {
|
||||
const remaining = Object.values(initialTasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
ctx.ui.notify("All ralpi tasks completed during reload.", "info");
|
||||
deleteLoopActive(projectDir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show a status notification for the reconnect
|
||||
const taskCount = loopState.taskIds.length;
|
||||
ctx.ui.notify(
|
||||
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Shared state for the widget
|
||||
let tickCount = 0;
|
||||
const MAX_COLLAPSED = 3;
|
||||
|
||||
if (loopState.mode === "parallel") {
|
||||
// ── Parallel mode: single batch widget ──
|
||||
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`;
|
||||
let widgetTui: { requestRender(): void } | null = null;
|
||||
|
||||
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return [t.fg("dim", "(waiting for progress...)")];
|
||||
|
||||
const lines: string[] = [];
|
||||
// Only show tasks that have started (in_progress, completed, failed).
|
||||
// Pending/unstarted tasks are noise after a reload.
|
||||
const sortedIds = [...loopState.taskIds].sort().filter((id) => {
|
||||
const info = tasks[id];
|
||||
return info && info.status !== "pending";
|
||||
});
|
||||
|
||||
// If no tasks have started yet, show nothing — polling will pick up
|
||||
// changes within 500ms.
|
||||
if (sortedIds.length === 0) return [t.fg("dim", "(starting tasks...)")];
|
||||
|
||||
for (const id of sortedIds) {
|
||||
const info = tasks[id]!;
|
||||
const title = titleMap.get(id);
|
||||
const header = title ? `${id} · ${title}` : id;
|
||||
|
||||
// Status icon
|
||||
if (info.status === "completed") {
|
||||
lines.push(`${t.fg("success", "✓")} ${header}`);
|
||||
} else if (info.status === "failed") {
|
||||
lines.push(`${t.fg("error", "✗")} ${header}`);
|
||||
} else if (info.status === "in_progress") {
|
||||
const frame = t.fg(
|
||||
"accent",
|
||||
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
|
||||
);
|
||||
lines.push(`${frame} ${header}`);
|
||||
|
||||
// Show recent tool calls for active tasks
|
||||
const toolCalls = readRecentToolCalls(id);
|
||||
if (toolCalls.length > 0) {
|
||||
if (toolCalls.length <= MAX_COLLAPSED) {
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const tc = toolCalls[i];
|
||||
const isLast = i === toolCalls.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
lines.push(
|
||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||
const remaining = toolCalls.length - shown.length;
|
||||
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
|
||||
for (let i = 0; i < shown.length; i++) {
|
||||
const tc = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
lines.push(
|
||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: () => buildBatchLines(t),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
|
||||
// 100ms tick: advances spinner frame every tick, refreshes
|
||||
// progress + tool calls every 5 ticks (500ms).
|
||||
const tickTimer = setInterval(() => {
|
||||
if (abortPolling) return;
|
||||
tickCount++;
|
||||
widgetTui?.requestRender();
|
||||
|
||||
if (tickCount % 5 === 0) {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return;
|
||||
const activeCount = Object.values(tasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
if (activeCount === 0) {
|
||||
clearInterval(tickTimer);
|
||||
ctx.ui.setWidget(widgetKey, undefined);
|
||||
deleteLoopActive(projectDir);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Clean up timer when extension is shut down
|
||||
pi.on("session_shutdown", () => {
|
||||
abortPolling = true;
|
||||
clearInterval(tickTimer);
|
||||
});
|
||||
} else {
|
||||
// ── Sequential mode: per-task widget ──
|
||||
const currentTaskId = loopState.taskIds.find((id) => {
|
||||
const tasks = readTasks();
|
||||
return tasks?.[id]?.status === "in_progress";
|
||||
});
|
||||
|
||||
if (currentTaskId) {
|
||||
const widgetKey = `ralpi-task-${currentTaskId}`;
|
||||
let widgetTui: { requestRender(): void } | null = null;
|
||||
|
||||
const buildLines = (t: typeof ctx.ui.theme): string[] => {
|
||||
const tasks = readTasks();
|
||||
const info = tasks?.[currentTaskId];
|
||||
const title = titleMap.get(currentTaskId);
|
||||
const header = title ? `${currentTaskId} · ${title}` : currentTaskId;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (!info || info.status === "pending") {
|
||||
return [t.fg("dim", "(starting task...)")];
|
||||
}
|
||||
|
||||
if (info.status === "completed") {
|
||||
lines.push(`${t.fg("success", "✓")} ${header}`);
|
||||
} else if (info.status === "failed") {
|
||||
lines.push(`${t.fg("error", "✗")} ${header}`);
|
||||
} else if (info.status === "in_progress") {
|
||||
const frame = t.fg(
|
||||
"accent",
|
||||
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
|
||||
);
|
||||
lines.push(`${frame} ${header}`);
|
||||
|
||||
// Show recent tool calls
|
||||
const toolCalls = readRecentToolCalls(currentTaskId);
|
||||
if (toolCalls.length > 0) {
|
||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||
const remaining = toolCalls.length - shown.length;
|
||||
if (remaining > 0) {
|
||||
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
|
||||
}
|
||||
for (let i = 0; i < shown.length; i++) {
|
||||
const tc = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
lines.push(
|
||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: () => buildLines(t),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
|
||||
const tickTimer = setInterval(() => {
|
||||
if (abortPolling) return;
|
||||
tickCount++;
|
||||
widgetTui?.requestRender();
|
||||
|
||||
if (tickCount % 5 === 0) {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return;
|
||||
const status = tasks[currentTaskId]?.status;
|
||||
if (status !== "in_progress") {
|
||||
clearInterval(tickTimer);
|
||||
// Keep widget visible a moment, then clean up
|
||||
setTimeout(() => {
|
||||
ctx.ui.setWidget(widgetKey, undefined);
|
||||
deleteLoopActive(projectDir);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
pi.on("session_shutdown", () => {
|
||||
abortPolling = true;
|
||||
clearInterval(tickTimer);
|
||||
});
|
||||
} else {
|
||||
// No task actively in progress — show a "resume" hint
|
||||
ctx.ui.notify(
|
||||
"No running task found. Use /ralpi resume to continue execution.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("ralpi", {
|
||||
description:
|
||||
"Execute tasks from a task file using DAG-based dependency resolution",
|
||||
@@ -423,9 +769,20 @@ async function handleRun(
|
||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
||||
const plan = buildPlanByMode(mode, project, completed);
|
||||
|
||||
// Show execution plan before starting so user can see batch breakdown
|
||||
// Show dependency chain + execution plan before starting
|
||||
const depChain = formatDependencyChain(project);
|
||||
const formattedPlan = formatExecutionPlan(plan);
|
||||
ctx.ui.notify(`${formattedPlan}\n\nStarting ${mode} execution...`, "info");
|
||||
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,
|
||||
|
||||
89
src/dag.ts
89
src/dag.ts
@@ -309,6 +309,95 @@ export function getCriticalPath(project: Project): Task[] {
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,9 +284,11 @@ export async function runTask(
|
||||
if (entry) {
|
||||
entry.toolCalls.push({ name: event.toolName, label });
|
||||
}
|
||||
}
|
||||
batchRender?.();
|
||||
} else {
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined, // no abort signal
|
||||
sessionFilePath, // stream events to file
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
142
src/parser.ts
142
src/parser.ts
@@ -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)})`,
|
||||
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",
|
||||
);
|
||||
if (simplePattern.test(content)) {
|
||||
content = content.replace(simplePattern, `$1${char}$3`);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
72
src/utils.ts
72
src/utils.ts
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user