fix width exceeding, release prep

This commit is contained in:
2026-05-31 08:18:46 -04:00
parent ab1e2eb430
commit 3c01652b90
9 changed files with 518 additions and 301 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
node_modules node_modules
dist
.pi-lens .pi-lens
package-lock.json package-lock.json

View File

@@ -4,18 +4,17 @@
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host. A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
## Build ## Type checking
``` ```
npm run build # tsc → dist/ npm run typecheck # tsc --noEmit
npm run watch # tsc --watch
``` ```
No bundler, no linter, no test framework. Plain `tsc` with strict mode. No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/jiti), which compiles TypeScript at runtime. `index.ts` is the entry point directly.
## Entry point ## Entry point
`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`. The `tsconfig.json` sets `rootDir: "./"` so `index.ts` compiles to `dist/index.js`. `index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`.
## External dependencies ## External dependencies

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Michael Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -11,6 +11,7 @@ import {
buildSequentialPlan, buildSequentialPlan,
formatExecutionPlan, formatExecutionPlan,
getReadyTasks, getReadyTasks,
getBlockedTasks,
} from "./src/dag"; } from "./src/dag";
import { ProgressTracker } from "./src/progress"; import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts"; import { buildPlanPrompt } from "./src/prompts";
@@ -69,7 +70,7 @@ async function selectExecutionMode(
config: import("./src/types").RalpiConfig, config: import("./src/types").RalpiConfig,
): Promise<ExecutionMode> { ): Promise<ExecutionMode> {
const mode = await ctx.ui.select("Execution mode for this run?", [ const mode = await ctx.ui.select("Execution mode for this run?", [
`Parallel (where dependencies allow)-[${config.execution.maxParallel} max]`, `Parallel (where dependencies allow)[${config.execution.maxParallel} max]`,
"Sequential (one at a time)", "Sequential (one at a time)",
]); ]);
const isParallel = mode?.startsWith("Parallel") ?? false; const isParallel = mode?.startsWith("Parallel") ?? false;
@@ -125,6 +126,9 @@ async function executePlanBatches(
sendChatMessage?: SendChatMessage, sendChatMessage?: SendChatMessage,
projectDir?: string, projectDir?: string,
): Promise<void> { ): Promise<void> {
// Track failed task IDs across batches to block downstream tasks
const failedTaskIds = new Set(progress.getFailedTaskIds());
for (const batch of plan.batches) { for (const batch of plan.batches) {
if (progress.getState().paused) { if (progress.getState().paused) {
ctx.ui.notify( ctx.ui.notify(
@@ -157,6 +161,43 @@ async function executePlanBatches(
const status = progress.getTaskStatus(task.id); const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status); updateTaskInFile(taskFile, task.id, status);
} }
// Update failed task IDs after batch completes
const newFailed = progress.getFailedTaskIds();
for (const id of newFailed) {
failedTaskIds.add(id);
}
// In sequential mode, stop after any failure
if (mode === "sequential" && failedTaskIds.size > 0) {
break;
}
// In parallel mode, rebuild the plan to filter out newly blocked tasks
if (mode === "parallel") {
const completed = new Set(progress.getCompletedTaskIds());
const newPlan = buildExecutionPlan(
project,
completed,
undefined,
failedTaskIds,
);
// Replace remaining batches with filtered ones
const currentIdx = plan.batches.indexOf(batch);
const remainingBatches = newPlan.batches.filter(
(b) => b.batchIndex > currentIdx,
);
// Update the plan's batches in-place
plan.batches.length = 0;
plan.batches.push(...remainingBatches);
// Skip empty batches
if (remainingBatches.length === 0) {
break;
}
}
} }
} }

View File

@@ -2,7 +2,6 @@
"name": "ralpi", "name": "ralpi",
"version": "0.1.0", "version": "0.1.0",
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
"main": "dist/index.js",
"keywords": [ "keywords": [
"pi-package", "pi-package",
"pi-extension", "pi-extension",
@@ -12,22 +11,40 @@
"ralpi-loop", "ralpi-loop",
"prd" "prd"
], ],
"author": "", "author": "Michael Freno",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/mikefreno/ralpi",
"repository": {
"type": "git",
"url": "git+https://github.com/mikefreno/ralpi.git"
},
"bugs": {
"url": "https://github.com/mikefreno/ralpi/issues"
},
"files": [ "files": [
"dist/", "index.ts",
"src/",
"skills/", "skills/",
"prompts/", "prompts/",
"index.ts" "README.md",
"LICENSE"
], ],
"scripts": { "scripts": {
"build": "tsc", "typecheck": "tsc --noEmit",
"watch": "tsc --watch", "prepublishOnly": "tsc --noEmit"
"prepublishOnly": "npm run build" },
"engines": {
"bun": ">=1.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [
"./dist/index.js" "./index.ts"
],
"skills": [
"./skills"
],
"prompts": [
"./prompts"
] ]
}, },
"dependencies": { "dependencies": {
@@ -37,6 +54,17 @@
"@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*" "@earendil-works/pi-tui": "*"
}, },
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
}
},
"publishConfig": {
"access": "public"
},
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"typescript": "^5.3.0" "typescript": "^5.3.0"

