Compare commits
13 Commits
dd61249e8d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 723b7be84a | |||
| bed74f9d33 | |||
| 3a3c293aa7 | |||
| b9a12931fe | |||
| f9b57ec2ed | |||
| b0749467c9 | |||
| f361f05f96 | |||
| 9f1250d1a1 | |||
| fc2f8a879f | |||
| 7253e51fb9 | |||
| 284b3720af | |||
| d7c3962bc8 | |||
| 6223ac84a5 |
100
AGENTS.md
100
AGENTS.md
@@ -2,7 +2,9 @@
|
||||
|
||||
## What this is
|
||||
|
||||
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 commands
|
||||
(`/ralpi`, `/ralpi-run`, `/ralpi-plan`, `/ralpi-resume`, `/ralpi-reset`).
|
||||
Not a standalone app — it runs inside Pi's extension host.
|
||||
|
||||
## Type checking
|
||||
|
||||
@@ -10,6 +12,8 @@ A Pi coding agent extension that registers the `/ralpi` slash command. Not a sta
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
Tests: `bun test` (parser and DAG suites in `tests/`).
|
||||
|
||||
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
|
||||
@@ -23,22 +27,41 @@ The extension imports from Pi SDK packages (not in `package.json` — provided b
|
||||
- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
|
||||
- `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer
|
||||
|
||||
The only real npm dependency is `yaml` (^2.4.0).
|
||||
The only real npm dependency is `yaml` (^2.4.0). It is used for parsing YAML
|
||||
task files (`src/parser.ts`) and config files (`parseSimpleYaml` in
|
||||
`src/utils.ts`, which falls back to a flat key:value parser when the package
|
||||
is unavailable).
|
||||
|
||||
## Source structure
|
||||
|
||||
- `index.ts` — extension entry, command routing, UI registration, reload detection
|
||||
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
|
||||
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
|
||||
prompts, reload auto-resume via `session_start`, progress message renderer
|
||||
- `src/` — all logic modules:
|
||||
- `parser.ts` — task file parsing (Fio, checkbox, YAML formats)
|
||||
- `dag.ts` — Kahn's algorithm dependency resolution, batch planning
|
||||
- `executor.ts` — task execution, retry, parallel/sequential modes
|
||||
- `progress.ts` — `.ralpi/progress.json` state management
|
||||
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
||||
YAML formats), dependency + parallel-group + timeout parsing,
|
||||
`updateTaskInFile()` for PRD checkbox updates
|
||||
- `dag.ts` — Kahn's algorithm dependency resolution, group-aware batching,
|
||||
cycle detection, sequential/parallel plan builders
|
||||
- `executor.ts` — task execution, parallel/sequential modes, model
|
||||
round-robin + failover, review-gated loop, worktree orchestration,
|
||||
batch-level conflict resolution
|
||||
- `review.ts` — review verdict extraction (`## REVIEW VERDICT`), review
|
||||
save/load to `.ralpi/reviews/`
|
||||
- `worktree.ts` — git worktree create/merge/cleanup helpers, stale-worktree
|
||||
cleanup, `finalizeCommittedWorktrees()`
|
||||
- `progress.ts` — `.ralpi/progress.json` state management (multi-PRD)
|
||||
- `prompts.ts` — prompt generation for spawned agent sessions
|
||||
- `reflection.ts` — reflection extraction from agent output
|
||||
- `utils.ts` — config loading, progress discovery, `runAgentSession()`
|
||||
- `utils.ts` — config loading, progress/PRD discovery, `runAgentSession()`,
|
||||
model resolution (`resolveModelSpec`), loop-active marker
|
||||
- `types.ts` — all interfaces and `DEFAULT_CONFIG`
|
||||
- `widget-batcher.ts` — debounced widget updates for parallel tasks
|
||||
- `constants.ts` — static constants
|
||||
- `task-manager-prompt.ts` — loads and expands the bundled
|
||||
`prompts/task-manager.md` template for `/ralpi-plan`
|
||||
- `constants.ts` — static constants (slash command, task file names,
|
||||
reflection/review patterns)
|
||||
- `tests/` — bun test suites for parser and DAG behavior
|
||||
- `skills/ralpi-use.md` — Pi skill definition for task execution
|
||||
- `prompts/task-manager.md` — Pi prompt for task planning
|
||||
|
||||
@@ -47,27 +70,70 @@ The only real npm dependency is `yaml` (^2.4.0).
|
||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||
|
||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||
- `.ralpi/loop-active.json` — marker written while a loop runs; drives
|
||||
auto-resume after a session reload
|
||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
||||
`saveReviews` is on)
|
||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||
- `.ralpi/sessions/` — full session transcripts
|
||||
- `.ralpi/config.yaml` — project-level config (optional)
|
||||
|
||||
There is no `.ralpi/sessions/` directory anymore — full task output is shown
|
||||
inline via expandable `ralpi-progress` chat messages, and review output is
|
||||
persisted under `.ralpi/reviews/`.
|
||||
|
||||
## Task ID convention
|
||||
|
||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0` to parsed digits. Never use raw numeric IDs.
|
||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
|
||||
single lowercase letter suffix for sub-tasks (`"02b"`, `"02c"`). The parser
|
||||
normalizes `2b` → `02b` (see `normalizeTaskId` in `src/parser.ts`). Never
|
||||
use raw numeric IDs.
|
||||
|
||||
## Command routing
|
||||
|
||||
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `resume`, `reset`).
|
||||
- `/ralpi` — no args → show plan for `README.md`; first token looks like a
|
||||
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
||||
error suggesting the dash commands
|
||||
- `/ralpi-run [task-file]` — run tasks (auto-resumes when progress already
|
||||
exists for the file; otherwise prompts for execution mode + loop options)
|
||||
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
|
||||
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
||||
with `expandPromptTemplates: false`, so the extension does its own
|
||||
frontmatter stripping and `$@`/`$1` arg substitution
|
||||
(`loadTaskManagerPrompt` in `src/task-manager-prompt.ts`)
|
||||
- `/ralpi-resume [task-file]` — resume from persisted progress; prompts for
|
||||
the PRD when multiple loops have progress. Reuses the loop snapshot in
|
||||
`loop-active.json` (mode + autoCommit/autoReview/saveReviews) to resume
|
||||
non-interactively
|
||||
- `/ralpi-reset [task-file]` — reset execution progress (does not modify the PRD)
|
||||
|
||||
The old `/ralpi plan|resume|reset` subcommand dispatch, plus `status` and
|
||||
`next`, were removed.
|
||||
|
||||
## Config
|
||||
|
||||
Read from `.ralpi/config.yaml` in project directory (and global `~/.pi/ralpi/config.yaml`). Falls back to `DEFAULT_CONFIG` in `src/types.ts` when files are missing. Config is loaded at `projectDir` level, not extension level.
|
||||
Read from `.ralpi/config.yaml` in project directory (and global
|
||||
`~/.pi/ralpi/config.yaml`), project overrides global. Falls back to
|
||||
`DEFAULT_CONFIG` in `src/types.ts` when files are missing. Config is loaded
|
||||
at `projectDir` level, not extension level. Execution keys explicitly
|
||||
present in a loaded YAML are tracked in `execution.explicitKeys` so the
|
||||
loop-startup interactive prompts (`selectLoopOptions` in `index.ts`) can be
|
||||
skipped for fields the user already set.
|
||||
|
||||
Key config fields in `execution`:
|
||||
|
||||
- `autoCommit` / `autoReview` — toggle follow-up commit and review agent sessions (also selectable at loop startup via `selectLoopOptions`)
|
||||
- `models` — round-robin model list for parallel mode
|
||||
- `implModel` / `commitModel` / `reviewModel` — `<provider>/<model>` strings resolved via `resolveModelSpec` in `utils.ts`
|
||||
- `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at
|
||||
loop startup via `selectLoopOptions`; review is asked FIRST, commit is
|
||||
mandated when review is on)
|
||||
- `models` — slot-aware round-robin model list for parallel mode, with
|
||||
automatic failover to the next model per task
|
||||
- `implModel` / `commitModel` / `reviewModel` — `<provider>/<model>` strings
|
||||
resolved via `resolveModelSpec` in `utils.ts`
|
||||
- `maxReviewRetries` / `reviewBlockOnFail` — review-gated loop retry behavior
|
||||
- `worktrees` — `"never" | "parallel" | "always"` git worktree isolation
|
||||
(default `"parallel"`; see `shouldUseWorktrees` in `src/executor.ts`)
|
||||
- `commitTimeoutMs` / `reviewTimeoutMs` — timeouts for follow-up sessions
|
||||
- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked between batches in `executePlanBatches`)
|
||||
- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked
|
||||
between batches in `executePlanBatches`)
|
||||
- `timeoutMs` — per-task execution timeout
|
||||
- `prompts.projectContext` / `prompts.reflectionPrompt` — prompt-level settings
|
||||
|
||||
197
README.md
197
README.md
@@ -8,35 +8,59 @@ pi install npm:@mikefreno/ralpi
|
||||
|
||||
## Features
|
||||
|
||||
- **Parallel batching**: Independent tasks in each batch can run concurrently
|
||||
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`
|
||||
- **DAG-based execution**: Tasks ordered via dependencies (arrow notation, natural language, "must be done before", or YAML)
|
||||
- **Parallel batching**: Independent tasks in each batch run concurrently, round-robin across configured models
|
||||
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`, supporting multiple PRDs simultaneously
|
||||
- **Resume & auto-resume**: `/ralpi-resume` continues paused execution; a session reload mid-loop auto-resumes via `.ralpi/loop-active.json`
|
||||
- **Reflection system**: Each task produces a reflection for downstream tasks
|
||||
- **Retry with backoff**: Failed tasks retry with exponential backoff
|
||||
- **Multiple formats**: Supports simple checkboxes, and YAML
|
||||
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
|
||||
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
|
||||
- **Session saving**: Saves full task output for expandable session review
|
||||
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution
|
||||
- **Phased plans**: `## Phase N — Title` sections add implicit phase-boundary dependencies
|
||||
- **Model failover**: Unreachable providers cycle to the next model in the list before a task fails
|
||||
- **Auto-commit / auto-review loop**: Optional per-task commit and review-gated re-execution until pass
|
||||
- **Worktree isolation**: Parallel tasks run in separate git worktrees so they can't stomp each other, with batch-level merge-conflict resolution
|
||||
- **Multiple formats**: Fio README (numbered + dependencies), phased, simple checkboxes, and YAML
|
||||
- **Tool usage tracking**: Reports read/write/edit/bash usage from task execution
|
||||
- **Configurable timeouts**: Task-level timeouts (inline, meta block, or YAML) with global fallback
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ralpi [task-file] # Execute all tasks
|
||||
/ralpi plan # Alias to /task-manager to plan new tasks
|
||||
/ralpi resume # Resume paused execution
|
||||
/ralpi reset # Reset progress and .ralpi directory - does not modify PRD
|
||||
/ralpi [task-file] # No args → show plan for README.md; path arg → run tasks
|
||||
/ralpi-run [task-file] # Execute tasks from a task file
|
||||
/ralpi-plan [prompt] # Open the Task Manager to plan tasks
|
||||
/ralpi-resume [task-file] # Resume paused/interrupted execution
|
||||
/ralpi-reset [task-file] # Reset execution progress — does not modify the PRD
|
||||
```
|
||||
|
||||
### Highly recommended to use the task-manager prompt for prd construction, it's output pairs perfectly - /task-manager or /ralpi plan
|
||||
`/ralpi` with no arguments shows the execution plan for the default task file. When the first token looks like a path (`@path`, `./path`, `path/file.md`, `.yaml`, etc.) it routes to `/ralpi-run`. Everything else is handled by the dedicated dash commands above (the old `/ralpi plan|resume|reset` subcommand syntax is gone).
|
||||
|
||||
> The task-manager prompt (`/ralpi-plan`) pairs perfectly with ralpi's task file formats — use it for PRD construction.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Simple Checkbox Format
|
||||
|
||||
```markdown
|
||||
- [ ] 01: Setup project structure
|
||||
- [ ] 02: Implement auth
|
||||
- [ ] 03: Build API
|
||||
- [ ] Setup project structure
|
||||
- [ ] Implement auth
|
||||
- [ ] Build API
|
||||
```
|
||||
|
||||
Checkbox-only files get sequential IDs (`01`, `02`, ...). Status characters: `[ ]` pending, `[x]` done, `[~]` in progress, `[!]` failed, `[-]` skipped.
|
||||
|
||||
### Fio Format (numbered tasks + dependencies)
|
||||
|
||||
```markdown
|
||||
# Build a web application
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] 01 — Setup project structure
|
||||
- [ ] 02 — Implement auth
|
||||
- [ ] 03 — Build API
|
||||
|
||||
## Dependencies
|
||||
|
||||
01 -> 02, 03
|
||||
```
|
||||
|
||||
### YAML Format
|
||||
@@ -72,40 +96,98 @@ Use lettered sub-tasks when you discover mid-stream that a step needs to be
|
||||
split. They let you preserve sibling numbering (`01`, `02`, `03`, ...) while
|
||||
adding granularity between two existing steps.
|
||||
|
||||
## Phases
|
||||
|
||||
`## Phase N — Title` headings group tasks into phases and add an implicit
|
||||
dependency from the first task of each phase to the last task of the
|
||||
previous one, so phases always run in order:
|
||||
|
||||
```markdown
|
||||
## Phase 1 — Push-to-Talk MVP
|
||||
|
||||
- [ ] 01 — Voice capture
|
||||
- [ ] 02 — Transmission
|
||||
|
||||
## Phase 2 — Group Chat
|
||||
|
||||
- [ ] 03 — Channels
|
||||
- [ ] 04 — Presence
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Dependency lines live in a `## Dependencies` section (or a plain
|
||||
`Dependencies` heading). Multiple formats are supported and can be mixed.
|
||||
|
||||
### Arrow Notation (recommended)
|
||||
|
||||
```
|
||||
1 -> 2,3,4
|
||||
5 -> 6
|
||||
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
|
||||
```
|
||||
|
||||
"Task 1 must complete before tasks 2, 3, and 4 can start." Also supports
|
||||
chains (`03 -> 04 -> 05`) and multi-prereq sources (`05, 07, 08 -> 13`).
|
||||
|
||||
### Natural Language
|
||||
|
||||
```
|
||||
13 depends on 17, 18, 19, 20
|
||||
14 depends on 13, 15, 16
|
||||
22, 23, 24 depend on 21
|
||||
```
|
||||
|
||||
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
|
||||
"Task 13 depends on tasks 17, 18, 19, and 20." `also depends on` is accepted.
|
||||
|
||||
### Parallel Groups (informational only)
|
||||
### "must be done before"
|
||||
|
||||
1, 2, 3, 4 can be done in parallel
|
||||
```
|
||||
21 must be done before 22, 23, 24
|
||||
02, 03 must be done before 04
|
||||
```
|
||||
|
||||
### Parallel Groups
|
||||
|
||||
```
|
||||
1, 2, 3, 4 can be done in parallel (Play Store prep)
|
||||
5, 6, 7, 8 can be done in parallel
|
||||
```
|
||||
|
||||
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
|
||||
Tasks listed in a parallel group are allowed to run concurrently. Group
|
||||
declarations imply no cross-group dependencies, and intra-group
|
||||
dependencies are still respected — group-aware batching produces a plan
|
||||
where tasks from any group run as soon as their dependencies are
|
||||
satisfied.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Task-Level Timeout
|
||||
|
||||
You can set a timeout for individual tasks using a meta block in the task file:
|
||||
Timeouts can be set inline on the task line, as an inline comment, via a
|
||||
meta block in the Dependencies section, or in YAML:
|
||||
|
||||
```markdown
|
||||
- [ ] 01: Setup project structure
|
||||
timeout: 10m
|
||||
- [ ] 01 — Setup project structure timeout: 10m
|
||||
- [ ] 02 — Implement auth # timeout=30s
|
||||
```
|
||||
|
||||
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
||||
```markdown
|
||||
## Dependencies
|
||||
|
||||
01 -> 02
|
||||
01 [timeout] = 10m
|
||||
```
|
||||
|
||||
```yaml
|
||||
tasks:
|
||||
- id: "01"
|
||||
title: Setup project structure
|
||||
timeout: 15m
|
||||
```
|
||||
|
||||
Supported units: `m` / `min` (minutes), `s` (seconds), `ms` (milliseconds).
|
||||
Bare numbers default to minutes; in YAML, numeric values ≥ 1000 are treated
|
||||
as milliseconds.
|
||||
|
||||
### Config files
|
||||
|
||||
@@ -114,23 +196,31 @@ Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
||||
| **Global** | `~/.pi/ralpi/config.yaml` |
|
||||
| **Project** | `./.ralpi/config.yaml` |
|
||||
|
||||
Project config overrides global, which overrides defaults. Keys set
|
||||
explicitly in YAML skip the corresponding loop-startup prompt.
|
||||
|
||||
```yaml
|
||||
execution:
|
||||
maxParallel: 3 # ralpi-level concurrency only
|
||||
models: # round-robin in <provider>/<model> format
|
||||
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
||||
- openai/gpt-5.5 # 2nd task in parallel
|
||||
autoCommit: true # commit after each task (mandated when autoReview is on; standalone toggle when off)
|
||||
autoReview: false # commit → review → loop on fail → merge on pass
|
||||
implModel: "" # model for task impl (sequential mode, empty = inherit parent)
|
||||
commitModel: "" # model for commit sessions (empty = inherit task model)
|
||||
reviewModel: "" # model for review sessions (empty = inherit task model)
|
||||
timeoutMs: 0 # per-task timeout in ms (0 = inherit Pi's defaults)
|
||||
commitTimeoutMs: 60000 # timeout for auto-commit agent sessions
|
||||
reviewTimeoutMs: 120000 # timeout for auto-review agent sessions
|
||||
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit)
|
||||
maxParallel: 3 # ralpi-level concurrency only (0 = unlimited)
|
||||
models: # round-robin for parallel tasks, <provider>/<model>
|
||||
- anthropic/claude-sonnet-4
|
||||
- openai/gpt-4o
|
||||
autoCommit: true # commit after each task (mandated when autoReview is on)
|
||||
autoReview: false # commit → review → loop on fail → merge on pass
|
||||
saveReviews: false # persist full review output to .ralpi/reviews/ (only with autoReview)
|
||||
maxReviewRetries: 2 # re-executions on a 'fail' verdict before giving up
|
||||
reviewBlockOnFail: false # true = mark task failed after retries exhausted instead of merging
|
||||
implModel: "" # model for task impl (empty = inherit parent session model)
|
||||
commitModel: "" # model for commit sessions (empty = inherit task model)
|
||||
reviewModel: "" # model for review sessions (empty = inherit task model)
|
||||
timeoutMs: 0 # per-task timeout in ms (0 = inherit Pi's defaults)
|
||||
commitTimeoutMs: 0 # timeout for auto-commit agent sessions (0 = inherit)
|
||||
reviewTimeoutMs: 0 # timeout for auto-review agent sessions (0 = inherit)
|
||||
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit; checked between batches)
|
||||
worktrees: parallel # "never" | "parallel" (default) | "always" — git worktree isolation
|
||||
prompts:
|
||||
projectContext: "Additional context for all tasks"
|
||||
reflectionPrompt: "" # custom suffix for reflection extraction
|
||||
```
|
||||
|
||||
> `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent
|
||||
@@ -140,8 +230,9 @@ prompts:
|
||||
> **Automatic failover**: if a provider/API is unreachable (rate limit, 503, etc.),
|
||||
> 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.
|
||||
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
||||
> parent pi session's model is used
|
||||
> **NOTE**: model lists are only used in parallel execution. In sequential mode
|
||||
> (or parallel mode with no `models` list) the parent pi session's model is used,
|
||||
> unless `implModel` is set.
|
||||
|
||||
#### Auto-review and Auto-commit
|
||||
|
||||
@@ -158,19 +249,27 @@ state — original work plus fixes. On pass, the changes are already
|
||||
committed and the worktree merges.
|
||||
|
||||
When `autoReview` is disabled, `autoCommit` runs a follow-up commit
|
||||
agent after each task with no review. Both options can be overridden at
|
||||
loop startup via a selection prompt (config YAML values are honored
|
||||
without prompting when set explicitly).
|
||||
agent after each task with no review. With `autoReview` on, the user is
|
||||
also asked whether to persist full review output to
|
||||
`.ralpi/reviews/<prdKey>/<task-id>.json` (`saveReviews` — this is what
|
||||
enables review feedback recovery when resuming interrupted loops). Both
|
||||
options can be overridden at loop startup via a selection prompt (config
|
||||
YAML values are honored without prompting when set explicitly).
|
||||
|
||||
`commitModel` and `reviewModel` accept `<provider>/<model>` strings (e.g.
|
||||
`anthropic/claude-sonnet-4`) resolved via the model registry. When empty, the
|
||||
task's model is inherited. `implModel` sets the model for task implementation
|
||||
in sequential mode (overridden by `execution.models` round-robin in parallel
|
||||
mode).
|
||||
(used whenever no round-robin model is assigned — sequential mode, or parallel
|
||||
mode with an empty `models` list; overridden by `execution.models` round-robin
|
||||
in parallel mode).
|
||||
|
||||
## State Files
|
||||
|
||||
- `.ralpi/progress.json` - Execution progress
|
||||
- `.ralpi/reflections/` - Per-task reflections
|
||||
- `.ralpi/prompts/` - Generated prompts
|
||||
- `.ralpi/sessions/` - Full task output for review
|
||||
```
|
||||
.ralpi/progress.json # Execution progress (supports multiple PRDs)
|
||||
.ralpi/loop-active.json # Active-loop marker used for auto-resume after a reload
|
||||
.ralpi/reflections/ # Per-task reflections
|
||||
.ralpi/reviews/<prdKey>/ # Full review output (when saveReviews is on)
|
||||
.ralpi/prompts/ # Generated prompts (timestamped, for debugging)
|
||||
.ralpi/config.yaml # Project-level config (optional)
|
||||
```
|
||||
|
||||
593
index.ts
593
index.ts
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
@@ -14,11 +15,16 @@ import {
|
||||
} from "./src/dag";
|
||||
import { ProgressTracker } from "./src/progress";
|
||||
import { buildPlanPrompt } from "./src/prompts";
|
||||
import { loadTaskManagerPrompt } from "./src/task-manager-prompt";
|
||||
import { formatReflections } from "./src/reflection";
|
||||
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
||||
import type { ReviewResult } from "./src/types";
|
||||
import { executeBatch, type SendChatMessage } from "./src/executor";
|
||||
import { cleanupStaleWorktrees } from "./src/worktree";
|
||||
import {
|
||||
cleanupStaleWorktrees,
|
||||
finalizeCommittedWorktrees,
|
||||
abortMerge,
|
||||
} from "./src/worktree";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveTaskArg,
|
||||
@@ -29,11 +35,10 @@ import {
|
||||
readLoopActive,
|
||||
findRalpiDir,
|
||||
listPRDsSorted,
|
||||
countPRDResumeStats,
|
||||
formatDuration,
|
||||
} from "./src/utils";
|
||||
|
||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
||||
|
||||
type ExecutionMode = "parallel" | "sequential";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -160,9 +165,9 @@ async function selectLoopOptions(
|
||||
saveReviews = config.execution.saveReviews;
|
||||
} else {
|
||||
const saveChoice = await ctx.ui.select(
|
||||
"Save full review output to disk?",
|
||||
"Save full review output to disk? (recommended — enables review feedback recovery when resuming interrupted loops)",
|
||||
[
|
||||
"Yes — write each review to .ralpi/reviews/<loop>/<task>.md",
|
||||
"Yes — write each review to .ralpi/reviews/<loop>/<task>.json",
|
||||
"No — keep reviews in-chat only",
|
||||
],
|
||||
);
|
||||
@@ -196,13 +201,14 @@ async function selectLoopOptions(
|
||||
|
||||
/**
|
||||
* When multiple PRD loops have progress, prompt the user to select which one
|
||||
* to resume. Returns the selected PRD key and sourcePath.
|
||||
* to act on. Returns the selected PRD key and sourcePath.
|
||||
* If only one PRD exists, returns it without prompting.
|
||||
* Returns null if no PRDs exist.
|
||||
*/
|
||||
async function selectPRDToResume(
|
||||
async function selectPRD(
|
||||
ctx: ExtensionContext,
|
||||
found: NonNullable<ReturnType<typeof findProgressFile>>,
|
||||
prompt: string,
|
||||
): Promise<{ prdKey: string; sourcePath: string } | null> {
|
||||
const prds = listPRDsSorted(found.state);
|
||||
if (prds.length === 0) return null;
|
||||
@@ -212,23 +218,20 @@ async function selectPRDToResume(
|
||||
|
||||
// Multiple PRDs — show selection sorted by most recent first
|
||||
const options = prds.map((entry) => {
|
||||
const tasks = entry.prd.tasks;
|
||||
const total = Object.keys(tasks).length;
|
||||
const completed = Object.values(tasks).filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
const failed = Object.values(tasks).filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
const relPath = path.relative(process.cwd(), entry.prd.sourcePath);
|
||||
// Total/completed must come from the parsed PRD file, not just the
|
||||
// progress map: the tracker only records TOUCHED tasks (started/
|
||||
// completed/failed), so never-started tasks would be silently missing
|
||||
// from a naive Object.keys() count and the totals would under-report.
|
||||
const { total, completed, failed } = countPRDResumeStats(
|
||||
entry.prd,
|
||||
entry.prd.sourcePath,
|
||||
);
|
||||
const relPath = path.relative(ctx.cwd, entry.prd.sourcePath);
|
||||
const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString();
|
||||
return `${relPath} — ${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`;
|
||||
});
|
||||
|
||||
const selected = await ctx.ui.select(
|
||||
"Multiple loops found. Which to resume?",
|
||||
options,
|
||||
);
|
||||
const selected = await ctx.ui.select(prompt, options);
|
||||
if (!selected) return null;
|
||||
|
||||
const idx = options.indexOf(selected);
|
||||
@@ -252,6 +255,23 @@ async function executePlanBatches(
|
||||
projectDir?: string,
|
||||
isResume?: boolean,
|
||||
): Promise<void> {
|
||||
// Refresh the model registry so the host reloads models.json before we
|
||||
// resolve the round-robin model pool. The registry snapshot is captured at
|
||||
// host startup and only reloaded here; a long-running host would otherwise
|
||||
// skip providers added to models.json after it booted (e.g. "strix").
|
||||
// Best-effort: a failed refresh shouldn't block execution — the pool just
|
||||
// resolves against the existing snapshot.
|
||||
try {
|
||||
await ctx.modelRegistry?.refresh();
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
`ralpi: model registry refresh failed — continuing with existing snapshot: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
|
||||
// Write loop-active marker so a session reload can detect an interrupted
|
||||
// loop and resume it (in-process agent sessions die on reload — the marker
|
||||
// + progress.json in_progress tasks are the signal to re-run them).
|
||||
@@ -390,6 +410,31 @@ async function executePlanBatches(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a sendProgress closure that posts ralpi progress messages into the
|
||||
* chat history for the expandable tool-call-tree renderer.
|
||||
*
|
||||
* Used by every registered command so they share one rendering path.
|
||||
*/
|
||||
function makeSendProgress(pi: ExtensionAPI): SendChatMessage {
|
||||
return (content, meta) => {
|
||||
pi.sendMessage({
|
||||
customType: "ralpi-progress",
|
||||
content,
|
||||
display: true,
|
||||
details: {
|
||||
phase: "progress",
|
||||
toolCalls: meta?.toolCalls,
|
||||
reviewText: meta?.reviewText,
|
||||
reviewPath: meta?.reviewPath,
|
||||
reviewResult: meta?.reviewResult,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Extension Entry ────────────────────────────────────────────────────────
|
||||
|
||||
export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
@@ -538,27 +583,9 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
t.status === "in_progress" ? [id] : [],
|
||||
);
|
||||
|
||||
if (inProgressIds.length === 0) {
|
||||
// Nothing was mid-flight — loop either finished cleanly between
|
||||
// the reload landing and this handler running, or was stopped
|
||||
// between tasks. Clean up the stale marker and bail.
|
||||
ctx.ui.notify(
|
||||
"ralpi loop has no in-progress task to resume — marking complete.",
|
||||
"info",
|
||||
);
|
||||
deleteLoopActive(projectDir);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskCount = loopState.taskIds.length;
|
||||
ctx.ui.notify(
|
||||
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
|
||||
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Build the sendProgress wrapper so resumed task messages render the
|
||||
// same expandable tool-call tree as an interactive run.
|
||||
// same expandable tool-call tree as an interactive run. Defined before
|
||||
// the finalize path below so it can report self-healed merges.
|
||||
const sendProgress: SendChatMessage = (
|
||||
content: string,
|
||||
meta?: {
|
||||
@@ -582,6 +609,131 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
});
|
||||
};
|
||||
|
||||
if (inProgressIds.length === 0) {
|
||||
// Nothing was mid-flight — the loop either finished cleanly between
|
||||
// the reload landing and this handler running, or was stopped
|
||||
// between tasks. Either way, committed worktree branches from an
|
||||
// interrupted loop may still be unmerged (e.g. a prior resume
|
||||
// attempt reset tasks to pending before it was itself interrupted).
|
||||
// Finalize those first so committed code lands in the workspace,
|
||||
// persist the state to progress.json, update the PRD file, THEN
|
||||
// clean up the stale marker.
|
||||
try {
|
||||
const config = loadConfig(projectDir);
|
||||
// Clear any half-done merge left by an interrupted
|
||||
// conflict-resolution session (it would block every merge below).
|
||||
abortMerge(projectDir);
|
||||
const allIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
||||
t.status !== "failed" && t.status !== "pending" ? [id] : [],
|
||||
);
|
||||
const fin = finalizeCommittedWorktrees(
|
||||
projectDir,
|
||||
config.paths.stateDir,
|
||||
loopState.prdKey,
|
||||
allIds,
|
||||
);
|
||||
// Persist finalized tasks to progress.json + PRD file so the
|
||||
// state is correct for subsequent /ralpi resume calls.
|
||||
const stateDir = config.paths.stateDir;
|
||||
const progressPath = path.join(projectDir, stateDir, "progress.json");
|
||||
// Batch-update progress.json and PRD file for all finalized tasks
|
||||
if (fin.finalized.length > 0) {
|
||||
const progressRaw = fs.existsSync(progressPath)
|
||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
||||
: null;
|
||||
for (const id of fin.finalized) {
|
||||
sendProgress?.(
|
||||
`✓ ${id} — finalized on resume (committed branch merged into main)`,
|
||||
);
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "completed";
|
||||
tasks[id].completedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
try {
|
||||
const prdPath = loopState.taskFile;
|
||||
if (fs.existsSync(prdPath)) {
|
||||
updateTaskInFile(prdPath, id, "completed");
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (progressRaw) {
|
||||
fs.writeFileSync(
|
||||
progressPath,
|
||||
JSON.stringify(progressRaw, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
// ── Handle conflicted tasks ──
|
||||
// Same logic as resumeLoop: reset to pending so the DAG can
|
||||
// re-schedule them, keep the worktree for in-place re-run.
|
||||
const conflictIds = Object.keys(fin.conflicts);
|
||||
if (conflictIds.length > 0) {
|
||||
const detail = conflictIds
|
||||
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
|
||||
.join("; ");
|
||||
// Batch-reset all conflicted tasks to pending, then write once
|
||||
const progressRaw = fs.existsSync(progressPath)
|
||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
||||
: null;
|
||||
for (const id of conflictIds) {
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "pending";
|
||||
delete tasks[id].startedAt;
|
||||
delete tasks[id].error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const prdPath = loopState.taskFile;
|
||||
if (fs.existsSync(prdPath)) {
|
||||
updateTaskInFile(prdPath, id, "pending");
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (progressRaw) {
|
||||
fs.writeFileSync(
|
||||
progressPath,
|
||||
JSON.stringify(progressRaw, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — the marker is removed either way; the worktrees
|
||||
// stay on disk for a manual /ralpi-resume.
|
||||
}
|
||||
ctx.ui.notify(
|
||||
"ralpi loop has no in-progress task to resume — marking complete.",
|
||||
"info",
|
||||
);
|
||||
deleteLoopActive(projectDir);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskCount = loopState.taskIds.length;
|
||||
ctx.ui.notify(
|
||||
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
|
||||
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Load config from the project directory so model + thinking level
|
||||
// resolve the same way the interactive command handler does.
|
||||
const config = loadConfig(projectDir);
|
||||
@@ -618,35 +770,7 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
"Execute tasks from a task file using DAG-based dependency resolution",
|
||||
handler: async (args: string, ctx: ExtensionContext) => {
|
||||
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
// Wraps pi.sendMessage() for posting status to the chat history.
|
||||
// Uses "ralpi-progress" customType with a "progress" phase so the
|
||||
// renderer omits the label prefix entirely (no [INFO] etc.).
|
||||
// Accepts an optional meta object with toolCalls for the expandable view,
|
||||
// and reviewText/reviewPath/reviewResult for review messages so the expanded
|
||||
// (Ctrl+O) view can render the full review body without truncation.
|
||||
const sendProgress: SendChatMessage = (
|
||||
content: string,
|
||||
meta?: {
|
||||
toolCalls?: Array<{ name: string; label: string }>;
|
||||
reviewText?: string;
|
||||
reviewPath?: string;
|
||||
reviewResult?: ReviewResult;
|
||||
},
|
||||
) => {
|
||||
pi.sendMessage({
|
||||
customType: "ralpi-progress",
|
||||
content,
|
||||
display: true,
|
||||
details: {
|
||||
phase: "progress",
|
||||
toolCalls: meta?.toolCalls,
|
||||
reviewText: meta?.reviewText,
|
||||
reviewPath: meta?.reviewPath,
|
||||
reviewResult: meta?.reviewResult,
|
||||
},
|
||||
});
|
||||
};
|
||||
const sendProgress = makeSendProgress(pi);
|
||||
|
||||
// If no args, show plan. If first token looks like a path (@path, /path, ./path),
|
||||
// route to run so the execution mode prompt fires.
|
||||
@@ -663,50 +787,70 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
);
|
||||
}
|
||||
|
||||
const command = parts[0];
|
||||
switch (command) {
|
||||
case "run":
|
||||
return handleRun(
|
||||
ctx,
|
||||
parts.slice(1),
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
case "plan":
|
||||
pi.sendUserMessage("@task-manager");
|
||||
ctx.ui.notify("Opening Task Manager...", "info");
|
||||
return;
|
||||
case "resume":
|
||||
return handleResume(
|
||||
ctx,
|
||||
parts.slice(1),
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
case "reset":
|
||||
return handleReset(ctx, parts.slice(1));
|
||||
default: {
|
||||
// Auto-discover progress and offer resume
|
||||
const found = findProgressFile(process.cwd());
|
||||
if (found) {
|
||||
ctx.ui.notify(
|
||||
`Unknown command: ${command}\n\nFound existing progress in ${
|
||||
found.path
|
||||
}\nUse /ralpi resume to continue.\n\nAvailable: ${COMMANDS.join(
|
||||
", ",
|
||||
)}`,
|
||||
"warning",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify(
|
||||
`Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Subcommands (run/plan/resume/reset) are handled by the dash commands
|
||||
// below — /ralpi only dispatches no-args → plan and path → run.
|
||||
ctx.ui.notify(
|
||||
`Unknown: ${parts[0]}. Use /ralpi-run, /ralpi-plan, /ralpi-resume, or /ralpi-reset`,
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Dedicated subcommands (dash namespace) ──────────────────────────
|
||||
//
|
||||
// Each subcommand is registered as its own top-level Pi command so the
|
||||
// slash-menu autocompletes it directly (`/ralpi-run`, `/ralpi-resume`, …)
|
||||
// instead of requiring the user to type `/ralpi <subcommand>` and rely on
|
||||
// raw-string dispatch. `/ralpi` above remains as a back-compat dispatcher.
|
||||
pi.registerCommand("ralpi-run", {
|
||||
description: "Run tasks from a task file (DAG-based execution)",
|
||||
handler: async (args: string, ctx: ExtensionContext) => {
|
||||
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||
return handleRun(
|
||||
ctx,
|
||||
parts,
|
||||
makeSendProgress(pi),
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
pi.registerCommand("ralpi-plan", {
|
||||
description: "Open the Task Manager to plan a ralpi run",
|
||||
handler: async (args: string, ctx: ExtensionContext) => {
|
||||
// pi.sendUserMessage() sends with expandPromptTemplates: false, so it
|
||||
// would NOT expand `/task-manager` — and `@task-manager` is an
|
||||
// @-mention, not a template invocation. Load the bundled template,
|
||||
// strip frontmatter, substitute $@ args ourselves, and send the
|
||||
// expanded body directly.
|
||||
const body = loadTaskManagerPrompt(extensionDir, args ?? "");
|
||||
pi.sendUserMessage(body);
|
||||
ctx.ui.notify("Opening Task Manager...", "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("ralpi-resume", {
|
||||
description: "Resume an interrupted ralpi loop from persisted progress",
|
||||
handler: async (args: string, ctx: ExtensionContext) => {
|
||||
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||
return handleResume(
|
||||
ctx,
|
||||
parts,
|
||||
makeSendProgress(pi),
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("ralpi-reset", {
|
||||
description: "Reset ralpi progress for a task file",
|
||||
handler: async (args: string, ctx: ExtensionContext) => {
|
||||
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||
return handleReset(ctx, parts);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -717,7 +861,7 @@ async function handlePlan(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||
const project = parseTaskFile(taskFile);
|
||||
if (!Array.isArray(project.tasks)) {
|
||||
throw new Error(
|
||||
@@ -741,11 +885,11 @@ async function handleRun(
|
||||
parentModel?: unknown,
|
||||
parentThinkingLevel?: unknown,
|
||||
): Promise<void> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||
|
||||
// If targeting a specific task file and there's existing progress for it,
|
||||
// auto-resume instead of starting fresh
|
||||
const existingProgress = findProgressFile(process.cwd(), taskFile);
|
||||
const existingProgress = findProgressFile(ctx.cwd, taskFile);
|
||||
if (existingProgress) {
|
||||
return handleResume(
|
||||
ctx,
|
||||
@@ -757,7 +901,7 @@ async function handleRun(
|
||||
}
|
||||
|
||||
// No existing progress for this task — check for any progress at all
|
||||
const found = findProgressFile(process.cwd());
|
||||
const found = findProgressFile(ctx.cwd);
|
||||
if (found && !args[0]) {
|
||||
// Offer to resume instead of starting fresh
|
||||
const shouldResume = await ctx.ui.select(
|
||||
@@ -776,9 +920,7 @@ async function handleRun(
|
||||
}
|
||||
}
|
||||
|
||||
const projectDir = found
|
||||
? path.dirname(path.dirname(found.path))
|
||||
: process.cwd();
|
||||
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||
|
||||
const project = parseTaskFile(taskFile);
|
||||
const config = loadConfig(projectDir);
|
||||
@@ -880,11 +1022,104 @@ async function resumeLoop(
|
||||
|
||||
progress.setPaused(false);
|
||||
|
||||
// Any task left `in_progress` died with the previous session (ralpi runs
|
||||
// agents in-process). Reset them to `pending` so the DAG re-schedules
|
||||
// them cleanly. Without this they'd still be re-run (they're not in the
|
||||
// completed set), but the progress.json would carry a stale in_progress
|
||||
// state during the rebuild window.
|
||||
// ── Self-heal: finalize tasks that finished but were never merged ──
|
||||
//
|
||||
// A review-gated task whose agent committed + reviewed successfully still
|
||||
// needs a final merge into main + worktree removal to be "done". If the
|
||||
// loop was interrupted between that commit and the merge, the task is left
|
||||
// with a committed worktree branch. Resuming without finalizing would
|
||||
// wastefully re-run finished work — or worse, strand the committed code in
|
||||
// `.ralpi/worktrees/` forever.
|
||||
//
|
||||
// finalizeCommittedWorktrees runs over EVERY non-failed task, not just
|
||||
// `in_progress` ones: a prior interrupted resume can reset tasks to
|
||||
// `pending` while their worktree branch still holds committed work that
|
||||
// was never merged. Only scanning in_progress tasks would silently leave
|
||||
// that code out of the workspace on every resume.
|
||||
//
|
||||
// `pending` tasks (never started) are excluded — they never had worktrees
|
||||
// created, so finalize always puts them in `rerun`, which is wasted work.
|
||||
// Failed tasks keep their worktrees for inspection/re-run and are also
|
||||
// deliberately excluded.
|
||||
const prdKeyForFinalize = progress.getKey();
|
||||
// Clear any half-done merge left in the main repo by an interrupted
|
||||
// conflict-resolution session — it would block every merge below
|
||||
// (`git merge` refuses while a merge is already in progress). No-op when
|
||||
// the repo isn't mid-merge.
|
||||
abortMerge(projectDir);
|
||||
const finalizeCandidateIds = Object.entries(
|
||||
progress.getState().tasks,
|
||||
).flatMap(([id, t]) =>
|
||||
t.status !== "failed" && t.status !== "pending" ? [id] : [],
|
||||
);
|
||||
if (finalizeCandidateIds.length > 0) {
|
||||
const fin = finalizeCommittedWorktrees(
|
||||
projectDir,
|
||||
config.paths.stateDir,
|
||||
prdKeyForFinalize,
|
||||
finalizeCandidateIds,
|
||||
);
|
||||
for (const id of fin.finalized) {
|
||||
progress.markCompleted(id, 0);
|
||||
try {
|
||||
updateTaskInFile(taskFile, id, "completed");
|
||||
} catch {
|
||||
// Best-effort — progress.json is the source of truth for scheduling.
|
||||
}
|
||||
sendChatMessage?.(
|
||||
`✓ ${id} — finalized on resume (committed branch merged into main)`,
|
||||
);
|
||||
}
|
||||
// ── Handle conflicted tasks ──
|
||||
//
|
||||
// Tasks whose committed branch could not be auto-merged (git conflicts)
|
||||
// must be reset to `pending` so the DAG re-schedules them. The worktree
|
||||
// is preserved — the agent re-runs in-place in the existing worktree via
|
||||
// createWorktree's reuse logic. If the agent's re-run changes make the
|
||||
// merge succeed on the next attempt, the loop continues normally. If the
|
||||
// merge fails again, `executeBatch`'s batch-level conflict resolution
|
||||
// (`resolveConflictsSession`) handles the conflict markers properly.
|
||||
const conflictIds = Object.keys(fin.conflicts);
|
||||
if (conflictIds.length > 0) {
|
||||
const detail = conflictIds
|
||||
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
|
||||
.join("; ");
|
||||
// Batch-reset all conflicted tasks to pending, then save once.
|
||||
// Directly mutate the progress state (there's no markPending method
|
||||
// on ProgressTracker — markFailed would leave it as 'failed' which the
|
||||
// DAG excludes). The worktree is preserved so createWorktree reuses
|
||||
// it and the agent re-runs in-place.
|
||||
const tasks = progress.getState().tasks;
|
||||
for (const id of conflictIds) {
|
||||
if (tasks[id]) {
|
||||
tasks[id].status = "pending";
|
||||
delete tasks[id].startedAt;
|
||||
delete tasks[id].error;
|
||||
}
|
||||
try {
|
||||
updateTaskInFile(taskFile, id, "pending");
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
progress.save();
|
||||
sendChatMessage?.(
|
||||
`⚠ ${conflictIds.join(
|
||||
", ",
|
||||
)} — merge conflict on resume-finalize; reset to pending for re-execution (${detail})`,
|
||||
);
|
||||
ctx.ui.notify(
|
||||
`Reset ${conflictIds.length} conflicted task(s) to pending for re-execution`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Any task still `in_progress` (those NOT finalized above) died with the
|
||||
// previous session (ralpi runs agents in-process). Reset them to `pending`
|
||||
// so the DAG re-schedules them cleanly. Without this they'd still be
|
||||
// re-run (they're not in the completed set), but the progress.json would
|
||||
// carry a stale in_progress state during the rebuild window.
|
||||
const resetIds = progress.resetInProgressToPending();
|
||||
if (resetIds.length > 0) {
|
||||
// Keep the source-file checkboxes in sync so a later parse sees these
|
||||
@@ -970,8 +1205,8 @@ async function handleResume(
|
||||
let prdKey: string | undefined;
|
||||
|
||||
if (args[0]) {
|
||||
taskFile = resolveTaskArg(args[0], process.cwd());
|
||||
const found = findProgressFile(process.cwd(), taskFile);
|
||||
taskFile = resolveTaskArg(args[0], ctx.cwd);
|
||||
const found = findProgressFile(ctx.cwd, taskFile);
|
||||
if (!found) {
|
||||
ctx.ui.notify(
|
||||
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
||||
@@ -982,7 +1217,7 @@ async function handleResume(
|
||||
projectDir = path.dirname(path.dirname(found.path));
|
||||
prdKey = found.prdKey;
|
||||
} else {
|
||||
const found = findProgressFile(process.cwd());
|
||||
const found = findProgressFile(ctx.cwd);
|
||||
if (!found) {
|
||||
ctx.ui.notify(
|
||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||
@@ -994,7 +1229,11 @@ async function handleResume(
|
||||
|
||||
// When no specific task file is given, let the user select which loop
|
||||
// to resume from multiple PRDs (sorted by most recent first).
|
||||
const selected = await selectPRDToResume(ctx, found);
|
||||
const selected = await selectPRD(
|
||||
ctx,
|
||||
found,
|
||||
"Multiple loops found. Which to resume?",
|
||||
);
|
||||
if (!selected) {
|
||||
ctx.ui.notify("Resume cancelled.", "info");
|
||||
return;
|
||||
@@ -1003,6 +1242,30 @@ async function handleResume(
|
||||
prdKey = selected.prdKey;
|
||||
}
|
||||
|
||||
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
||||
// persisted when the loop started, so an interrupted loop resumes
|
||||
// non-interactively — matching the auto-resume-on-reload path. Only fall
|
||||
// back to interactive prompts when no snapshot is present.
|
||||
const snapshot = readLoopActive(projectDir);
|
||||
const loopOpts = (() => {
|
||||
if (
|
||||
snapshot &&
|
||||
snapshot.prdKey === prdKey &&
|
||||
snapshot.mode &&
|
||||
snapshot.autoCommit !== undefined &&
|
||||
snapshot.autoReview !== undefined &&
|
||||
snapshot.saveReviews !== undefined
|
||||
) {
|
||||
return {
|
||||
mode: snapshot.mode as ExecutionMode,
|
||||
autoCommit: snapshot.autoCommit,
|
||||
autoReview: snapshot.autoReview,
|
||||
saveReviews: snapshot.saveReviews,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
await resumeLoop(
|
||||
ctx,
|
||||
taskFile,
|
||||
@@ -1011,6 +1274,7 @@ async function handleResume(
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
loopOpts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1023,16 +1287,19 @@ async function handleReset(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
let sourcePath: string;
|
||||
let prdKey: string | undefined;
|
||||
let progress: ProgressTracker;
|
||||
|
||||
if (args[0]) {
|
||||
const taskFile = resolveTaskArg(args[0], process.cwd());
|
||||
const found = findProgressFile(process.cwd(), taskFile);
|
||||
const projectDir = found
|
||||
? path.dirname(path.dirname(found.path))
|
||||
: process.cwd();
|
||||
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
|
||||
progress.reset();
|
||||
const taskFile = resolveTaskArg(args[0], ctx.cwd);
|
||||
const found = findProgressFile(ctx.cwd, taskFile);
|
||||
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||
sourcePath = taskFile;
|
||||
prdKey = found?.prdKey;
|
||||
progress = new ProgressTracker(projectDir, taskFile, prdKey);
|
||||
} else {
|
||||
const found = findProgressFile(process.cwd());
|
||||
const found = findProgressFile(ctx.cwd);
|
||||
if (!found) {
|
||||
ctx.ui.notify(
|
||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||
@@ -1041,13 +1308,53 @@ async function handleReset(
|
||||
return;
|
||||
}
|
||||
const projectDir = path.dirname(path.dirname(found.path));
|
||||
// Use the most recently updated PRD (first in sorted order)
|
||||
const prds = listPRDsSorted(found.state);
|
||||
const sourcePath =
|
||||
prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath;
|
||||
const progress = new ProgressTracker(projectDir, sourcePath);
|
||||
progress.reset();
|
||||
|
||||
// Multiple loops may have progress — let the user select which one to
|
||||
// reset (sorted by most recent first), same as resume.
|
||||
const selected = await selectPRD(
|
||||
ctx,
|
||||
found,
|
||||
"Multiple loops found. Which to reset?",
|
||||
);
|
||||
if (!selected) {
|
||||
ctx.ui.notify("Reset cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
sourcePath = selected.sourcePath;
|
||||
prdKey = selected.prdKey;
|
||||
progress = new ProgressTracker(projectDir, sourcePath, prdKey);
|
||||
}
|
||||
|
||||
// Ask whether to also clear the progress markers (checkboxes/status) in the
|
||||
// source PRD/README file itself, not just .ralpi/progress.json.
|
||||
const taskIds = Object.keys(progress.getState().tasks);
|
||||
if (taskIds.length > 0) {
|
||||
const choice = await ctx.ui.select(
|
||||
`Also reset progress markers in the source file (${path.basename(
|
||||
sourcePath,
|
||||
)})?`,
|
||||
[
|
||||
"Yes — clear checkboxes/status markers in the source PRD/README",
|
||||
"No — only reset .ralpi/progress.json",
|
||||
],
|
||||
);
|
||||
if (choice === undefined) {
|
||||
ctx.ui.notify("Reset cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
if (choice.startsWith("Yes")) {
|
||||
progress.reset();
|
||||
for (const id of taskIds) {
|
||||
updateTaskInFile(sourcePath, id, "pending");
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`Progress reset — cleared ${taskIds.length} task marker(s) in ${path.basename(sourcePath)}.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
progress.reset();
|
||||
ctx.ui.notify("Progress reset. All task statuses cleared.", "info");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mikefreno/ralpi",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
---
|
||||
description: Executes individual tasks from ralpi task files using DAG-based dependency resolution, with progress tracking and reflection support
|
||||
description: Execute tasks from ralpi task files / PRDs using DAG-based dependency resolution, with persistent progress tracking and reflection support
|
||||
---
|
||||
|
||||
# ralpi-task
|
||||
|
||||
Execute a single task from a ralpi task file.
|
||||
Execute tasks from a ralpi task file (checkbox, Fio, phased, or YAML) using DAG-based dependency resolution, with persistent progress tracking and reflection support.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks to execute a specific task from a task file
|
||||
- User provides a task ID and wants to run it
|
||||
- User wants to run the next task in sequence
|
||||
- User asks to execute a task file, PRD, or task list (e.g. "run the tasks", "execute the plan")
|
||||
- User wants to run a full ralpi loop from a task file in the project
|
||||
- User wants to resume an interrupted or paused ralpi run
|
||||
- User wants to plan new tasks with the task-manager prompt
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ralpi run [task-file] # Run all tasks
|
||||
/ralpi next [task-file] # Run next batch
|
||||
/ralpi status [task-file] # Check progress
|
||||
/ralpi [task-file] # No args → show plan; path arg → run all tasks
|
||||
/ralpi-run [task-file] # Run all tasks (auto-resumes if progress exists)
|
||||
/ralpi-resume [task-file] # Resume paused/interrupted execution
|
||||
/ralpi-plan [prompt] # Open the Task Manager to plan tasks
|
||||
```
|
||||
|
||||
Note: ralpi runs whole task plans — there is no single-task or next-batch subcommand. To execute only part of a plan, edit the task file and remove/adjust the tasks first.
|
||||
|
||||
## Task File Location
|
||||
|
||||
Default: `README.md` in current directory. Can be overridden with explicit path.
|
||||
Default: `README.md` in the current directory. Can be overridden with an explicit path (`@path`, `./path`, `*.md`, `*.yaml`, `*.yml`).
|
||||
|
||||
## Reflection Format
|
||||
|
||||
After completing a task, include:
|
||||
After completing a task, the task agent ends its response with a reflection block, which the extension parses and passes to downstream tasks:
|
||||
|
||||
```
|
||||
## REFLECTION
|
||||
SUMMARY: [what was done]
|
||||
FILES: [files changed]
|
||||
SUMMARY: [1-2 sentence description of what was accomplished]
|
||||
FILES: [comma-separated list of files created or modified]
|
||||
LEARNINGS:
|
||||
- [key learning]
|
||||
BLOCKERS: [issues or 'none']
|
||||
- [key decision, pattern, or architectural choice]
|
||||
- [important API or interface details]
|
||||
- [anything downstream tasks need to know]
|
||||
BLOCKERS: [any unresolved issues, or 'none']
|
||||
```
|
||||
|
||||
108
src/executor.ts
108
src/executor.ts
@@ -23,6 +23,7 @@ import { extractReflection } from "./reflection";
|
||||
import {
|
||||
extractReview,
|
||||
saveReviewToFile as saveReviewJson,
|
||||
loadReview as loadReviewJson,
|
||||
verdictGlyph,
|
||||
verdictSummary,
|
||||
} from "./review";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
abortMerge,
|
||||
hasMergeConflicts,
|
||||
completeMerge,
|
||||
worktreeHasPreservableWork,
|
||||
type WorktreeHandle,
|
||||
type MergeResult,
|
||||
} from "./worktree";
|
||||
@@ -813,6 +815,27 @@ async function executeTask(
|
||||
? captureGitHead(worktreeDir)
|
||||
: undefined;
|
||||
|
||||
// Load a prior review from disk when resuming an interrupted loop.
|
||||
// If the previous run's review rejected the task (verdict 'fail') and the
|
||||
// re-execution was lost to a crash/connection error, the findings would
|
||||
// otherwise be orphaned. Injecting them here gives the fresh run the
|
||||
// reviewer's feedback so it doesn't reintroduce the same blockers.
|
||||
let priorReview: ReviewResult | undefined;
|
||||
if (config.execution.autoReview) {
|
||||
const loaded = loadReviewJson(
|
||||
projectDir,
|
||||
config.paths.reviewsDir,
|
||||
task.id,
|
||||
progress.getKey(),
|
||||
);
|
||||
if (loaded && loaded.verdict === "fail") {
|
||||
priorReview = loaded;
|
||||
sendChatMessage?.(
|
||||
`↻ ${task.id} · ${task.title} — resuming with prior review feedback (${loaded.findings.length} findings)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the task
|
||||
const result = await runTask(
|
||||
task,
|
||||
@@ -825,6 +848,7 @@ async function executeTask(
|
||||
parallelState,
|
||||
currentModel,
|
||||
batchRender,
|
||||
priorReview,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
@@ -1014,24 +1038,42 @@ async function executeTask(
|
||||
`↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`,
|
||||
);
|
||||
|
||||
// Re-execute the task with review feedback injected.
|
||||
const fixResult = await runTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
depReflections,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
worktreeDir,
|
||||
parallelState,
|
||||
currentModel,
|
||||
batchRender,
|
||||
review ?? undefined,
|
||||
);
|
||||
// Re-execute the task with review feedback injected, cycling
|
||||
// through failover models on connection errors so a flaky
|
||||
// provider doesn't waste the review-fix attempt.
|
||||
const fixModels = buildFailoverModels(currentModel, roundRobin);
|
||||
let fixResult: Awaited<ReturnType<typeof runTask>> | undefined;
|
||||
for (
|
||||
let fixAttempt = 0;
|
||||
fixAttempt < fixModels.length;
|
||||
fixAttempt++
|
||||
) {
|
||||
const fixModel = fixModels[fixAttempt];
|
||||
fixResult = await runTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
depReflections,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
worktreeDir,
|
||||
parallelState,
|
||||
fixModel,
|
||||
batchRender,
|
||||
review ?? undefined,
|
||||
);
|
||||
if (fixResult.success) break;
|
||||
// Connection/error failover — try the next model.
|
||||
if (fixAttempt < fixModels.length - 1) {
|
||||
sendChatMessage?.(
|
||||
`~ re-execution for ${task.id} · ${task.title} — cycling to model ${fixAttempt + 2}/${fixModels.length} (previous: ${fixResult.error})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixResult.success) {
|
||||
if (!fixResult || !fixResult.success) {
|
||||
sendChatMessage?.(
|
||||
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult.error}`,
|
||||
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult?.error}`,
|
||||
);
|
||||
break; // proceed with what we have
|
||||
}
|
||||
@@ -1207,7 +1249,7 @@ async function executeTask(
|
||||
}`,
|
||||
"error",
|
||||
);
|
||||
if (wt) removeWorktree(projectDir, wt);
|
||||
cleanupFailedWorktree(projectDir, wt, task, sendChatMessage);
|
||||
roundRobin?.release(task.id);
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -1223,7 +1265,7 @@ async function executeTask(
|
||||
}
|
||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
||||
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
||||
if (wt) removeWorktree(projectDir, wt);
|
||||
cleanupFailedWorktree(projectDir, wt, task, sendChatMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1239,11 +1281,37 @@ async function executeTask(
|
||||
`Task ${task.id} failed: all configured models exhausted`,
|
||||
"error",
|
||||
);
|
||||
if (wt) removeWorktree(projectDir, wt);
|
||||
cleanupFailedWorktree(projectDir, wt, task, sendChatMessage);
|
||||
}
|
||||
|
||||
// ─── Save Reflection to File ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove a task worktree after a failure UNLESS it still holds recoverable
|
||||
* work (commits ahead of main, or uncommitted changes).
|
||||
*
|
||||
* `removeWorktree` force-deletes the worktree's branch, which makes any
|
||||
* commits the agent made before failing/timing out unreachable — real code
|
||||
* loss. A preserved worktree is instead picked up on the next resume:
|
||||
* resume-finalize merges committed work into main, or the task re-runs in
|
||||
* place and the agent continues from where it stopped.
|
||||
*/
|
||||
function cleanupFailedWorktree(
|
||||
projectDir: string,
|
||||
wt: WorktreeHandle | null,
|
||||
task: Task,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
): void {
|
||||
if (!wt) return;
|
||||
if (worktreeHasPreservableWork(projectDir, wt)) {
|
||||
sendChatMessage?.(
|
||||
`~ ${task.id} · ${task.title} — task failed but worktree preserved (${wt.branch}); committed work will be merged on resume`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
removeWorktree(projectDir, wt);
|
||||
}
|
||||
|
||||
function saveReflectionToFile(
|
||||
sourceDir: string,
|
||||
config: RalpiConfig,
|
||||
@@ -1366,7 +1434,7 @@ async function runFollowUpSession(
|
||||
undefined,
|
||||
model,
|
||||
config.thinkingLevel,
|
||||
true, // noSkills — follow-up sessions don't need the skills catalog
|
||||
false, // noSkills=false — follow-up sessions load skills too
|
||||
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
||||
);
|
||||
|
||||
|
||||
@@ -142,6 +142,33 @@ export class ProgressTracker {
|
||||
|
||||
/** Save current state to disk */
|
||||
save(): void {
|
||||
// Merge into the freshest on-disk state instead of writing the
|
||||
// construction-time snapshot verbatim. Each ProgressTracker instance
|
||||
// (one per PRD loop) snapshots the WHOLE state at construction; when
|
||||
// two loops run concurrently in one project, saving a stale snapshot
|
||||
// would silently revert the OTHER loop's task status changes — tasks
|
||||
// get wrongly written back to "pending" while their worktrees carry
|
||||
// real work, stranding it on the next resume.
|
||||
let disk: ProgressState | null = null;
|
||||
try {
|
||||
if (fs.existsSync(this.statePath)) {
|
||||
const raw = fs.readFileSync(this.statePath, "utf-8");
|
||||
disk = JSON.parse(raw) as ProgressState;
|
||||
}
|
||||
} catch {
|
||||
disk = null;
|
||||
}
|
||||
if (disk && disk.prds) {
|
||||
// Keep THIS tracker's in-memory PRD (its own tasks are the source
|
||||
// of truth — all status mutations happened on it), but adopt the
|
||||
// on-disk entries for OTHER PRDs instead of writing the stale
|
||||
// construction-time snapshot over them.
|
||||
const mine = this.getPRD();
|
||||
this.state = disk;
|
||||
this.state.prds ??= {};
|
||||
this.state.prds[this.prdKey] = mine;
|
||||
}
|
||||
|
||||
const prd = this.getPRD();
|
||||
prd.lastUpdatedAt = new Date().toISOString();
|
||||
// Sync legacy flat fields with current PRD for backward compat
|
||||
|
||||
104
src/task-manager-prompt.ts
Normal file
104
src/task-manager-prompt.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { stripFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const TEMPLATE_REL = path.join("prompts", "task-manager.md");
|
||||
|
||||
/**
|
||||
* Parse command arguments respecting quoted strings (bash-style).
|
||||
* Ported from pi's core/prompt-templates.js so the task-manager template
|
||||
* receives the same arg-splitting a real `/task-manager` invocation would.
|
||||
*/
|
||||
function parseCommandArgs(argsString: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
let inQuote: string | null = null;
|
||||
for (let i = 0; i < argsString.length; i++) {
|
||||
const char = argsString[i];
|
||||
if (inQuote) {
|
||||
if (char === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
inQuote = char;
|
||||
} else if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = "";
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) args.push(current);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute argument placeholders in template content.
|
||||
* Faithful port of pi's substituteArgs (core/prompt-templates.js):
|
||||
* - $1, $2, ... positional args
|
||||
* - $@ / $ARGUMENTS all args joined
|
||||
* - ${N:-default} positional N with default when missing/empty
|
||||
* - ${@:-default} all args with default when empty
|
||||
* - ${@:N} / ${@:N:L} bash-style slicing
|
||||
*
|
||||
* Replacement runs once over the template only; argument/default values
|
||||
* containing patterns like $1 or $@ are NOT recursively substituted.
|
||||
*/
|
||||
function substituteArgs(content: string, args: string[]): string {
|
||||
const allArgs = args.join(" ");
|
||||
return content.replace(
|
||||
/\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g,
|
||||
(_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => {
|
||||
if (defaultTarget) {
|
||||
const value =
|
||||
defaultTarget === "@" || defaultTarget === "ARGUMENTS"
|
||||
? allArgs
|
||||
: args[parseInt(defaultTarget, 10) - 1];
|
||||
return value ? value : defaultValue;
|
||||
}
|
||||
if (sliceStart) {
|
||||
let start = parseInt(sliceStart, 10) - 1; // 1-indexed → 0-indexed
|
||||
if (start < 0) start = 0;
|
||||
if (sliceLength) {
|
||||
const length = parseInt(sliceLength, 10);
|
||||
return args.slice(start, start + length).join(" ");
|
||||
}
|
||||
return args.slice(start).join(" ");
|
||||
}
|
||||
if (simple === "ARGUMENTS" || simple === "@") {
|
||||
return allArgs;
|
||||
}
|
||||
const index = parseInt(simple, 10) - 1;
|
||||
return args[index] ?? "";
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and expand the task-manager prompt template bundled with the extension.
|
||||
*
|
||||
* `pi.sendUserMessage()` sends with `expandPromptTemplates: false`, so it will
|
||||
* NOT expand a `/task-manager` invocation — and `@task-manager` is an
|
||||
* @-mention, not a template invocation anyway. We therefore read the
|
||||
* template ourselves, strip its frontmatter, substitute args ($@ etc.), and
|
||||
* return the fully-expanded prompt body ready to send as a user message.
|
||||
*
|
||||
* @param extensionDir Absolute path to the extension root (where index.ts
|
||||
* lives), used to locate `prompts/task-manager.md`.
|
||||
* @param argsString Raw argument string from the slash command (may be "").
|
||||
* @throws if the template file is missing or unreadable.
|
||||
*/
|
||||
export function loadTaskManagerPrompt(
|
||||
extensionDir: string,
|
||||
argsString: string,
|
||||
): string {
|
||||
const templatePath = path.join(extensionDir, TEMPLATE_REL);
|
||||
const raw = fs.readFileSync(templatePath, "utf-8");
|
||||
const body = stripFrontmatter(raw);
|
||||
const args = parseCommandArgs(argsString);
|
||||
return substituteArgs(body, args).trim();
|
||||
}
|
||||
1261
src/utils.ts
1261
src/utils.ts
File diff suppressed because it is too large
Load Diff
191
src/worktree.ts
191
src/worktree.ts
@@ -1,5 +1,10 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { ensureDir } from "./utils";
|
||||
import {
|
||||
ensureDir,
|
||||
hasUncommittedChanges,
|
||||
hasTrackedUncommittedChanges,
|
||||
} from "./utils";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -82,6 +87,28 @@ export function getCurrentBranch(dir: string): string | null {
|
||||
return git("rev-parse --abbrev-ref HEAD", dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a directory path, resolving symlinks.
|
||||
*
|
||||
* `git worktree list --porcelain` emits REAL paths (symlinks resolved,
|
||||
* e.g. `/private/tmp/...` for `/tmp/...` on macOS), while `path.join` on a
|
||||
* caller-supplied path keeps the literal spelling. Comparing the two
|
||||
* verbatim silently fails — resume then can't see an existing worktree,
|
||||
* `createWorktree` falls through to a fresh `worktree add` that fails
|
||||
* because the directory already exists, returns null, and the task agent
|
||||
* ends up running in the MAIN repo with no worktree merge at all.
|
||||
*
|
||||
* All worktree path computation and porcelain comparisons go through this
|
||||
* so literal vs real paths can never diverge.
|
||||
*/
|
||||
function canonicalDir(dir: string): string {
|
||||
try {
|
||||
return fs.realpathSync(dir);
|
||||
} catch {
|
||||
return path.resolve(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Worktree Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -151,6 +178,9 @@ export function createWorktree(
|
||||
baseRef?: string,
|
||||
taskTitle?: string,
|
||||
): WorktreeHandle | null {
|
||||
// Canonicalize FIRST: every path below (worktree dir, porcelain
|
||||
// comparisons, branch refs) must share one spelling of the repo path.
|
||||
mainDir = canonicalDir(mainDir);
|
||||
if (!isGitRepo(mainDir)) return null;
|
||||
|
||||
const safeId = safeBranchSuffix(taskId);
|
||||
@@ -171,6 +201,23 @@ export function createWorktree(
|
||||
if (existing && existing.includes(`worktree ${wtDir}`)) {
|
||||
// The worktree is registered — sanity-check it's a valid checkout.
|
||||
if (getGitHead(wtDir)) {
|
||||
// Return the branch the worktree is ACTUALLY checked out on, NOT the
|
||||
// slug recomputed from the (possibly changed) task title. Mismatch
|
||||
// happens routinely on resume: the task agent may have created its
|
||||
// own feature branch (e.g. `proctored-exam-delivery-mode-10-exam-...`)
|
||||
// once it saw the convention in the git log, the title may have been
|
||||
// edited between runs, or an older ralpi version used a different
|
||||
// naming scheme. Returning the slug here makes `git merge <slug>`
|
||||
// fail with "not something we can merge" because no such ref exists —
|
||||
// exactly the spurious merge-conflict we see on resumes.
|
||||
const actual = getCurrentBranch(wtDir);
|
||||
if (actual && actual !== "HEAD" && actual !== "detached") {
|
||||
return { dir: wtDir, branch: actual, mainDir };
|
||||
}
|
||||
// Detached-HEAD worktree (e.g. left by a prior `--detach` fallback).
|
||||
// The slug ref doesn't exist as a branch — create one matching the
|
||||
// slug from the worktree's current HEAD so the merge step resolves.
|
||||
git(`branch "${branch}" HEAD`, mainDir);
|
||||
return { dir: wtDir, branch, mainDir };
|
||||
}
|
||||
// Registered but broken (dir gone / checkout corrupt) — drop its
|
||||
@@ -339,7 +386,7 @@ export function cleanupStaleWorktrees(
|
||||
// When a prdKey is given, narrow to that PRD's subdir so concurrent
|
||||
// loops (other PRDs) are not disturbed.
|
||||
const managedRoot = path.resolve(
|
||||
mainDir,
|
||||
canonicalDir(mainDir),
|
||||
stateDir,
|
||||
"worktrees",
|
||||
...(prdKey ? [prdKey] : []),
|
||||
@@ -375,3 +422,143 @@ export function cleanupStaleWorktrees(
|
||||
git("worktree prune", mainDir);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Result of attempting to finalize a single in-progress worktree on resume. */
|
||||
export interface FinalizeResult {
|
||||
/** Task IDs whose committed branch was merged into main and cleaned up. */
|
||||
finalized: string[];
|
||||
/** Task IDs left to re-run (no worktree, dirty tree, nothing committed,
|
||||
* or merge conflict — work is preserved for re-execution). */
|
||||
rerun: string[];
|
||||
/** Task IDs that hit a merge conflict; their committed branch + worktree
|
||||
* are left intact for manual resolution. Excluded from `rerun` so the
|
||||
* scheduler does not blindly re-execute conflicting work. */
|
||||
conflicts: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize worktrees that already hold committed, clean work that was never
|
||||
* merged into main (typically because the loop was interrupted between the
|
||||
* task commit and the merge/finalize step).
|
||||
*
|
||||
* For each task ID:
|
||||
* - If no worktree exists / is registered → re-run (fresh worktree later).
|
||||
* - If the worktree has uncommitted edits to TRACKED files (e.g. an
|
||||
* interrupted agent mid-edit) → re-run, preserving the worktree so
|
||||
* `createWorktree` reuses it and the agent continues where it left off.
|
||||
* Untracked files are ignored here — they never block a merge, and a
|
||||
* worktree whose task work is fully committed is "done" even if it
|
||||
* carries stray untracked files. Counting `??` entries would strand the
|
||||
* committed branch in `.ralpi/worktrees/` forever on every resume.
|
||||
* - If the worktree has no commits ahead of main → re-run.
|
||||
* - If the worktree has ≥1 commit ahead of main → merge the branch into
|
||||
* main (`--no-ff`) and report finalized. Fully clean worktrees are then
|
||||
* removed; worktrees that also carry untracked files are kept so that
|
||||
* (possibly meaningful) uncommitted files aren't destroyed — the next
|
||||
* fresh-loop sweep cleans them up. On merge conflict the merge is
|
||||
* aborted (main left clean), the worktree is preserved, and the task is
|
||||
* reported in `conflicts`.
|
||||
*
|
||||
* This is the self-healing path for an interrupted review-gated loop:
|
||||
* tasks that finished (commit + review already saved) but never got their
|
||||
* merge are completed here, so `/ralpi-resume` does not wastefully re-run
|
||||
* finished work.
|
||||
*/
|
||||
export function finalizeCommittedWorktrees(
|
||||
mainDir: string,
|
||||
stateDir: string,
|
||||
prdKey: string,
|
||||
taskIds: string[],
|
||||
): FinalizeResult {
|
||||
mainDir = canonicalDir(mainDir);
|
||||
const result: FinalizeResult = { finalized: [], rerun: [], conflicts: {} };
|
||||
|
||||
const mainHead = getGitHead(mainDir);
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId);
|
||||
|
||||
// No worktree directory on disk → nothing to finalize.
|
||||
if (!fs.existsSync(wtDir)) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Confirm the worktree is actually registered with git (not a leftover
|
||||
// dir from a half-cleaned-up run). If registered but broken, drop its
|
||||
// metadata so a fresh worktree can be created on re-run.
|
||||
const list = git("worktree list --porcelain", mainDir) ?? "";
|
||||
if (!list.includes(`worktree ${wtDir}`)) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Broken checkout → re-run (createWorktree will recreate it).
|
||||
const branch = getCurrentBranch(wtDir);
|
||||
if (!branch || branch === "HEAD" || branch === "detached") {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Uncommitted edits to TRACKED files (an interrupted agent mid-edit) →
|
||||
// re-run, keeping the worktree so the agent resumes in place. Untracked
|
||||
// files alone do NOT count as dirty here (see doc comment above).
|
||||
if (hasTrackedUncommittedChanges(wtDir)) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No commits ahead of main → nothing to merge.
|
||||
const aheadStr =
|
||||
mainHead !== null
|
||||
? git(`rev-list --count ${mainHead}..HEAD`, wtDir)
|
||||
: null;
|
||||
const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0;
|
||||
if (Number.isNaN(ahead) || ahead <= 0) {
|
||||
result.rerun.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Committed + no tracked edits → finalize. mergeWorktree aborts on
|
||||
// conflict, leaving main's working tree clean.
|
||||
const merge = mergeWorktree(mainDir, branch);
|
||||
if (merge.success) {
|
||||
// Remove the worktree only when it's fully clean. If it still carries
|
||||
// untracked files, keep it so that uncommitted work isn't destroyed
|
||||
// (the branch is merged; the leftover worktree is swept by the next
|
||||
// fresh-loop cleanup).
|
||||
if (!hasUncommittedChanges(wtDir)) {
|
||||
removeWorktree(mainDir, { dir: wtDir, branch, mainDir });
|
||||
}
|
||||
result.finalized.push(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Conflict — preserve the worktree for manual resolution and report.
|
||||
result.conflicts[taskId] = merge.conflicts;
|
||||
}
|
||||
|
||||
git("worktree prune", mainDir);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a worktree still holds work worth preserving (committed commits
|
||||
* ahead of main, or uncommitted changes). Used by the task-failure path so a
|
||||
* failed/timeout agent's partial output isn't force-deleted with the
|
||||
* worktree.
|
||||
*/
|
||||
export function worktreeHasPreservableWork(
|
||||
mainDir: string,
|
||||
wt: WorktreeHandle,
|
||||
): boolean {
|
||||
mainDir = canonicalDir(mainDir);
|
||||
// Any uncommitted changes (tracked edits or untracked files) count — the
|
||||
// agent may have been mid-write when it failed.
|
||||
if (hasUncommittedChanges(wt.dir)) return true;
|
||||
const mainHead = getGitHead(mainDir);
|
||||
if (!mainHead) return true;
|
||||
const aheadStr = git(`rev-list --count ${mainHead}..${wt.branch}`, mainDir);
|
||||
const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0;
|
||||
return !Number.isNaN(ahead) && ahead > 0;
|
||||
}
|
||||
|
||||
82
tests/progress-multiprd.test.ts
Normal file
82
tests/progress-multiprd.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ProgressTracker } from "../src/progress";
|
||||
|
||||
/**
|
||||
* Regression test: two concurrent loops (different PRDs) each run their own
|
||||
* ProgressTracker. Each instance snapshots the whole state at construction;
|
||||
* a save() that writes that stale snapshot verbatim would revert the OTHER
|
||||
* loop's task status changes — tasks wrongly back to "pending" while their
|
||||
* worktrees carry real work, stranding it on the next resume.
|
||||
*/
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-prog-test-"));
|
||||
});
|
||||
|
||||
function prdA(projectDir: string): ProgressTracker {
|
||||
return new ProgressTracker(
|
||||
projectDir,
|
||||
path.join(projectDir, "tasks/a/README.md"),
|
||||
);
|
||||
}
|
||||
function prdB(projectDir: string): ProgressTracker {
|
||||
return new ProgressTracker(
|
||||
projectDir,
|
||||
path.join(projectDir, "tasks/b/README.md"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the on-disk progress state; the file is written by the tracker, so
|
||||
* a parse failure is a test bug worth surfacing. */
|
||||
function readState(): Record<string, any> {
|
||||
const raw = fs.readFileSync(
|
||||
path.join(root, ".ralpi", "progress.json"),
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, any>;
|
||||
} catch {
|
||||
throw new Error(`malformed progress.json:\n${raw.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ProgressTracker multi-PRD save isolation", () => {
|
||||
it("does not clobber another PRD's task status on save", () => {
|
||||
const a = prdA(root);
|
||||
const b = prdB(root);
|
||||
expect(a.getKey()).not.toBe(b.getKey());
|
||||
|
||||
// Loop A marks its task in_progress.
|
||||
a.markInProgress("01");
|
||||
expect(a.getTaskStatus("01")).toBe("in_progress");
|
||||
|
||||
// Loop B (stale snapshot from before A's update) marks ITS task.
|
||||
b.markInProgress("02");
|
||||
|
||||
// The on-disk state must show BOTH updates.
|
||||
const raw = readState();
|
||||
expect(raw.prds[a.getKey()].tasks["01"].status).toBe("in_progress");
|
||||
expect(raw.prds[b.getKey()].tasks["02"].status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("preserves other PRD completions when this PRD saves", () => {
|
||||
const a = prdA(root);
|
||||
const b = prdB(root);
|
||||
|
||||
a.markCompleted("01", 1000);
|
||||
b.markInProgress("02");
|
||||
|
||||
// A completes another task later — A's save must not revert B.
|
||||
a.markCompleted("03", 500);
|
||||
|
||||
const raw = readState();
|
||||
expect(raw.prds[a.getKey()].tasks["01"].status).toBe("completed");
|
||||
expect(raw.prds[a.getKey()].tasks["03"].status).toBe("completed");
|
||||
expect(raw.prds[b.getKey()].tasks["02"].status).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
87
tests/resume-stats.test.ts
Normal file
87
tests/resume-stats.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ProgressTracker } from "../src/progress";
|
||||
import { countPRDResumeStats } from "../src/utils";
|
||||
|
||||
/**
|
||||
* Regression test: the resume-selection prompt under-reported task totals
|
||||
* when multiple loop histories existed. The progress tracker only records
|
||||
* TOUCHED tasks (started/completed/failed) — never-started tasks are absent
|
||||
* from prd.tasks, so a naive Object.keys() count missed them entirely, and
|
||||
* file-checked completions were ignored unless markCompleted had run.
|
||||
* countPRDResumeStats derives the true total from the parsed PRD file and
|
||||
* counts checkbox completions too.
|
||||
*/
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-stats-test-"));
|
||||
});
|
||||
|
||||
const PRD_CONTENT = `# Test PRD
|
||||
|
||||
## Tasks
|
||||
- [ ] Task one
|
||||
- [x] Task two
|
||||
- [ ] Task three
|
||||
- [ ] Task four
|
||||
`;
|
||||
|
||||
function writePRD(rel: string): string {
|
||||
const p = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, PRD_CONTENT, "utf-8");
|
||||
return p;
|
||||
}
|
||||
|
||||
describe("countPRDResumeStats", () => {
|
||||
it("reports the full task total from the PRD file, not just touched tasks", () => {
|
||||
const sourcePath = writePRD("tasks/a/README.md");
|
||||
const progress = new ProgressTracker(root, sourcePath);
|
||||
|
||||
// Simple-checkbox format assigns sequential ids 00-03. Only 00
|
||||
// (completed) and 02 (failed) were touched by the loop; 01 is checked
|
||||
// off in the file; 03 was never started.
|
||||
progress.markCompleted("00", 1000);
|
||||
progress.markFailed("02", "boom");
|
||||
|
||||
const stats = countPRDResumeStats(progress.getState(), sourcePath);
|
||||
expect(stats.total).toBe(4); // old code reported 2
|
||||
expect(stats.completed).toBe(2); // 00 via progress + 01 via checkbox
|
||||
expect(stats.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not double-count a task that is both progress-completed and file-checked", () => {
|
||||
const sourcePath = writePRD("tasks/b/README.md");
|
||||
const progress = new ProgressTracker(root, sourcePath);
|
||||
|
||||
progress.markCompleted("01", 500); // 01 already [x] in the file
|
||||
|
||||
const stats = countPRDResumeStats(progress.getState(), sourcePath);
|
||||
expect(stats.completed).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to touched-task counts when the PRD file is missing", () => {
|
||||
const missing = path.join(root, "tasks/gone/README.md");
|
||||
const progress = new ProgressTracker(root, missing);
|
||||
progress.markCompleted("01", 1000);
|
||||
progress.markFailed("02", "nope");
|
||||
|
||||
const stats = countPRDResumeStats(progress.getState(), missing);
|
||||
expect(stats.total).toBe(2);
|
||||
expect(stats.completed).toBe(1);
|
||||
expect(stats.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("reports zero for a never-touched PRD with no file", () => {
|
||||
const missing = path.join(root, "tasks/none/README.md");
|
||||
const progress = new ProgressTracker(root, missing);
|
||||
const stats = countPRDResumeStats(progress.getState(), missing);
|
||||
expect(stats.total).toBe(0);
|
||||
expect(stats.completed).toBe(0);
|
||||
expect(stats.failed).toBe(0);
|
||||
});
|
||||
});
|
||||
265
tests/worktree-resume.test.ts
Normal file
265
tests/worktree-resume.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
createWorktree,
|
||||
finalizeCommittedWorktrees,
|
||||
mergeWorktree,
|
||||
removeWorktree,
|
||||
worktreeHasPreservableWork,
|
||||
} from "../src/worktree";
|
||||
|
||||
/**
|
||||
* Regression tests for worktree resume/finalize behavior:
|
||||
*
|
||||
* 1. finalizeCommittedWorktrees merges a committed worktree branch even
|
||||
* when the worktree carries UNTRACKED files (previously the dirty check
|
||||
* counted `??` entries, stranding committed code in .ralpi/worktrees/).
|
||||
* 2. finalize works for tasks that are NOT in_progress (pending) — the
|
||||
* stranded-work case after an interrupted resume.
|
||||
* 3. createWorktree reuses an existing worktree under a symlinked project
|
||||
* path (git porcelain emits realpaths; literal path.join must not be
|
||||
* compared verbatim).
|
||||
* 4. worktreeHasPreservableWork keeps failed-task branches alive so a
|
||||
* timeout doesn't destroy commits the agent already made.
|
||||
*/
|
||||
|
||||
const sh = (cmd: string, cwd: string): string => {
|
||||
try {
|
||||
return execSync(cmd, { cwd, encoding: "utf-8" }).trim();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`git cmd failed in ${cwd}: ${cmd}\n${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const STATE_DIR = ".ralpi";
|
||||
const PRD_KEY = "prd";
|
||||
|
||||
function makeRepo(root: string): void {
|
||||
sh("git init -q -b master .", root);
|
||||
sh("git config user.email t@t.co", root);
|
||||
sh("git config user.name T", root);
|
||||
sh("echo '# Demo' > README.md", root);
|
||||
sh("git add -A && git commit -qm init", root);
|
||||
}
|
||||
|
||||
/** Commit work in a worktree and record the commit message. */
|
||||
function commitInWorktree(wt: { dir: string }, filename: string, msg: string) {
|
||||
sh(`echo '${filename} content' > ${filename}`, wt.dir);
|
||||
sh(`git add -A && git commit -qm '${msg}'`, wt.dir);
|
||||
}
|
||||
|
||||
function masterHasFile(root: string, filename: string): boolean {
|
||||
try {
|
||||
sh(`git show master:${filename}`, root);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-wt-test-"));
|
||||
makeRepo(root);
|
||||
});
|
||||
|
||||
describe("finalizeCommittedWorktrees", () => {
|
||||
it("merges a committed worktree branch even when untracked files exist", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
commitInWorktree(wt, "work.txt", "task 01 work");
|
||||
// The task agent left a scratch file untracked (like build artifacts).
|
||||
sh("mkdir -p scratch && echo junk > scratch/junk.bin", wt.dir);
|
||||
expect(sh("git status --porcelain", wt.dir)).toContain("??");
|
||||
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["01"]);
|
||||
|
||||
expect(fin.finalized).toEqual(["01"]);
|
||||
expect(masterHasFile(root, "work.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("finalizes tasks that are pending (not in_progress) with committed work", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"02",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task two",
|
||||
)!;
|
||||
commitInWorktree(wt, "b.txt", "task 02 work");
|
||||
// Simulate a prior interrupted resume: task reset to pending, branch
|
||||
// never merged.
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["02"]);
|
||||
expect(fin.finalized).toEqual(["02"]);
|
||||
expect(masterHasFile(root, "b.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a worktree with uncommitted TRACKED edits for re-run", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"03",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task three",
|
||||
)!;
|
||||
commitInWorktree(wt, "c.txt", "task 03 work");
|
||||
// Agent was mid-edit when interrupted: a tracked file modified.
|
||||
sh("echo more >> README.md", wt.dir);
|
||||
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["03"]);
|
||||
expect(fin.finalized).toEqual([]);
|
||||
expect(fin.rerun).toEqual(["03"]);
|
||||
expect(masterHasFile(root, "c.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not re-merge an already-merged branch", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"04",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task four",
|
||||
)!;
|
||||
commitInWorktree(wt, "d.txt", "task 04 work");
|
||||
expect(mergeWorktree(root, wt.branch).success).toBe(true);
|
||||
removeWorktree(root, wt);
|
||||
|
||||
// Re-create a worktree on the same (now-merged) branch tip: nothing
|
||||
// ahead of main → re-run, no spurious merge.
|
||||
const wt2 = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"04",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task four",
|
||||
)!;
|
||||
commitInWorktree(wt2, "e.txt", "task 04 more work");
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["04"]);
|
||||
expect(fin.finalized).toEqual(["04"]);
|
||||
expect(masterHasFile(root, "e.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports conflicts and preserves the worktree", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"05",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task five",
|
||||
)!;
|
||||
// Both sides edit f.txt: master AFTER the worktree exists, so the
|
||||
// branches genuinely diverge and the merge must conflict.
|
||||
sh(
|
||||
"echo master > f.txt && git add -A && git commit -qm 'master f.txt'",
|
||||
root,
|
||||
);
|
||||
sh("echo worktree > f.txt", wt.dir);
|
||||
sh("git add -A && git commit -qm 'task 05 work'", wt.dir);
|
||||
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["05"]);
|
||||
expect(fin.finalized).toEqual([]);
|
||||
expect(fin.conflicts["05"]).toBeTruthy();
|
||||
// worktree preserved for manual resolution
|
||||
expect(fs.existsSync(wt.dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorktree resume reuse under symlinked paths", () => {
|
||||
it("reuses an existing worktree when the project path contains a symlink", () => {
|
||||
// macOS /tmp → /private/tmp style symlink: git porcelain reports the
|
||||
// REAL path, path.join keeps the literal one. Reuse must still match.
|
||||
const realBase = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-wt-real-"));
|
||||
const link = path.join(realBase, "link");
|
||||
fs.mkdirSync(path.join(realBase, "repo"));
|
||||
fs.symlinkSync(path.join(realBase, "repo"), link);
|
||||
const symRoot = link;
|
||||
|
||||
makeRepo(symRoot);
|
||||
// Sanity: this is genuinely a symlink situation.
|
||||
expect(fs.realpathSync(symRoot)).not.toBe(symRoot);
|
||||
|
||||
const wt = createWorktree(
|
||||
symRoot,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
commitInWorktree(wt, "a.txt", "task 01 work");
|
||||
|
||||
// Resume: createWorktree again must REUSE the registered worktree
|
||||
// (same dir), not fail and fall through to the main repo.
|
||||
const reused = createWorktree(
|
||||
symRoot,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
expect(reused.dir).toBe(fs.realpathSync(wt.dir));
|
||||
|
||||
const fin = finalizeCommittedWorktrees(symRoot, STATE_DIR, PRD_KEY, ["01"]);
|
||||
expect(fin.finalized).toEqual(["01"]);
|
||||
expect(masterHasFile(fs.realpathSync(symRoot), "a.txt")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktreeHasPreservableWork", () => {
|
||||
it("returns true for a worktree with committed work ahead of main", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
commitInWorktree(wt, "a.txt", "task 01 work");
|
||||
expect(worktreeHasPreservableWork(root, wt)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a worktree with uncommitted changes", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
sh("echo x > junk.txt", wt.dir);
|
||||
expect(worktreeHasPreservableWork(root, wt)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an empty fresh worktree", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
expect(worktreeHasPreservableWork(root, wt)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user