fix width exceeding, release prep
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.pi-lens
|
||||
package-lock.json
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Build
|
||||
## Type checking
|
||||
|
||||
```
|
||||
npm run build # tsc → dist/
|
||||
npm run watch # tsc --watch
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
43
index.ts
43
index.ts
@@ -11,6 +11,7 @@ import {
|
||||
buildSequentialPlan,
|
||||
formatExecutionPlan,
|
||||
getReadyTasks,
|
||||
getBlockedTasks,
|
||||
} from "./src/dag";
|
||||
import { ProgressTracker } from "./src/progress";
|
||||
import { buildPlanPrompt } from "./src/prompts";
|
||||
@@ -69,7 +70,7 @@ async function selectExecutionMode(
|
||||
config: import("./src/types").RalpiConfig,
|
||||
): Promise<ExecutionMode> {
|
||||
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)",
|
||||
]);
|
||||
const isParallel = mode?.startsWith("Parallel") ?? false;
|
||||
@@ -125,6 +126,9 @@ async function executePlanBatches(
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
): Promise<void> {
|
||||
// Track failed task IDs across batches to block downstream tasks
|
||||
const failedTaskIds = new Set(progress.getFailedTaskIds());
|
||||
|
||||
for (const batch of plan.batches) {
|
||||
if (progress.getState().paused) {
|
||||
ctx.ui.notify(
|
||||
@@ -157,6 +161,43 @@ async function executePlanBatches(
|
||||
const status = progress.getTaskStatus(task.id);
|
||||
updateTaskInFile(taskFile, task.id, status);
|
||||
}
|
||||
|
||||
// Update failed task IDs after batch completes
|
||||
const newFailed = progress.getFailedTaskIds();
|
||||
for (const id of newFailed) {
|
||||
failedTaskIds.add(id);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
package.json
44
package.json
@@ -2,7 +2,6 @@
|
||||
"name": "ralpi",
|
||||
"version": "0.1.0",
|
||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||
"main": "dist/index.js",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
@@ -12,22 +11,40 @@
|
||||
"ralpi-loop",
|
||||
"prd"
|
||||
],
|
||||
"author": "",
|
||||
"author": "Michael Freno",
|
||||
"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": [
|
||||
"dist/",
|
||||
"index.ts",
|
||||
"src/",
|
||||
"skills/",
|
||||
"prompts/",
|
||||
"index.ts"
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"prepublishOnly": "npm run build"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "tsc --noEmit"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
"./index.ts"
|
||||
],
|
||||
"skills": [
|
||||
"./skills"
|
||||
],
|
||||
"prompts": [
|
||||
"./prompts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -37,6 +54,17 @@
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"@earendil-works/pi-tui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
|
||||
71
src/dag.ts
71
src/dag.ts
@@ -1,5 +1,33 @@
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -10,16 +38,15 @@ export function buildExecutionPlan(
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
parallelGroup?: number,
|
||||
failedTaskIds: Set<string> = new Set(),
|
||||
): ExecutionPlan {
|
||||
const allTasks = new Map(project.tasks.map((t) => [t.id, t]));
|
||||
|
||||
// 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, allTasks, completed),
|
||||
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||
};
|
||||
@@ -27,7 +54,7 @@ export function buildExecutionPlan(
|
||||
|
||||
// Use dependency-based Kahn's algorithm
|
||||
return {
|
||||
batches: buildBatches(pendingTasks, allTasks, completed),
|
||||
batches: buildBatches(pendingTasks, failedTaskIds),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||
};
|
||||
@@ -41,9 +68,18 @@ export function buildExecutionPlan(
|
||||
export function buildSequentialPlan(
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
failedTaskIds: Set<string> = new Set(),
|
||||
): ExecutionPlan {
|
||||
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],
|
||||
batchIndex: i,
|
||||
}));
|
||||
@@ -51,7 +87,7 @@ export function buildSequentialPlan(
|
||||
return {
|
||||
batches,
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||
skippedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,12 +95,15 @@ export function buildSequentialPlan(
|
||||
|
||||
function buildBatches(
|
||||
pendingTasks: Task[],
|
||||
allTasks: Map<string, Task>,
|
||||
completed: Set<string>,
|
||||
failedTaskIds: Set<string>,
|
||||
): ExecutionBatch[] {
|
||||
const batches: ExecutionBatch[] = [];
|
||||
const done = new Set(completed);
|
||||
const remaining = new Set(pendingTasks.map((t) => t.id));
|
||||
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
|
||||
@@ -74,7 +113,7 @@ function buildBatches(
|
||||
|
||||
const deps = task.dependencies || [];
|
||||
const depsSatisfied = deps.every(
|
||||
(dep) => done.has(dep) || !allTasks.has(dep),
|
||||
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||
);
|
||||
|
||||
if (depsSatisfied) {
|
||||
@@ -108,12 +147,14 @@ function buildBatches(
|
||||
*/
|
||||
function buildParallelGroupBatches(
|
||||
pendingTasks: Task[],
|
||||
allTasks: Map<string, Task>,
|
||||
completed: Set<string>,
|
||||
failedTaskIds: Set<string>,
|
||||
): ExecutionBatch[] {
|
||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||
|
||||
const groups = new Map<number, Task[]>();
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
for (const task of activeTasks) {
|
||||
const group = task.parallelGroup ?? 0;
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(task);
|
||||
@@ -121,7 +162,7 @@ function buildParallelGroupBatches(
|
||||
|
||||
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,
|
||||
batchIndex: i,
|
||||
}));
|
||||
|
||||
123
src/executor.ts
123
src/executor.ts
@@ -1,3 +1,4 @@
|
||||
import { truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import * as path from "node:path";
|
||||
import type { Task, Project, Reflection, ToolUsage } from "./types";
|
||||
import type { RalpiConfig } from "./types";
|
||||
@@ -83,6 +84,27 @@ class ModelRoundRobin {
|
||||
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
|
||||
@@ -162,9 +184,13 @@ export async function runTask(
|
||||
} else {
|
||||
// Build widget lines from current state. Live widgets can't expand/collapse
|
||||
// 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 lines = [`${frame} ${taskHeader}`];
|
||||
const lines = [truncateToWidth(`${frame} ${taskHeader}`, effectiveWidth)];
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
if (toolCalls.length <= MAX_COLLAPSED) {
|
||||
@@ -173,18 +199,27 @@ export async function runTask(
|
||||
const isLast = i === toolCalls.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const tag = t.fg("accent", `[${entry.name}]`);
|
||||
lines.push(`${branch}${tag} ${entry.label}`);
|
||||
lines.push(
|
||||
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||
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++) {
|
||||
const entry = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
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) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: () => buildLines(t),
|
||||
render: (width?: number) => buildLines(t, width),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
@@ -378,6 +413,7 @@ export async function executeBatch(
|
||||
|
||||
// Execute sequentially
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
const model = roundRobin?.assign(task.id);
|
||||
await executeTask(
|
||||
task,
|
||||
@@ -391,6 +427,15 @@ export async function executeBatch(
|
||||
model,
|
||||
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()}`;
|
||||
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 sortedIds = Array.from(sharedState.keys()).sort();
|
||||
|
||||
@@ -425,7 +474,9 @@ async function executeBatchParallel(
|
||||
? "✓"
|
||||
: "✗"
|
||||
: 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 <= MAX_COLLAPSED) {
|
||||
@@ -434,18 +485,27 @@ async function executeBatchParallel(
|
||||
const isLast = i === entry.toolCalls.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const tag = t.fg("accent", `[${tc.name}]`);
|
||||
lines.push(`${branch}${tag} ${tc.label}`);
|
||||
lines.push(
|
||||
truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const shown = entry.toolCalls.slice(-MAX_COLLAPSED);
|
||||
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++) {
|
||||
const tc = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
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) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: () => buildBatchLines(t),
|
||||
render: (width?: number) => buildBatchLines(t, width),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
@@ -488,7 +548,15 @@ async function executeBatchParallel(
|
||||
sharedState,
|
||||
assignedModel,
|
||||
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
|
||||
@@ -531,9 +599,11 @@ async function executeTask(
|
||||
let currentModel: unknown = assignedModel ?? config.model;
|
||||
|
||||
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) {
|
||||
currentModel = roundRobin.assign(task.id);
|
||||
currentModel = roundRobin.advance(task.id);
|
||||
}
|
||||
|
||||
let retries = 0;
|
||||
@@ -584,7 +654,9 @@ async function executeTask(
|
||||
// Agent session failed (provider error).
|
||||
// If we have more models, cycle immediately — don't waste retries.
|
||||
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++;
|
||||
ctx.ui.notify(
|
||||
`Task ${task.id}: model failed, trying next (${modelAttempt + 1}/${maxModelAttempts}): ${result.error}`,
|
||||
@@ -607,13 +679,20 @@ async function executeTask(
|
||||
} else {
|
||||
// Max retries exceeded
|
||||
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) {
|
||||
roundRobin?.release(task.id);
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
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++;
|
||||
}
|
||||
|
||||
// All models exhausted
|
||||
// All models exhausted — release the slot
|
||||
roundRobin?.release(task.id);
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -213,6 +213,14 @@ export class ProgressTracker {
|
||||
.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 */
|
||||
getAllReflections(): Reflection[] {
|
||||
const prd = this.getPRD();
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["index.ts", "src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user