View File

@@ -1,5 +1,33 @@
import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types"; import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
// ─── Blocked Tasks ───────────────────────────────────────────────────────────
/**
* Find tasks that are blocked (direct or transitive) due to failed dependencies.
* Returns a Set of blocked task IDs.
*/
export function getBlockedTasks(
pendingTasks: Task[],
failedTaskIds: Set<string>,
): 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;
}
}
}
return blocked;
}
// ─── Main Entry ────────────────────────────────────────────────────────────── // ─── Main Entry ──────────────────────────────────────────────────────────────
/** /**
@@ -10,16 +38,15 @@ export function buildExecutionPlan(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
parallelGroup?: number, parallelGroup?: number,
failedTaskIds: Set<string> = new Set(),
): ExecutionPlan { ): ExecutionPlan {
const allTasks = new Map(project.tasks.map((t) => [t.id, t]));
// 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, allTasks, completed), 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)),
}; };
@@ -27,7 +54,7 @@ export function buildExecutionPlan(
// Use dependency-based Kahn's algorithm // Use dependency-based Kahn's algorithm
return { return {
batches: buildBatches(pendingTasks, allTasks, completed), 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)),
}; };
@@ -41,9 +68,18 @@ export function buildExecutionPlan(
export function buildSequentialPlan( export function buildSequentialPlan(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
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));
const batches: ExecutionBatch[] = pendingTasks.map((task, i) => ({
// 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], tasks: [task],
batchIndex: i, batchIndex: i,
})); }));
@@ -51,7 +87,7 @@ export function buildSequentialPlan(
return { return {
batches, batches,
totalTasks: pendingTasks.length, totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)), skippedTasks,
}; };
} }
@@ -59,12 +95,15 @@ export function buildSequentialPlan(
function buildBatches( function buildBatches(
pendingTasks: Task[], pendingTasks: Task[],
allTasks: Map<string, Task>, failedTaskIds: Set<string>,
completed: Set<string>,
): ExecutionBatch[] { ): ExecutionBatch[] {
const batches: ExecutionBatch[] = []; const batches: ExecutionBatch[] = [];
const done = new Set(completed); const done = new Set<string>();
const remaining = new Set(pendingTasks.map((t) => t.id)); 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) { while (remaining.size > 0) {
// Find tasks whose dependencies are all satisfied // Find tasks whose dependencies are all satisfied
@@ -74,7 +113,7 @@ function buildBatches(
const deps = task.dependencies || []; const deps = task.dependencies || [];
const depsSatisfied = deps.every( const depsSatisfied = deps.every(
(dep) => done.has(dep) || !allTasks.has(dep), (dep) => done.has(dep) || !pendingSet.has(dep),
); );
if (depsSatisfied) { if (depsSatisfied) {
@@ -108,12 +147,14 @@ function buildBatches(
*/ */
function buildParallelGroupBatches( function buildParallelGroupBatches(
pendingTasks: Task[], pendingTasks: Task[],
allTasks: Map<string, Task>, failedTaskIds: Set<string>,
completed: Set<string>,
): ExecutionBatch[] { ): ExecutionBatch[] {
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 pendingTasks) { 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);
@@ -121,7 +162,7 @@ function buildParallelGroupBatches(
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,
})); }));

View File

