Compare commits

...

3 Commits

Author SHA1 Message Date
dae33248e3 until done 2026-05-31 08:47:35 -04:00
9f90ed4252 readme cleanup 2026-05-31 08:46:59 -04:00
3c01652b90 fix width exceeding, release prep 2026-05-31 08:19:21 -04:00
10 changed files with 985 additions and 934 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
@@ -55,7 +54,7 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0`
## Command routing ## Command routing
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `status`, `resume`, `next`, `reset`). `/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `resume`, `reset`).
## Config ## Config

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.

119
README.md
View File

@@ -1,78 +1,36 @@
# Ralpi # Ralpi
Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking. Execute tasks from task files until done using DAG-based dependency resolution with persistent progress tracking.
## Features ## Features
- **DAG-based execution**: Tasks are ordered by dependencies using Kahn's algorithm
- **Parallel batching**: Independent tasks in each batch can run concurrently - **Parallel batching**: Independent tasks in each batch can run concurrently
- **Persistent progress**: Execution state saved to `.ralpi/progress.json` - **Persistent progress**: Execution state saved to `.ralpi/progress.json`
- **Reflection system**: Each task produces a reflection for downstream tasks - **Reflection system**: Each task produces a reflection for downstream tasks
- **Retry with backoff**: Failed tasks retry with exponential backoff - **Retry with backoff**: Failed tasks retry with exponential backoff
- **Multiple formats**: Supports Fio README, simple checkboxes, and YAML - **Multiple formats**: Supports simple checkboxes, and YAML
- **Chat progress**: Real-time progress messages in Pi chat via `pi.sendMessage`
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution - **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
- **Git commit capture**: Captures git commit messages and generates summaries per task
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback - **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
- **Session saving**: Saves full task output for expandable session review - **Session saving**: Saves full task output for expandable session review
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution - **Resume auto-discovery**: Automatically finds and resumes interrupted execution
- **Custom message renderer**: Compact UI labels with expandable details in Pi TUI
## Usage ## Usage
``` ```
/ralpi plan [task-file] # Show execution plan /ralpi [task-file] # Execute all tasks
/ralpi run [task-file] # Execute all tasks /ralpi plan # Alias to /task-manager to plan new tasks
/ralpi status [task-file] # Show current progress /ralpi resume # Resume paused execution
/ralpi resume [task-file] # Resume paused execution /ralpi reset [task-file] # Reset progress and .ralpi directory - does not modify PRD
/ralpi next [task-file] # Execute next batch only
/ralpi reset [task-file] # Reset all progress
``` ```
## Task File Formats ## Task File Formats
### Fio README Format ### Highly recommended to use the task-manager prompt for prd construction, it's output pairs perfectly
```markdown
# Project Title # Project Title
## Tasks ## Tasks
- [ ] 01 — Setup project structure -> `tasks/01-setup.md`
- [ ] 02 — Implement auth -> `tasks/02-auth.md`
- [ ] 03 — Build API -> `tasks/03-api.md`
## Dependencies
1 -> 2,3
2 -> 3
```
#### Supported Dependency Formats
The parser supports two dependency declaration styles in the `## Dependencies` section:
**Arrow Notation** (recommended):
```
1 -> 2,3,4
5 -> 6
```
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
**Natural Language**:
```
13 depends on 17, 18, 19, 20
14 depends on 13, 15, 16
```
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
**Parallel Groups** (informational only):
```
1, 2, 3, 4 can be done in parallel
5, 6, 7, 8 can be done in parallel
```
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
### Simple Checkbox Format ### Simple Checkbox Format
```markdown ```markdown
@@ -96,9 +54,45 @@ tasks:
depends_on: ["01"] depends_on: ["01"]
``` ```
## Dependencies
### Arrow Notation (recommended):
1 -> 2,3,4
5 -> 6
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
### Natural Language:
13 depends on 17, 18, 19, 20
14 depends on 13, 15, 16
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
### Parallel Groups (informational only):
1, 2, 3, 4 can be done in parallel
5, 6, 7, 8 can be done in parallel
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
## Configuration ## Configuration
Create config files. Both are optional: ### Task-Level Timeout
You can set a timeout for individual tasks using a meta block in the task file:
```markdown
- [ ] 01: Setup project structure
timeout: 10m
```
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
### Config files
| Scope | Path | | Scope | Path |
|-------|------| |-------|------|
@@ -115,9 +109,6 @@ prompts:
projectContext: "Additional context for all tasks" projectContext: "Additional context for all tasks"
``` ```
> ralpi deliberately does **not** set timeouts or retries — those are inherited
> from Pi's own settings. Tasks run until they complete or Pi's own flow stops them.
>
> `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent > `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent
> tasks, only the first two models are used. The third model is only touched when > tasks, only the first two models are used. The third model is only touched when
> a third concurrent task starts. Freed model slots are reused before new ones > a third concurrent task starts. Freed model slots are reused before new ones
@@ -127,28 +118,6 @@ prompts:
> the task automatically cycles to the next model in the list without counting it > the task automatically cycles to the next model in the list without counting it
> as a task failure. Each model is tried once before the task is marked as failed. > as a task failure. Each model is tried once before the task is marked as failed.
The keys mirror the nested structure of `RalpiConfig` in `src/types.ts`.
### Precedence (highest wins)
| Priority | Source |
|----------|--------|
| **1st** | In-memory overrides (`model`, `thinkingLevel` from parent Pi session) |
| **2nd** | `./.ralpi/config.yaml` — project-level |
| **3rd** | `~/.pi/ralpi/config.yaml` — global, shared across projects |
| **4th** | `DEFAULT_CONFIG` in `src/types.ts` |
### Task-Level Timeout
You can set a timeout for individual tasks using a meta block in the task file:
```markdown
- [ ] 01: Setup project structure
timeout: 10m
```
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
## State Files ## State Files
- `.ralpi/progress.json` - Execution progress - `.ralpi/progress.json` - Execution progress

