Compare commits
3 Commits
ab1e2eb430
...
dae33248e3
| Author | SHA1 | Date | |
|---|---|---|---|
| dae33248e3 | |||
| 9f90ed4252 | |||
| 3c01652b90 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
|
||||||
.pi-lens
|
.pi-lens
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -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
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.
|
||||||
119
README.md
119
README.md
@@ -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
|
||||||
|
|||||||
38
package.json
38
package.json
@@ -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"
|
||||||
|
|||||||
71
src/dag.ts
71
src/dag.ts
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
170
src/executor.ts
170
src/executor.ts
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|||||||
470
src/progress.ts
470
src/progress.ts
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user