@@ -1,3 +1,4 @@
import { truncateToWidth } from "@earendil-works/pi-tui";
import * as path from "node:path"; import * as path from "node:path";
import type { Task, Project, Reflection, ToolUsage } from "./types"; import type { Task, Project, Reflection, ToolUsage } from "./types";
import type { RalpiConfig } from "./types"; import type { RalpiConfig } from "./types";
@@ -83,6 +84,27 @@ class ModelRoundRobin {
this.assignments.delete(taskId); this.assignments.delete(taskId);
} }
} }
/**
* Advance a task to the next model slot without going through freed slots.
* Used for model failover — when the current model is down, skip to the
* next one instead of re-assigning the same freed index.
*/
advance(taskId: string): unknown {
const currentIndex = this.assignments.get(taskId);
if (currentIndex === undefined) {
// No current assignment — fresh assign (fallback, shouldn't happen)
return this.assign(taskId);
}
// If this index was freed (e.g. from an earlier release call that raced),
// remove it from freeSlots so it's not handed out to another task.
const freeIdx = this.freeSlots.indexOf(currentIndex);
if (freeIdx !== -1) this.freeSlots.splice(freeIdx, 1);
// Advance to the next index (circular)
const nextIndex = (currentIndex + 1) % this.models.length;
this.assignments.set(taskId, nextIndex);
return this.models[nextIndex];
}
} }
/** Shared state for parallel-batch widget. Each running task writes its /** Shared state for parallel-batch widget. Each running task writes its
@@ -162,9 +184,13 @@ export async function runTask(
} else { } else {
// Build widget lines from current state. Live widgets can't expand/collapse // Build widget lines from current state. Live widgets can't expand/collapse
// like chat messages, so we always truncate to MAX_COLLAPSED recent calls. // like chat messages, so we always truncate to MAX_COLLAPSED recent calls.
const buildLines = (t: typeof ctx.ui.theme): string[] => { const truncateWidth = 74; // Account for widget container padding
const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => {
const effectiveWidth = width
? Math.min(width, truncateWidth)
: truncateWidth;
const frame = t.fg("accent", SPINNER_FRAMES[frameIndex]); const frame = t.fg("accent", SPINNER_FRAMES[frameIndex]);
const lines = [`${frame} ${taskHeader}`]; const lines = [truncateToWidth(`${frame} ${taskHeader}`, effectiveWidth)];
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
if (toolCalls.length <= MAX_COLLAPSED) { if (toolCalls.length <= MAX_COLLAPSED) {
@@ -173,18 +199,27 @@ export async function runTask(
const isLast = i === toolCalls.length - 1; const isLast = i === toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── "; const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`); const tag = t.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`); lines.push(
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
);
} }
} else { } else {
const shown = toolCalls.slice(-MAX_COLLAPSED); const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length; const remaining = toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`)); lines.push(
truncateToWidth(
t.fg("dim", ` ├── …${remaining} earlier`),
effectiveWidth,
),
);
for (let i = 0; i < shown.length; i++) { for (let i = 0; i < shown.length; i++) {
const entry = shown[i]; const entry = shown[i];
const isLast = i === shown.length - 1; const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── "; const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`); const tag = t.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`); lines.push(
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
);
} }
} }
} }
@@ -194,7 +229,7 @@ export async function runTask(
ctx.ui.setWidget(widgetKey, (tui, t) => { ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui; widgetTui = tui;
return { return {
render: () => buildLines(t), render: (width?: number) => buildLines(t, width),
invalidate: () => widgetTui?.requestRender(), invalidate: () => widgetTui?.requestRender(),
}; };
}); });
@@ -378,6 +413,7 @@ export async function executeBatch(
// Execute sequentially // Execute sequentially
for (const task of tasks) { for (const task of tasks) {
try {
const model = roundRobin?.assign(task.id); const model = roundRobin?.assign(task.id);
await executeTask( await executeTask(
task, task,
@@ -391,6 +427,15 @@ export async function executeBatch(
model, model,
roundRobin, roundRobin,
); );
} catch (error) {
// Task failed — stop the batch. Dependent tasks are blocked by
// the DAG layer (getBlockedTasks) so they won't appear in this batch.
roundRobin?.release(task.id);
const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
break;
}
} }
} }
@@ -414,7 +459,11 @@ async function executeBatchParallel(
const widgetKey = `ralpi-parallel-${Date.now()}`; const widgetKey = `ralpi-parallel-${Date.now()}`;
let widgetTui: { requestRender(): void } | null = null; let widgetTui: { requestRender(): void } | null = null;
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => { const buildBatchLines = (
t: typeof ctx.ui.theme,
width?: number,
): string[] => {
const effectiveWidth = width || 74;
const lines: string[] = []; const lines: string[] = [];
const sortedIds = Array.from(sharedState.keys()).sort(); const sortedIds = Array.from(sharedState.keys()).sort();
@@ -425,7 +474,9 @@ async function executeBatchParallel(
? "✓" ? "✓"
: "✗" : "✗"
: t.fg("accent", SPINNER_FRAMES[entry.frameIndex]); : t.fg("accent", SPINNER_FRAMES[entry.frameIndex]);
lines.push(`${frame} ${entry.taskHeader}`); lines.push(
truncateToWidth(`${frame} ${entry.taskHeader}`, effectiveWidth),
);
if (entry.toolCalls.length > 0) { if (entry.toolCalls.length > 0) {
if (entry.toolCalls.length <= MAX_COLLAPSED) { if (entry.toolCalls.length <= MAX_COLLAPSED) {
@@ -434,18 +485,27 @@ async function executeBatchParallel(
const isLast = i === entry.toolCalls.length - 1; const isLast = i === entry.toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── "; const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${tc.name}]`); const tag = t.fg("accent", `[${tc.name}]`);
lines.push(`${branch}${tag} ${tc.label}`); lines.push(
truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth),
);
} }
} else { } else {
const shown = entry.toolCalls.slice(-MAX_COLLAPSED); const shown = entry.toolCalls.slice(-MAX_COLLAPSED);
const remaining = entry.toolCalls.length - shown.length; const remaining = entry.toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`)); lines.push(
truncateToWidth(
t.fg("dim", ` ├── …${remaining} earlier`),
effectiveWidth,
),
);
for (let i = 0; i < shown.length; i++) { for (let i = 0; i < shown.length; i++) {
const tc = shown[i]; const tc = shown[i];
const isLast = i === shown.length - 1; const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── "; const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${tc.name}]`); const tag = t.fg("accent", `[${tc.name}]`);
lines.push(`${branch}${tag} ${tc.label}`); lines.push(
truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth),
);
} }
} }
} }
@@ -456,7 +516,7 @@ async function executeBatchParallel(
ctx.ui.setWidget(widgetKey, (tui, t) => { ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui; widgetTui = tui;
return { return {
render: () => buildBatchLines(t), render: (width?: number) => buildBatchLines(t, width),
invalidate: () => widgetTui?.requestRender(), invalidate: () => widgetTui?.requestRender(),
}; };
}); });
@@ -488,7 +548,15 @@ async function executeBatchParallel(
sharedState, sharedState,
assignedModel, assignedModel,
roundRobin, roundRobin,
), ).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);
const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
}),
}); });
// Limit concurrency // Limit concurrency
@@ -531,9 +599,11 @@ async function executeTask(
let currentModel: unknown = assignedModel ?? config.model; let currentModel: unknown = assignedModel ?? config.model;
while (modelAttempt < maxModelAttempts) { while (modelAttempt < maxModelAttempts) {
// Get the next model from round-robin (on first try, use the pre-assigned model) // On subsequent model attempts, advance to the next model.
// Uses advance() instead of assign() so we don't get stuck on
// the same freed slot when the current model is down.
if (modelAttempt > 0 && roundRobin) { if (modelAttempt > 0 && roundRobin) {
currentModel = roundRobin.assign(task.id); currentModel = roundRobin.advance(task.id);
} }
let retries = 0; let retries = 0;
@@ -584,7 +654,9 @@ async function executeTask(
// Agent session failed (provider error). // Agent session failed (provider error).
// If we have more models, cycle immediately — don't waste retries. // If we have more models, cycle immediately — don't waste retries.
if (roundRobin && modelAttempt < maxModelAttempts - 1) { if (roundRobin && modelAttempt < maxModelAttempts - 1) {
roundRobin.release(task.id); // Don't release — advance() already handles the transition.
// release() would put the slot in freeSlots, then assign()
// would pick it right back up, getting stuck on the same model.
modelAttempt++; modelAttempt++;
ctx.ui.notify( ctx.ui.notify(
`Task ${task.id}: model failed, trying next (${modelAttempt + 1}/${maxModelAttempts}): ${result.error}`, `Task ${task.id}: model failed, trying next (${modelAttempt + 1}/${maxModelAttempts}): ${result.error}`,
@@ -607,13 +679,20 @@ 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");
throw new Error(`Task ${task.id} failed: ${result.error}`); ctx.ui.notify(
`Task ${task.id} failed after ${maxRetries} retries: ${
result.error || "Unknown error"
}`,
"error",
);
return;
} }
} catch (error) { } catch (error) {
roundRobin?.release(task.id); roundRobin?.release(task.id);
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);
throw error; ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return;
} }
} }
@@ -621,9 +700,13 @@ async function executeTask(
modelAttempt++; modelAttempt++;
} }
// All models exhausted // All models exhausted — release the slot
roundRobin?.release(task.id);
progress.markFailed(task.id, "All configured models exhausted"); progress.markFailed(task.id, "All configured models exhausted");
throw new Error(`Task ${task.id} failed: all configured models exhausted`); ctx.ui.notify(
`Task ${task.id} failed: all configured models exhausted`,
"error",
);
} }
// ─── Save Reflection to File ──────────────────────────────────────────────── // ─── Save Reflection to File ────────────────────────────────────────────────

View File

@@ -213,6 +213,14 @@ export class ProgressTracker {
.map(([id]) => id); .map(([id]) => id);
} }
/** Get IDs of all failed tasks */
getFailedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "failed")
.map(([id]) => id);
}
/** Get all reflections from completed tasks */ /** Get all reflections from completed tasks */
getAllReflections(): Reflection[] { getAllReflections(): Reflection[] {
const prd = this.getPRD(); const prd = this.getPRD();

View File

@@ -1,18 +1,15 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "commonjs", "module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"], "lib": ["ES2022"],
"outDir": "./dist", "noEmit": true,
"rootDir": "./",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true
"declaration": true,
"declarationMap": true,
"sourceMap": true
}, },
"include": ["index.ts", "src/**/*"], "include": ["index.ts", "src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]