1007
index.ts

File diff suppressed because it is too large Load Diff

View File

@@ -2,32 +2,49 @@
"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",
"task-runner", "task-runner",
"dag", "dag",
"task-manager", "task-manager",
"ralpi-loop", "ralph-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,9 @@
"@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*" "@earendil-works/pi-tui": "*"
}, },
"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(),
}; };
}); });
@@ -261,8 +296,8 @@ export async function runTask(
} }
if (!output.success) { if (!output.success) {
sendChatMessage?.(`${taskHeader}${output.error}`); // Failure reporting is handled by the caller (executeTask) to avoid
ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error"); // duplicate messages when model failover or retry cycling is active.
return { return {
success: false, success: false,
error: output.error, error: output.error,
@@ -378,19 +413,30 @@ export async function executeBatch(
// Execute sequentially // Execute sequentially
for (const task of tasks) { for (const task of tasks) {
const model = roundRobin?.assign(task.id); try {
await executeTask( const model = roundRobin?.assign(task.id);
task, await executeTask(
project, task,
config, project,
progress, config,
ctx, progress,
sendChatMessage, ctx,
projectDir, sendChatMessage,
undefined, projectDir,
model, undefined,
roundRobin, 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);
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
break;
}
} }
} }
@@ -414,7 +460,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 +475,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 +486,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 +517,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 +549,16 @@ 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);
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
}),
}); });
// Limit concurrency // Limit concurrency
@@ -531,9 +601,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,11 +656,12 @@ 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( sendChatMessage?.(
`Task ${task.id}: model failed, trying next (${modelAttempt + 1}/${maxModelAttempts}): ${result.error}`, `~ ${task.id} · ${task.title} trying model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
"warning",
); );
break; // exit retry loop, cycle to next model break; // exit retry loop, cycle to next model
} }
@@ -596,9 +669,8 @@ async function executeTask(
// No more models — use normal retry logic // No more models — use normal retry logic
if (retries < maxRetries) { if (retries < maxRetries) {
retries = progress.incrementRetry(task.id); retries = progress.incrementRetry(task.id);
ctx.ui.notify( sendChatMessage?.(
`Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`, `~ ${task.id} · ${task.title} — retrying (${retries}/${maxRetries}): ${result.error}`,
"warning",
); );
// Exponential backoff // Exponential backoff
@@ -607,13 +679,22 @@ 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}`); sendChatMessage?.(` ${task.id} · ${task.title} ${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; sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return;
} }
} }
@@ -621,9 +702,16 @@ 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`); sendChatMessage?.(
`${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
);
ctx.ui.notify(
`Task ${task.id} failed: all configured models exhausted`,
"error",
);
} }
// ─── Save Reflection to File ──────────────────────────────────────────────── // ─── Save Reflection to File ────────────────────────────────────────────────

View File

@@ -1,11 +1,11 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import type { import type {
ProgressState, ProgressState,
PRDProgress, PRDProgress,
Task, Task,
Reflection, Reflection,
ToolUsage, ToolUsage,
} from "./types"; } from "./types";
import { ensureDir } from "./utils"; import { ensureDir } from "./utils";
@@ -14,11 +14,11 @@ import { ensureDir } from "./utils";
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README" * e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
*/ */
export function derivePRDKey(projectDir: string, sourcePath: string): string { export function derivePRDKey(projectDir: string, sourcePath: string): string {
const rel = path.relative(projectDir, sourcePath); const rel = path.relative(projectDir, sourcePath);
return rel return rel
.replace(/[^a-zA-Z0-9_-]/g, "-") .replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-") .replace(/-+/g, "-")
.replace(/^-|-$/g, ""); .replace(/^-|-$/g, "");
} }
/** /**
@@ -28,250 +28,258 @@ export function derivePRDKey(projectDir: string, sourcePath: string): string {
* Falls back to legacy flat format for backward compatibility. * Falls back to legacy flat format for backward compatibility.
*/ */
export class ProgressTracker { export class ProgressTracker {
private statePath: string; private statePath: string;
private state: ProgressState; private state: ProgressState;
private prdKey: string; private prdKey: string;
constructor(projectDir: string, sourcePath: string, prdKey?: string) { constructor(projectDir: string, sourcePath: string, prdKey?: string) {
const stateDir = path.join(projectDir, ".ralpi"); const stateDir = path.join(projectDir, ".ralpi");
ensureDir(stateDir); ensureDir(stateDir);
this.statePath = path.join(stateDir, "progress.json"); this.statePath = path.join(stateDir, "progress.json");
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath); this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
this.state = this.loadOrCreate(sourcePath); this.state = this.loadOrCreate(sourcePath);
} }
/** Load existing state or create a fresh one */ /** Load existing state or create a fresh one */
private loadOrCreate(sourcePathHint: string): ProgressState { private loadOrCreate(sourcePathHint: string): ProgressState {
if (fs.existsSync(this.statePath)) { if (fs.existsSync(this.statePath)) {
try { try {
const raw = fs.readFileSync(this.statePath, "utf-8"); const raw = fs.readFileSync(this.statePath, "utf-8");
const parsed = JSON.parse(raw) as ProgressState; const parsed = JSON.parse(raw) as ProgressState;
// Multi-PRD mode: check if we have a PRD entry // Multi-PRD mode: check if we have a PRD entry
if (parsed.prds?.[this.prdKey]) { if (parsed.prds?.[this.prdKey]) {
// Found PRD entry — use it, but keep legacy fields for compat // Found PRD entry — use it, but keep legacy fields for compat
return parsed; return parsed;
} }
// Legacy flat mode: check if the source path matches // Legacy flat mode: check if the source path matches
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) { if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
// Migrate legacy state to PRD mode // Migrate legacy state to PRD mode
parsed.prds = { parsed.prds = {
[this.prdKey]: { [this.prdKey]: {
sourcePath: parsed.sourcePath, sourcePath: parsed.sourcePath,
tasks: parsed.tasks, tasks: parsed.tasks,
startedAt: parsed.startedAt, startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt, lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused, paused: parsed.paused,
}, },
}; };
return parsed; return parsed;
} }
// Different PRD — create new entry alongside existing ones // Different PRD — create new entry alongside existing ones
if (parsed.prds) { if (parsed.prds) {
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint); parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
return parsed; return parsed;
} }
// Legacy flat state exists but for a different source — promote it to PRD mode // Legacy flat state exists but for a different source — promote it to PRD mode
const legacyKey = derivePRDKey( const legacyKey = derivePRDKey(
path.dirname(this.statePath), path.dirname(this.statePath),
parsed.sourcePath, parsed.sourcePath,
); );
parsed.prds = { parsed.prds = {
[legacyKey]: { [legacyKey]: {
sourcePath: parsed.sourcePath, sourcePath: parsed.sourcePath,
tasks: parsed.tasks, tasks: parsed.tasks,
startedAt: parsed.startedAt, startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt, lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused, paused: parsed.paused,
}, },
[this.prdKey]: this.freshPRD(sourcePathHint), [this.prdKey]: this.freshPRD(sourcePathHint),
}; };
return parsed; return parsed;
} catch { } catch {
// Fall through to create new // Fall through to create new
} }
} }
return this.freshState(sourcePathHint); return this.freshState(sourcePathHint);
} }
private freshPRD(sourcePath: string): PRDProgress { private freshPRD(sourcePath: string): PRDProgress {
return { return {
sourcePath, sourcePath,
tasks: {}, tasks: {},
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(), lastUpdatedAt: new Date().toISOString(),
paused: false, paused: false,
}; };
} }
private freshState(sourcePath: string): ProgressState { private freshState(sourcePath: string): ProgressState {
return { return {
sourcePath, sourcePath,
tasks: {}, tasks: {},
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(), lastUpdatedAt: new Date().toISOString(),
paused: false, paused: false,
prds: { prds: {
[this.prdKey]: { [this.prdKey]: {
sourcePath, sourcePath,
tasks: {}, tasks: {},
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(), lastUpdatedAt: new Date().toISOString(),
paused: false, paused: false,
}, },
}, },
}; };
} }
/** Get the PRD-scoped progress entry */ /** Get the PRD-scoped progress entry */
private getPRD(): PRDProgress { private getPRD(): PRDProgress {
if (!this.state.prds) { if (!this.state.prds) {
// Should not happen after loadOrCreate, but guard anyway // Should not happen after loadOrCreate, but guard anyway
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) }; this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
} }
if (!this.state.prds[this.prdKey]) { if (!this.state.prds[this.prdKey]) {
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath); this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
} }
return this.state.prds[this.prdKey]; return this.state.prds[this.prdKey];
} }
/** Save current state to disk */ /** Save current state to disk */
save(): void { save(): void {
const prd = this.getPRD(); const prd = this.getPRD();
prd.lastUpdatedAt = new Date().toISOString(); prd.lastUpdatedAt = new Date().toISOString();
// Sync legacy flat fields with current PRD for backward compat // Sync legacy flat fields with current PRD for backward compat
this.state.sourcePath = prd.sourcePath; this.state.sourcePath = prd.sourcePath;
this.state.tasks = prd.tasks; this.state.tasks = prd.tasks;
this.state.startedAt = prd.startedAt; this.state.startedAt = prd.startedAt;
this.state.lastUpdatedAt = prd.lastUpdatedAt; this.state.lastUpdatedAt = prd.lastUpdatedAt;
this.state.paused = prd.paused; this.state.paused = prd.paused;
fs.writeFileSync( fs.writeFileSync(
this.statePath, this.statePath,
JSON.stringify(this.state, null, 2), JSON.stringify(this.state, null, 2),
"utf-8", "utf-8",
); );
} }
/** Mark a task as in progress */ /** Mark a task as in progress */
markInProgress(taskId: string): void { markInProgress(taskId: string): void {
const prd = this.getPRD(); const prd = this.getPRD();
this.ensureTask(prd, taskId); this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress"; prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString(); prd.tasks[taskId].startedAt = new Date().toISOString();
this.save(); this.save();
} }
/** Mark a task as completed */ /** Mark a task as completed */
markCompleted( markCompleted(
taskId: string, taskId: string,
durationMs: number, durationMs: number,
reflection?: Reflection, reflection?: Reflection,
toolUsage?: ToolUsage, toolUsage?: ToolUsage,
sessionFile?: string, sessionFile?: string,
outputPreview?: string, outputPreview?: string,
commitMessages?: string[], commitMessages?: string[],
commitSummary?: string, commitSummary?: string,
): void { ): void {
const prd = this.getPRD(); const prd = this.getPRD();
this.ensureTask(prd, taskId); this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "completed"; prd.tasks[taskId].status = "completed";
prd.tasks[taskId].completedAt = new Date().toISOString(); prd.tasks[taskId].completedAt = new Date().toISOString();
prd.tasks[taskId].durationMs = durationMs; prd.tasks[taskId].durationMs = durationMs;
if (reflection) prd.tasks[taskId].reflection = reflection; if (reflection) prd.tasks[taskId].reflection = reflection;
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage; if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile; if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview; if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages; if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary; if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
this.save(); this.save();
} }
/** Mark a task as failed */ /** Mark a task as failed */
markFailed(taskId: string, error: string): void { markFailed(taskId: string, error: string): void {
const prd = this.getPRD(); const prd = this.getPRD();
this.ensureTask(prd, taskId); this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "failed"; prd.tasks[taskId].status = "failed";
prd.tasks[taskId].error = error; prd.tasks[taskId].error = error;
this.save(); this.save();
} }
/** Get task status */ /** Get task status */
getTaskStatus(taskId: string): Task["status"] { getTaskStatus(taskId: string): Task["status"] {
const prd = this.getPRD(); const prd = this.getPRD();
return prd.tasks[taskId]?.status ?? "pending"; return prd.tasks[taskId]?.status ?? "pending";
} }
/** Get IDs of all completed tasks */ /** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] { getCompletedTaskIds(): string[] {
const prd = this.getPRD(); const prd = this.getPRD();
return Object.entries(prd.tasks) return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "completed") .filter(([, info]) => info.status === "completed")
.map(([id]) => id); .map(([id]) => id);
} }
/** Get all reflections from completed tasks */ /** Get IDs of all failed tasks */
getAllReflections(): Reflection[] { getFailedTaskIds(): string[] {
const prd = this.getPRD(); const prd = this.getPRD();
const reflections: Reflection[] = []; return Object.entries(prd.tasks)
for (const info of Object.values(prd.tasks)) { .filter(([, info]) => info.status === "failed")
if (info.reflection) reflections.push(info.reflection); .map(([id]) => id);
} }
return reflections;
}
/** Get reflections for specific dependency tasks */ /** Get all reflections from completed tasks */
getDependencyReflections(depIds: string[]): Reflection[] { getAllReflections(): Reflection[] {
const prd = this.getPRD(); const prd = this.getPRD();
return depIds const reflections: Reflection[] = [];
.map((id) => prd.tasks[id]?.reflection) for (const info of Object.values(prd.tasks)) {
.filter((r): r is Reflection => r !== undefined); if (info.reflection) reflections.push(info.reflection);
} }
return reflections;
}
/** Increment retry count */ /** Get reflections for specific dependency tasks */
incrementRetry(taskId: string): number { getDependencyReflections(depIds: string[]): Reflection[] {
const prd = this.getPRD(); const prd = this.getPRD();
this.ensureTask(prd, taskId); return depIds
prd.tasks[taskId].retries++; .map((id) => prd.tasks[id]?.reflection)
this.save(); .filter((r): r is Reflection => r !== undefined);
return prd.tasks[taskId].retries; }
}
/** Set paused state */ /** Increment retry count */
setPaused(paused: boolean): void { incrementRetry(taskId: string): number {
const prd = this.getPRD(); const prd = this.getPRD();
prd.paused = paused; this.ensureTask(prd, taskId);
this.save(); prd.tasks[taskId].retries++;
} this.save();
return prd.tasks[taskId].retries;
}
/** Get the raw PRD state (for status display) */ /** Set paused state */
getState(): PRDProgress { setPaused(paused: boolean): void {
return this.getPRD(); const prd = this.getPRD();
} prd.paused = paused;
this.save();
}
/** Get all PRDs (for multi-PRD status display) */ /** Get the raw PRD state (for status display) */
getAllPRDs(): Record<string, PRDProgress> { getState(): PRDProgress {
return this.state.prds ?? {}; return this.getPRD();
} }
/** Get the PRD key for this tracker */ /** Get all PRDs (for multi-PRD status display) */
getKey(): string { getAllPRDs(): Record<string, PRDProgress> {
return this.prdKey; return this.state.prds ?? {};
} }
/** Reset all progress for this PRD */ /** Get the PRD key for this tracker */
reset(): void { getKey(): string {
const prd = this.getPRD(); return this.prdKey;
Object.assign(prd, this.freshPRD(prd.sourcePath)); }
this.save();
}
private ensureTask(prd: PRDProgress, taskId: string): void { /** Reset all progress for this PRD */
if (!prd.tasks[taskId]) { reset(): void {
prd.tasks[taskId] = { status: "pending", retries: 0 }; const prd = this.getPRD();
} Object.assign(prd, this.freshPRD(prd.sourcePath));
} this.save();
}
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending", retries: 0 };
}
}
} }

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"]