fix: context overflow, retry, and reflection isolation
- Cap commit diffs in review/commit prompts at 50KB to prevent context window overflow on follow-up sessions - Skip skills catalog (noSkills) in commit/review follow-up sessions for leaner context - Wire Pi's SettingsManager into runAgentSession so Pi's built-in retry (exponential backoff, provider retry) applies to ralpi sessions — removes ralpi's duplicate manual retry loop - Remove maxRetries/retryDelayMs from ralpi config; rely on Pi's retry.* settings (with manual override support) - Remove retries field from progress.json and incrementRetry() from ProgressTracker - Add model failover to follow-up sessions (commit/review cycle through the model pool on connection errors) - Namespace reflection files by PRD key under .ralpi/reflections/<prdKey>/ so task sets don't overwrite each other - Skip loop-startup prompts for config fields explicitly set in YAML - Remove (default) annotations from loop options prompts - Default commitTimeoutMs/reviewTimeoutMs to 0 (inherit Pi defaults)
This commit is contained in:
13
AGENTS.md
13
AGENTS.md
@@ -19,6 +19,7 @@ No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/
|
|||||||
## External dependencies
|
## External dependencies
|
||||||
|
|
||||||
The extension imports from Pi SDK packages (not in `package.json` — provided by the host):
|
The extension imports from Pi SDK packages (not in `package.json` — provided by the host):
|
||||||
|
|
||||||
- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
|
- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
|
||||||
- `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer
|
- `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ The only real npm dependency is `yaml` (^2.4.0).
|
|||||||
## Runtime state
|
## Runtime state
|
||||||
|
|
||||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||||
|
|
||||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||||
@@ -59,4 +61,13 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0`
|
|||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Read from `.ralpi/config.yaml` in project directory. Falls back to `DEFAULT_CONFIG` in `src/types.ts` when file is missing. Config is loaded at `projectDir` level, not extension level.
|
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.
|
||||||
|
|
||||||
|
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`
|
||||||
|
- `commitTimeoutMs` / `reviewTimeoutMs` — timeouts for follow-up sessions
|
||||||
|
- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked between batches in `executePlanBatches`)
|
||||||
|
- `timeoutMs` — per-task execution timeout
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -120,6 +120,15 @@ execution:
|
|||||||
models: # round-robin in <provider>/<model> format
|
models: # round-robin in <provider>/<model> format
|
||||||
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
||||||
- openai/gpt-5.5 # 2nd task in parallel
|
- openai/gpt-5.5 # 2nd task in parallel
|
||||||
|
autoCommit: true # spawn a commit agent after each task completes
|
||||||
|
autoReview: false # spawn a review agent to review each commit
|
||||||
|
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)
|
||||||
prompts:
|
prompts:
|
||||||
projectContext: "Additional context for all tasks"
|
projectContext: "Additional context for all tasks"
|
||||||
```
|
```
|
||||||
@@ -134,6 +143,20 @@ prompts:
|
|||||||
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
||||||
> parent pi session's model is used
|
> parent pi session's model is used
|
||||||
|
|
||||||
|
#### Auto-commit and Auto-review
|
||||||
|
|
||||||
|
When `autoCommit` is enabled (default), a follow-up agent session is spawned
|
||||||
|
after each task to stage and commit uncommitted changes. When `autoReview` is
|
||||||
|
enabled, a second follow-up session reviews the latest commit against the task
|
||||||
|
description. Both options can be overridden at loop startup via a selection
|
||||||
|
prompt.
|
||||||
|
|
||||||
|
`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).
|
||||||
|
|
||||||
## State Files
|
## State Files
|
||||||
|
|
||||||
- `.ralpi/progress.json` - Execution progress
|
- `.ralpi/progress.json` - Execution progress
|
||||||
|
|||||||
156
index.ts
156
index.ts
@@ -29,6 +29,8 @@ import {
|
|||||||
deleteLoopActive,
|
deleteLoopActive,
|
||||||
readLoopActive,
|
readLoopActive,
|
||||||
findRalpiDir,
|
findRalpiDir,
|
||||||
|
listPRDsSorted,
|
||||||
|
formatDuration,
|
||||||
} from "./src/utils";
|
} from "./src/utils";
|
||||||
|
|
||||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
const COMMANDS = ["plan", "resume", "reset"] as const;
|
||||||
@@ -120,6 +122,96 @@ function buildPlanByMode(
|
|||||||
: buildSequentialPlan(project, completed);
|
: buildSequentialPlan(project, completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt the user to select auto-commit and auto-review options for this loop.
|
||||||
|
* Defaults are taken from config; the user can override at loop startup.
|
||||||
|
* Fields explicitly set in the config YAML are skipped (no prompt).
|
||||||
|
* Returns the selected options (or config defaults if cancelled).
|
||||||
|
*/
|
||||||
|
async function selectLoopOptions(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
config: import("./src/types").RalpiConfig,
|
||||||
|
): Promise<{ autoCommit: boolean; autoReview: boolean }> {
|
||||||
|
const explicit = config.execution.explicitKeys;
|
||||||
|
|
||||||
|
// Skip the commit prompt when the YAML explicitly sets it.
|
||||||
|
let autoCommit: boolean;
|
||||||
|
if (explicit?.has("autoCommit")) {
|
||||||
|
autoCommit = config.execution.autoCommit;
|
||||||
|
} else {
|
||||||
|
const commitChoice = await ctx.ui.select("Auto-commit after each task?", [
|
||||||
|
"Yes — stage and commit changes automatically",
|
||||||
|
"No — skip auto-commit",
|
||||||
|
]);
|
||||||
|
autoCommit = commitChoice
|
||||||
|
? commitChoice.startsWith("Yes")
|
||||||
|
: config.execution.autoCommit;
|
||||||
|
}
|
||||||
|
|
||||||
|
let autoReview = false;
|
||||||
|
if (autoCommit) {
|
||||||
|
// Skip the review prompt when the YAML explicitly sets it.
|
||||||
|
if (explicit?.has("autoReview")) {
|
||||||
|
autoReview = config.execution.autoReview;
|
||||||
|
} else {
|
||||||
|
const reviewChoice = await ctx.ui.select(
|
||||||
|
"Auto-review each commit against the task?",
|
||||||
|
["Yes — spawn a review agent after each commit", "No — skip review"],
|
||||||
|
);
|
||||||
|
autoReview = reviewChoice
|
||||||
|
? reviewChoice.startsWith("Yes")
|
||||||
|
: config.execution.autoReview;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { autoCommit, autoReview };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When multiple PRD loops have progress, prompt the user to select which one
|
||||||
|
* to resume. 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(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
found: NonNullable<ReturnType<typeof findProgressFile>>,
|
||||||
|
): Promise<{ prdKey: string; sourcePath: string } | null> {
|
||||||
|
const prds = listPRDsSorted(found.state);
|
||||||
|
if (prds.length === 0) return null;
|
||||||
|
if (prds.length === 1) {
|
||||||
|
return { prdKey: prds[0].key, sourcePath: prds[0].prd.sourcePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
if (!selected) return null;
|
||||||
|
|
||||||
|
const idx = options.indexOf(selected);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
return {
|
||||||
|
prdKey: prds[idx].key,
|
||||||
|
sourcePath: prds[idx].prd.sourcePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Run all batches in a plan, updating the task file after each batch. */
|
/** Run all batches in a plan, updating the task file after each batch. */
|
||||||
async function executePlanBatches(
|
async function executePlanBatches(
|
||||||
plan: ReturnType<typeof buildPlanByMode>,
|
plan: ReturnType<typeof buildPlanByMode>,
|
||||||
@@ -147,8 +239,20 @@ async function executePlanBatches(
|
|||||||
// Track failed task IDs across batches to block downstream tasks
|
// Track failed task IDs across batches to block downstream tasks
|
||||||
const failedTaskIds = new Set(progress.getFailedTaskIds());
|
const failedTaskIds = new Set(progress.getFailedTaskIds());
|
||||||
|
|
||||||
|
// Loop-level execution timeout: stop starting new batches once elapsed.
|
||||||
|
// In-progress tasks finish naturally; we just skip remaining batches.
|
||||||
|
const loopStart = Date.now();
|
||||||
|
const loopTimeoutMs = config.execution.loopTimeoutMs;
|
||||||
|
let loopTimedOut = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const batch of plan.batches) {
|
for (const batch of plan.batches) {
|
||||||
|
// Check loop timeout before starting a new batch
|
||||||
|
if (loopTimeoutMs > 0 && Date.now() - loopStart > loopTimeoutMs) {
|
||||||
|
loopTimedOut = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (progress.getState().paused) {
|
if (progress.getState().paused) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"Execution paused. Use /ralpi resume to continue.",
|
"Execution paused. Use /ralpi resume to continue.",
|
||||||
@@ -223,6 +327,13 @@ async function executePlanBatches(
|
|||||||
if (projectDir) {
|
if (projectDir) {
|
||||||
deleteLoopActive(projectDir);
|
deleteLoopActive(projectDir);
|
||||||
}
|
}
|
||||||
|
if (loopTimedOut) {
|
||||||
|
const elapsed = formatDuration(Date.now() - loopStart);
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Loop execution timeout reached (${elapsed}). Remaining tasks skipped. Use /ralpi resume to continue.`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,6 +895,9 @@ async function handleRun(
|
|||||||
|
|
||||||
const completed = buildCompletedSet(progress, project);
|
const completed = buildCompletedSet(progress, project);
|
||||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
||||||
|
const { autoCommit, autoReview } = await selectLoopOptions(ctx, config);
|
||||||
|
config.execution.autoCommit = autoCommit;
|
||||||
|
config.execution.autoReview = autoReview;
|
||||||
const plan = buildPlanByMode(mode, project, completed);
|
const plan = buildPlanByMode(mode, project, completed);
|
||||||
|
|
||||||
// Show dependency chain + execution plan before starting
|
// Show dependency chain + execution plan before starting
|
||||||
@@ -839,11 +953,11 @@ async function handleResume(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let taskFile: string;
|
let taskFile: string;
|
||||||
let projectDir: string;
|
let projectDir: string;
|
||||||
let found: ReturnType<typeof findProgressFile>;
|
let prdKey: string | undefined;
|
||||||
|
|
||||||
if (args[0]) {
|
if (args[0]) {
|
||||||
taskFile = resolveTaskArg(args[0], process.cwd());
|
taskFile = resolveTaskArg(args[0], process.cwd());
|
||||||
found = findProgressFile(process.cwd(), taskFile);
|
const found = findProgressFile(process.cwd(), taskFile);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
||||||
@@ -852,8 +966,9 @@ async function handleResume(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
projectDir = path.dirname(path.dirname(found.path));
|
projectDir = path.dirname(path.dirname(found.path));
|
||||||
|
prdKey = found.prdKey;
|
||||||
} else {
|
} else {
|
||||||
found = findProgressFile(process.cwd());
|
const found = findProgressFile(process.cwd());
|
||||||
if (!found) {
|
if (!found) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||||
@@ -862,10 +977,16 @@ async function handleResume(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
projectDir = path.dirname(path.dirname(found.path));
|
projectDir = path.dirname(path.dirname(found.path));
|
||||||
// For no-arg resume, use the first PRD's source path or legacy sourcePath
|
|
||||||
taskFile = found.state.prds
|
// When no specific task file is given, let the user select which loop
|
||||||
? Object.values(found.state.prds)[0].sourcePath
|
// to resume from multiple PRDs (sorted by most recent first).
|
||||||
: found.state.sourcePath;
|
const selected = await selectPRDToResume(ctx, found);
|
||||||
|
if (!selected) {
|
||||||
|
ctx.ui.notify("Resume cancelled.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskFile = selected.sourcePath;
|
||||||
|
prdKey = selected.prdKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = parseTaskFile(taskFile);
|
const project = parseTaskFile(taskFile);
|
||||||
@@ -877,21 +998,21 @@ async function handleResume(
|
|||||||
const config = loadConfig(projectDir);
|
const config = loadConfig(projectDir);
|
||||||
config.model = parentModel ?? ctx.model;
|
config.model = parentModel ?? ctx.model;
|
||||||
config.thinkingLevel = parentThinkingLevel;
|
config.thinkingLevel = parentThinkingLevel;
|
||||||
const progress = new ProgressTracker(projectDir, taskFile, found.prdKey);
|
const progress = new ProgressTracker(projectDir, taskFile, prdKey);
|
||||||
|
|
||||||
progress.setPaused(false);
|
progress.setPaused(false);
|
||||||
|
|
||||||
const completed = buildCompletedSet(progress, project);
|
const completed = buildCompletedSet(progress, project);
|
||||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
||||||
|
const { autoCommit, autoReview } = await selectLoopOptions(ctx, config);
|
||||||
|
config.execution.autoCommit = autoCommit;
|
||||||
|
config.execution.autoReview = autoReview;
|
||||||
const plan = buildPlanByMode(mode, project, completed);
|
const plan = buildPlanByMode(mode, project, completed);
|
||||||
|
|
||||||
// Print remaining batches before executing
|
// Print remaining batches before executing
|
||||||
const formattedPlan = formatExecutionPlan(plan);
|
const formattedPlan = formatExecutionPlan(plan);
|
||||||
if (mode === "parallel") {
|
if (mode === "parallel") {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info");
|
||||||
`${formattedPlan}\n\nResuming parallel execution...`,
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`${formattedPlan}\n\nResuming sequential execution...`,
|
`${formattedPlan}\n\nResuming sequential execution...`,
|
||||||
@@ -941,12 +1062,11 @@ async function handleReset(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectDir = path.dirname(path.dirname(found.path));
|
const projectDir = path.dirname(path.dirname(found.path));
|
||||||
const progress = new ProgressTracker(
|
// Use the most recently updated PRD (first in sorted order)
|
||||||
projectDir,
|
const prds = listPRDsSorted(found.state);
|
||||||
found.state.prds
|
const sourcePath =
|
||||||
? Object.values(found.state.prds)[0].sourcePath
|
prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath;
|
||||||
: found.state.sourcePath,
|
const progress = new ProgressTracker(projectDir, sourcePath);
|
||||||
);
|
|
||||||
progress.reset();
|
progress.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
431
src/executor.ts
431
src/executor.ts
@@ -4,7 +4,7 @@ import type { Task, Project, Reflection, ToolUsage } from "./types";
|
|||||||
import type { RalpiConfig } from "./types";
|
import type { RalpiConfig } from "./types";
|
||||||
import type { ProgressTracker } from "./progress";
|
import type { ProgressTracker } from "./progress";
|
||||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
import { buildTaskPrompt } from "./prompts";
|
import { buildTaskPrompt, buildReviewPrompt, MAX_DIFF_BYTES } from "./prompts";
|
||||||
import { extractReflection } from "./reflection";
|
import { extractReflection } from "./reflection";
|
||||||
import {
|
import {
|
||||||
runAgentSession,
|
runAgentSession,
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
hasUncommittedChanges,
|
hasUncommittedChanges,
|
||||||
getGitStatusPorcelain,
|
getGitStatusPorcelain,
|
||||||
getGitDiff,
|
getGitDiff,
|
||||||
|
getLatestCommitDiff,
|
||||||
|
resolveModelSpec,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import { updateTaskInFile } from "./parser";
|
import { updateTaskInFile } from "./parser";
|
||||||
@@ -74,6 +76,11 @@ class ModelRoundRobin {
|
|||||||
return this.models.length;
|
return this.models.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All resolved models in the pool (for follow-up session failover). */
|
||||||
|
get allModels(): unknown[] {
|
||||||
|
return this.models;
|
||||||
|
}
|
||||||
|
|
||||||
assign(taskId: string): unknown {
|
assign(taskId: string): unknown {
|
||||||
let index: number;
|
let index: number;
|
||||||
if (this.freeSlots.length > 0) {
|
if (this.freeSlots.length > 0) {
|
||||||
@@ -627,14 +634,19 @@ async function executeTask(
|
|||||||
roundRobin?: ModelRoundRobin | null,
|
roundRobin?: ModelRoundRobin | null,
|
||||||
batchRender?: () => void,
|
batchRender?: () => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const maxRetries = config.execution.maxRetries;
|
|
||||||
|
|
||||||
// Model failover: when a provider/API is down, cycle through available models.
|
// Model failover: when a provider/API is down, cycle through available models.
|
||||||
// result.success === false always means an agent-session failure (API error,
|
// Pi's built-in retry (via SettingsManager) handles transient errors with
|
||||||
// provider unreachable, etc.), not a task-work error.
|
// exponential backoff within each model. Ralpi only handles model cycling.
|
||||||
const maxModelAttempts = roundRobin ? roundRobin.length : 1;
|
const maxModelAttempts = roundRobin ? roundRobin.length : 1;
|
||||||
let modelAttempt = 0;
|
let modelAttempt = 0;
|
||||||
let currentModel: unknown = assignedModel ?? config.model;
|
// Resolve implModel from config (used in sequential mode when no round-robin assignment).
|
||||||
|
// In parallel mode, the round-robin assignedModel takes precedence.
|
||||||
|
const implModel = resolveModelSpec(
|
||||||
|
ctx.modelRegistry as { find(p: string, m: string): unknown } | undefined,
|
||||||
|
config.execution.implModel,
|
||||||
|
(msg) => ctx.ui.notify(msg, "warning"),
|
||||||
|
);
|
||||||
|
let currentModel: unknown = assignedModel ?? implModel ?? config.model;
|
||||||
|
|
||||||
while (modelAttempt < maxModelAttempts) {
|
while (modelAttempt < maxModelAttempts) {
|
||||||
// On subsequent model attempts, advance to the next model.
|
// On subsequent model attempts, advance to the next model.
|
||||||
@@ -644,8 +656,6 @@ async function executeTask(
|
|||||||
currentModel = roundRobin.advance(task.id);
|
currentModel = roundRobin.advance(task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let retries = 0;
|
|
||||||
while (retries <= maxRetries) {
|
|
||||||
try {
|
try {
|
||||||
// Mark as in progress
|
// Mark as in progress
|
||||||
progress.markInProgress(task.id);
|
progress.markInProgress(task.id);
|
||||||
@@ -676,14 +686,23 @@ async function executeTask(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// ── Auto-Commit: Trigger follow-up agent session for uncommitted changes ──
|
// ── Auto-Commit: optionally trigger follow-up agent session for uncommitted changes ──
|
||||||
let finalCommitMessages = result.commitMessages ?? [];
|
let finalCommitMessages = result.commitMessages ?? [];
|
||||||
let finalCommitSummary = result.commitSummary ?? "";
|
let finalCommitSummary = result.commitSummary ?? "";
|
||||||
|
|
||||||
|
if (config.execution.autoCommit) {
|
||||||
try {
|
try {
|
||||||
if (hasUncommittedChanges(projectDir)) {
|
if (hasUncommittedChanges(projectDir)) {
|
||||||
const status = getGitStatusPorcelain(projectDir);
|
const status = getGitStatusPorcelain(projectDir);
|
||||||
const diff = getGitDiff(projectDir);
|
let diff = getGitDiff(projectDir);
|
||||||
|
let diffNote = "";
|
||||||
|
if (diff.length > MAX_DIFF_BYTES) {
|
||||||
|
diffNote =
|
||||||
|
"\n\n... (diff truncated: omitted " +
|
||||||
|
(diff.length - MAX_DIFF_BYTES).toLocaleString() +
|
||||||
|
" bytes; run `git diff` to view the full diff)";
|
||||||
|
diff = diff.slice(0, MAX_DIFF_BYTES);
|
||||||
|
}
|
||||||
const commitPrompt = [
|
const commitPrompt = [
|
||||||
`## Auto-Commit for Task ${task.id}: ${task.title}`,
|
`## Auto-Commit for Task ${task.id}: ${task.title}`,
|
||||||
"",
|
"",
|
||||||
@@ -703,118 +722,34 @@ async function executeTask(
|
|||||||
"### Current Tracked Diff (git diff)",
|
"### Current Tracked Diff (git diff)",
|
||||||
"```diff",
|
"```diff",
|
||||||
diff || "(no tracked diff output)",
|
diff || "(no tracked diff output)",
|
||||||
|
diffNote,
|
||||||
"```",
|
"```",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
// ── Commit widget setup ──
|
// Resolve commit model (fall back to current task model)
|
||||||
const commitWidgetKey = `ralpi-commit-${task.id}`;
|
const commitModel =
|
||||||
let commitFrameIndex = 0;
|
resolveModelSpec(
|
||||||
const commitToolCalls: ToolCallEntry[] = [];
|
ctx.modelRegistry as
|
||||||
let commitWidgetTui: { requestRender(): void } | null = null;
|
| { find(p: string, m: string): unknown }
|
||||||
|
| undefined,
|
||||||
|
config.execution.commitModel,
|
||||||
|
(msg) => ctx.ui.notify(msg, "warning"),
|
||||||
|
) ?? currentModel;
|
||||||
|
|
||||||
const commitHeader = `commit for ${task.id} · ${task.title}`;
|
// Build failover list: primary model first, then the rest of the pool.
|
||||||
|
const commitModels = buildFailoverModels(commitModel, roundRobin);
|
||||||
|
|
||||||
const buildCommitLines = (
|
const { result: commitResult, toolCalls: commitToolCalls } =
|
||||||
t: typeof ctx.ui.theme,
|
await runFollowUpSession(
|
||||||
width?: number,
|
ctx,
|
||||||
): string[] => {
|
config,
|
||||||
const effectiveWidth = width || 74;
|
|
||||||
const frame = t.fg(
|
|
||||||
"accent",
|
|
||||||
SPINNER_FRAMES[commitFrameIndex % SPINNER_FRAMES.length],
|
|
||||||
);
|
|
||||||
const lines = [
|
|
||||||
truncateToWidth(`~ ${frame} ${commitHeader}`, effectiveWidth),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (commitToolCalls.length > 0) {
|
|
||||||
if (commitToolCalls.length <= MAX_COLLAPSED) {
|
|
||||||
for (let i = 0; i < commitToolCalls.length; i++) {
|
|
||||||
const entry = commitToolCalls[i];
|
|
||||||
const isLast = i === commitToolCalls.length - 1;
|
|
||||||
const branch = isLast ? " └── " : " ├── ";
|
|
||||||
const tag = t.fg("accent", `[${entry.name}]`);
|
|
||||||
lines.push(
|
|
||||||
truncateToWidth(
|
|
||||||
`${branch}${tag} ${entry.label}`,
|
|
||||||
effectiveWidth,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const shown = commitToolCalls.slice(-MAX_COLLAPSED);
|
|
||||||
const remaining = commitToolCalls.length - shown.length;
|
|
||||||
lines.push(
|
|
||||||
truncateToWidth(
|
|
||||||
t.fg("dim", ` ├── …${remaining} earlier`),
|
|
||||||
effectiveWidth,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (let i = 0; i < shown.length; i++) {
|
|
||||||
const entry = shown[i];
|
|
||||||
const isLast = i === shown.length - 1;
|
|
||||||
const branch = isLast ? " └── " : " ├── ";
|
|
||||||
const tag = t.fg("accent", `[${entry.name}]`);
|
|
||||||
lines.push(
|
|
||||||
truncateToWidth(
|
|
||||||
`${branch}${tag} ${entry.label}`,
|
|
||||||
effectiveWidth,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.ui.setWidget(commitWidgetKey, (tui, t) => {
|
|
||||||
commitWidgetTui = tui;
|
|
||||||
return {
|
|
||||||
render: (width?: number) => buildCommitLines(t, width),
|
|
||||||
invalidate: () => commitWidgetTui?.requestRender(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestCommitRender = () =>
|
|
||||||
commitWidgetTui?.requestRender();
|
|
||||||
|
|
||||||
const commitSpinnerTimer = setInterval(() => {
|
|
||||||
commitFrameIndex =
|
|
||||||
(commitFrameIndex + 1) % SPINNER_FRAMES.length;
|
|
||||||
requestCommitRender();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Use a short timeout for the commit session (60s should be enough)
|
|
||||||
const commitTimeout = Math.min(
|
|
||||||
60_000,
|
|
||||||
config.execution.timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
let commitResult: Awaited<ReturnType<typeof runAgentSession>>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
commitResult = await runAgentSession(
|
|
||||||
commitPrompt,
|
commitPrompt,
|
||||||
projectDir,
|
projectDir,
|
||||||
commitTimeout,
|
`commit for ${task.id} · ${task.title}`,
|
||||||
(event) => {
|
`commit-${task.id}`,
|
||||||
if (event.type === "tool_execution_start") {
|
config.execution.commitTimeoutMs,
|
||||||
const label = formatToolArg(event.toolName, event.args);
|
commitModels,
|
||||||
commitToolCalls.push({
|
|
||||||
name: event.toolName,
|
|
||||||
label,
|
|
||||||
});
|
|
||||||
requestCommitRender();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
currentModel,
|
|
||||||
config.thinkingLevel,
|
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
clearInterval(commitSpinnerTimer);
|
|
||||||
ctx.ui.setWidget(commitWidgetKey, undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commitResult.success) {
|
if (commitResult.success) {
|
||||||
// Re-capture commits made during this follow-up session
|
// Re-capture commits made during this follow-up session
|
||||||
@@ -846,10 +781,83 @@ async function executeTask(
|
|||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-Review: optionally spawn a review agent to review the latest commit ──
|
||||||
|
if (config.execution.autoReview) {
|
||||||
|
try {
|
||||||
|
const commitInfo = getLatestCommitDiff(projectDir);
|
||||||
|
if (commitInfo && commitInfo.diff) {
|
||||||
|
const reviewPrompt = buildReviewPrompt(
|
||||||
|
task,
|
||||||
|
project,
|
||||||
|
commitInfo.hash,
|
||||||
|
commitInfo.subject,
|
||||||
|
commitInfo.diff,
|
||||||
|
config.prompts.projectContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve review model (fall back to current task model)
|
||||||
|
const reviewModel =
|
||||||
|
resolveModelSpec(
|
||||||
|
ctx.modelRegistry as
|
||||||
|
| { find(p: string, m: string): unknown }
|
||||||
|
| undefined,
|
||||||
|
config.execution.reviewModel,
|
||||||
|
(msg) => ctx.ui.notify(msg, "warning"),
|
||||||
|
) ?? currentModel;
|
||||||
|
|
||||||
|
// Build failover list: primary model first, then the rest of the pool.
|
||||||
|
const reviewModels = buildFailoverModels(reviewModel, roundRobin);
|
||||||
|
|
||||||
|
const { result: reviewResult, toolCalls: reviewToolCalls } =
|
||||||
|
await runFollowUpSession(
|
||||||
|
ctx,
|
||||||
|
config,
|
||||||
|
reviewPrompt,
|
||||||
|
projectDir,
|
||||||
|
`review for ${task.id} · ${task.title}`,
|
||||||
|
`review-${task.id}`,
|
||||||
|
config.execution.reviewTimeoutMs,
|
||||||
|
reviewModels,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reviewResult.success) {
|
||||||
|
const reviewText = reviewResult.text.trim();
|
||||||
|
// Post review as a chat message with tool calls
|
||||||
|
const preview =
|
||||||
|
reviewText.length > 500
|
||||||
|
? reviewText.slice(0, 500) + "\n... (truncated)"
|
||||||
|
: reviewText;
|
||||||
|
sendChatMessage?.(
|
||||||
|
`⚑ review for ${task.id} · ${task.title}\n${preview}`,
|
||||||
|
{ toolCalls: reviewToolCalls },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ review for ${task.id} · ${task.title} — review session failed: ${reviewResult.error}`,
|
||||||
|
{ toolCalls: reviewToolCalls },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Don't fail the task if auto-review fails
|
||||||
|
sendChatMessage?.(
|
||||||
|
`~ review for ${task.id} · ${task.title} — auto-review error: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save reflection
|
// Save reflection
|
||||||
if (result.reflection) {
|
if (result.reflection) {
|
||||||
saveReflectionToFile(projectDir, config, result.reflection);
|
saveReflectionToFile(
|
||||||
|
projectDir,
|
||||||
|
config,
|
||||||
|
result.reflection,
|
||||||
|
progress.getKey(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark completed with all metadata
|
// Mark completed with all metadata
|
||||||
@@ -873,41 +881,31 @@ async function executeTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Agent session failed (provider error).
|
// Agent session failed (provider error).
|
||||||
// If we have more models, cycle immediately — don't waste retries.
|
// Pi's built-in retry already exhausted for this model. Cycle to the next.
|
||||||
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
|
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
|
||||||
// Don't release — advance() already handles the transition.
|
|
||||||
// release() would put the slot in freeSlots, then assign()
|
|
||||||
// would pick it right back up, getting stuck on the same model.
|
|
||||||
modelAttempt++;
|
modelAttempt++;
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
`~ ${task.id} · ${task.title} — trying model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
|
`~ ${task.id} · ${task.title} — cycling to model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
|
||||||
);
|
);
|
||||||
break; // exit retry loop, cycle to next model
|
continue; // next model in the outer while loop
|
||||||
}
|
}
|
||||||
|
|
||||||
// No more models — use normal retry logic
|
// All models exhausted.
|
||||||
if (retries < maxRetries) {
|
|
||||||
retries = progress.incrementRetry(task.id);
|
|
||||||
sendChatMessage?.(
|
|
||||||
`~ ${task.id} · ${task.title} — retrying (${retries}/${maxRetries}): ${result.error}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Exponential backoff
|
|
||||||
const delay = config.execution.retryDelayMs * 2 ** (retries - 1);
|
|
||||||
await sleep(delay);
|
|
||||||
} else {
|
|
||||||
// Max retries exceeded
|
|
||||||
progress.markFailed(task.id, result.error || "Unknown error");
|
progress.markFailed(task.id, result.error || "Unknown error");
|
||||||
// Don't update PRD — retry exhaustion is transient, not terminal
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`);
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`);
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`Task ${task.id} failed after ${maxRetries} retries: ${
|
`Task ${task.id} failed across ${maxModelAttempts} models: ${
|
||||||
result.error || "Unknown error"
|
result.error || "Unknown error"
|
||||||
}`,
|
}`,
|
||||||
"error",
|
"error",
|
||||||
);
|
);
|
||||||
|
roundRobin?.release(task.id);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
roundRobin?.release(task.id);
|
roundRobin?.release(task.id);
|
||||||
batchRender?.();
|
batchRender?.();
|
||||||
@@ -925,15 +923,10 @@ async function executeTask(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we broke out (model cycling), continue the outer loop
|
|
||||||
modelAttempt++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All models exhausted — release the slot
|
// All models exhausted — release the slot
|
||||||
roundRobin?.release(task.id);
|
roundRobin?.release(task.id);
|
||||||
batchRender?.();
|
batchRender?.();
|
||||||
progress.markFailed(task.id, "All configured models exhausted");
|
progress.markFailed(task.id, "All configured models exhausted");
|
||||||
// Don't update PRD — model exhaustion is transient, not terminal
|
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
`✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
|
`✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
|
||||||
);
|
);
|
||||||
@@ -949,17 +942,167 @@ function saveReflectionToFile(
|
|||||||
sourceDir: string,
|
sourceDir: string,
|
||||||
config: RalpiConfig,
|
config: RalpiConfig,
|
||||||
reflection: Reflection,
|
reflection: Reflection,
|
||||||
|
prdKey: string,
|
||||||
): void {
|
): void {
|
||||||
const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir);
|
const reflectionsDir = path.join(
|
||||||
|
sourceDir,
|
||||||
|
config.paths.reflectionsDir,
|
||||||
|
prdKey,
|
||||||
|
);
|
||||||
ensureDir(reflectionsDir);
|
ensureDir(reflectionsDir);
|
||||||
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
|
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
|
||||||
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
|
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Follow-Up Sessions (Commit / Review) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a follow-up agent session (commit, review, etc.) with a live spinner
|
||||||
|
* widget. Handles widget setup, spinner animation, session execution, and
|
||||||
|
* cleanup. Cycles through `models` on connection failure so a flaky provider
|
||||||
|
* doesn't kill the commit/review step. Returns the session result and
|
||||||
|
* captured tool calls.
|
||||||
|
*/
|
||||||
|
async function runFollowUpSession(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
config: RalpiConfig,
|
||||||
|
prompt: string,
|
||||||
|
projectDir: string,
|
||||||
|
header: string,
|
||||||
|
widgetKeySuffix: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
models: unknown[],
|
||||||
|
): Promise<{
|
||||||
|
result: Awaited<ReturnType<typeof runAgentSession>>;
|
||||||
|
toolCalls: ToolCallEntry[];
|
||||||
|
}> {
|
||||||
|
const toolCalls: ToolCallEntry[] = [];
|
||||||
|
let frameIndex = 0;
|
||||||
|
let widgetTui: { requestRender(): void } | null = null;
|
||||||
|
const widgetKey = `ralpi-${widgetKeySuffix}-${Date.now()}`;
|
||||||
|
|
||||||
|
const truncateWidth = 74;
|
||||||
|
|
||||||
|
const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => {
|
||||||
|
const effectiveWidth = width
|
||||||
|
? Math.min(width, truncateWidth)
|
||||||
|
: truncateWidth;
|
||||||
|
const frame = t.fg(
|
||||||
|
"accent",
|
||||||
|
SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length],
|
||||||
|
);
|
||||||
|
const lines = [truncateToWidth(`~ ${frame} ${header}`, effectiveWidth)];
|
||||||
|
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
if (toolCalls.length <= MAX_COLLAPSED) {
|
||||||
|
for (let i = 0; i < toolCalls.length; i++) {
|
||||||
|
const entry = toolCalls[i];
|
||||||
|
const isLast = i === toolCalls.length - 1;
|
||||||
|
const branch = isLast ? " └── " : " ├── ";
|
||||||
|
const tag = t.fg("accent", `[${entry.name}]`);
|
||||||
|
lines.push(
|
||||||
|
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||||
|
const remaining = toolCalls.length - shown.length;
|
||||||
|
lines.push(
|
||||||
|
truncateToWidth(
|
||||||
|
t.fg("dim", ` ├── …${remaining} earlier`),
|
||||||
|
effectiveWidth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < shown.length; i++) {
|
||||||
|
const entry = shown[i];
|
||||||
|
const isLast = i === shown.length - 1;
|
||||||
|
const branch = isLast ? " └── " : " ├── ";
|
||||||
|
const tag = t.fg("accent", `[${entry.name}]`);
|
||||||
|
lines.push(
|
||||||
|
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
||||||
|
widgetTui = tui;
|
||||||
|
return {
|
||||||
|
render: (width?: number) => buildLines(t, width),
|
||||||
|
invalidate: () => widgetTui?.requestRender(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRender = () => widgetTui?.requestRender();
|
||||||
|
|
||||||
|
const spinnerTimer = setInterval(() => {
|
||||||
|
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
||||||
|
requestRender();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof runAgentSession>> | undefined;
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; attempt < models.length; attempt++) {
|
||||||
|
const model = models[attempt];
|
||||||
|
result = await runAgentSession(
|
||||||
|
prompt,
|
||||||
|
projectDir,
|
||||||
|
timeoutMs,
|
||||||
|
(event) => {
|
||||||
|
if (event.type === "tool_execution_start") {
|
||||||
|
const label = formatToolArg(event.toolName, event.args);
|
||||||
|
toolCalls.push({ name: event.toolName, label });
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
model,
|
||||||
|
config.thinkingLevel,
|
||||||
|
true, // noSkills — follow-up sessions don't need the skills catalog
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) break;
|
||||||
|
|
||||||
|
// If there's a next model to try, cycle; otherwise give up.
|
||||||
|
if (attempt < models.length - 1) {
|
||||||
|
// Clear partial tool calls from the failed attempt so the widget
|
||||||
|
// reflects only the successful (or final) attempt.
|
||||||
|
toolCalls.length = 0;
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearInterval(spinnerTimer);
|
||||||
|
ctx.ui.setWidget(widgetKey, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// result is always set — the loop runs at least once (models.length >= 1)
|
||||||
|
return { result: result!, toolCalls };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
/**
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
* Build a model failover list for a follow-up session.
|
||||||
|
*
|
||||||
|
* The primary model goes first; the remaining models from the round-robin
|
||||||
|
* pool are appended (deduped) so a flaky provider doesn't kill the commit
|
||||||
|
* or review step. When there's no round-robin (sequential mode), the
|
||||||
|
* primary model is returned as a single-element list.
|
||||||
|
*/
|
||||||
|
function buildFailoverModels(
|
||||||
|
primary: unknown,
|
||||||
|
roundRobin: ModelRoundRobin | null | undefined,
|
||||||
|
): unknown[] {
|
||||||
|
const models: unknown[] = [primary];
|
||||||
|
if (roundRobin) {
|
||||||
|
for (const m of roundRobin.allModels) {
|
||||||
|
if (m !== primary) models.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tool Call Formatting ────────────────────────────────────────────────
|
// ─── Tool Call Formatting ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -237,15 +237,6 @@ export class ProgressTracker {
|
|||||||
.filter((r): r is Reflection => r !== undefined);
|
.filter((r): r is Reflection => r !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Increment retry count */
|
|
||||||
incrementRetry(taskId: string): number {
|
|
||||||
const prd = this.getPRD();
|
|
||||||
this.ensureTask(prd, taskId);
|
|
||||||
prd.tasks[taskId].retries++;
|
|
||||||
this.save();
|
|
||||||
return prd.tasks[taskId].retries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set paused state */
|
/** Set paused state */
|
||||||
setPaused(paused: boolean): void {
|
setPaused(paused: boolean): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
@@ -277,7 +268,7 @@ export class ProgressTracker {
|
|||||||
|
|
||||||
private ensureTask(prd: PRDProgress, taskId: string): void {
|
private ensureTask(prd: PRDProgress, taskId: string): void {
|
||||||
if (!prd.tasks[taskId]) {
|
if (!prd.tasks[taskId]) {
|
||||||
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
prd.tasks[taskId] = { status: "pending" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
src/prompts.ts
112
src/prompts.ts
@@ -1,6 +1,29 @@
|
|||||||
import type { Task, Project, Reflection } from "./types";
|
import type { Task, Project, Reflection } from "./types";
|
||||||
import { readTaskSpec } from "./parser";
|
import { readTaskSpec } from "./parser";
|
||||||
|
|
||||||
|
/** Maximum bytes of a commit diff embedded in a review/commit prompt.
|
||||||
|
* Diffs larger than this are truncated to avoid blowing past the model's
|
||||||
|
* context window. The agent can always run `git show HEAD` itself to
|
||||||
|
* inspect the full diff when it needs more detail.
|
||||||
|
*
|
||||||
|
* ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K
|
||||||
|
* context window once system-prompt overhead is accounted for. */
|
||||||
|
export const MAX_DIFF_BYTES = 50_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a diff to MAX_DIFF_BYTES, appending a clear notice when truncated.
|
||||||
|
*/
|
||||||
|
function truncateDiff(diff: string): string {
|
||||||
|
if (diff.length <= MAX_DIFF_BYTES) return diff;
|
||||||
|
const omitted = diff.length - MAX_DIFF_BYTES;
|
||||||
|
return (
|
||||||
|
diff.slice(0, MAX_DIFF_BYTES) +
|
||||||
|
"\n\n... (diff truncated: omitted " +
|
||||||
|
omitted.toLocaleString() +
|
||||||
|
" bytes; run `git show HEAD` to view the full diff)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +159,91 @@ export function buildTaskPrompt(
|
|||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plan Prompt ─────────────────────────────────────────────────────────────
|
// ─── Review Prompt ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the prompt for the auto-review agent.
|
||||||
|
* Includes the task description and the latest commit diff so the reviewer
|
||||||
|
* can assess whether the commit fulfills the task requirements.
|
||||||
|
*/
|
||||||
|
export function buildReviewPrompt(
|
||||||
|
task: Task,
|
||||||
|
project: Project,
|
||||||
|
commitHash: string,
|
||||||
|
commitSubject: string,
|
||||||
|
commitDiff: string,
|
||||||
|
projectContext?: string,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
parts.push(`# Code Review: Task ${task.id}: ${task.title}`);
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Description ──
|
||||||
|
|
||||||
|
parts.push("## Task Description");
|
||||||
|
if (task.description) {
|
||||||
|
parts.push(task.description);
|
||||||
|
} else {
|
||||||
|
parts.push(task.title);
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Specification ──
|
||||||
|
|
||||||
|
if (task.file) {
|
||||||
|
const spec = readTaskSpec(project.sourceDir, task.file);
|
||||||
|
if (spec) {
|
||||||
|
parts.push("## Task Specification");
|
||||||
|
parts.push(`Full details from \`${task.file}\`:`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(spec);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit Under Review ──
|
||||||
|
|
||||||
|
parts.push("## Commit Under Review");
|
||||||
|
parts.push(`Commit: ${commitHash} — ${commitSubject}`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push("### Diff");
|
||||||
|
parts.push("```diff");
|
||||||
|
parts.push(truncateDiff(commitDiff));
|
||||||
|
parts.push("```");
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Project Context ──
|
||||||
|
|
||||||
|
if (projectContext) {
|
||||||
|
parts.push("## Additional Context");
|
||||||
|
parts.push(projectContext);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review Instructions ──
|
||||||
|
|
||||||
|
parts.push("## Review Instructions");
|
||||||
|
parts.push(
|
||||||
|
"Review the commit above against the task description. Check for:",
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
"- **Correctness**: Does the implementation fulfill the task requirements?",
|
||||||
|
);
|
||||||
|
parts.push("- **Completeness**: Are all aspects of the task addressed?");
|
||||||
|
parts.push(
|
||||||
|
"- **Code quality**: Are there obvious bugs, anti-patterns, or issues?",
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
"- **Missing changes**: Are there files that should have been modified but weren't?",
|
||||||
|
);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
"Provide a concise review with any issues found. If the commit looks good, say so explicitly.",
|
||||||
|
);
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the prompt for a dry-run / plan display
|
* Build the prompt for a dry-run / plan display
|
||||||
@@ -155,7 +262,8 @@ export function buildPlanPrompt(project: Project): string {
|
|||||||
|
|
||||||
lines.push("## Tasks");
|
lines.push("## Tasks");
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
const deps = task.dependencies.length > 0
|
const deps =
|
||||||
|
task.dependencies.length > 0
|
||||||
? ` (depends on: ${task.dependencies.join(", ")})`
|
? ` (depends on: ${task.dependencies.join(", ")})`
|
||||||
: "";
|
: "";
|
||||||
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
|
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
|
||||||
|
|||||||
34
src/types.ts
34
src/types.ts
@@ -115,7 +115,6 @@ export interface TaskProgressInfo {
|
|||||||
status: Task["status"];
|
status: Task["status"];
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
retries: number;
|
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
reflection?: Reflection;
|
reflection?: Reflection;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -167,16 +166,31 @@ export interface RalpiConfig {
|
|||||||
reflectionsDir: string;
|
reflectionsDir: string;
|
||||||
};
|
};
|
||||||
execution: {
|
execution: {
|
||||||
/** Maximum retries per task */
|
|
||||||
maxRetries: number;
|
|
||||||
/** Delay between retries in milliseconds */
|
|
||||||
retryDelayMs: number;
|
|
||||||
/** Task execution timeout in milliseconds */
|
/** Task execution timeout in milliseconds */
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
/** Maximum parallel tasks (0 = unlimited) */
|
/** Maximum parallel tasks (0 = unlimited) */
|
||||||
maxParallel: number;
|
maxParallel: number;
|
||||||
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||||
models: string[];
|
models: string[];
|
||||||
|
/** Spawn a follow-up agent to commit changes after each task completes */
|
||||||
|
autoCommit: boolean;
|
||||||
|
/** Spawn a review agent to review the commit against the task description */
|
||||||
|
autoReview: boolean;
|
||||||
|
/** Keys under `execution:` explicitly present in a loaded config YAML.
|
||||||
|
* Used to skip interactive prompts for fields the user already set. */
|
||||||
|
explicitKeys?: Set<string>;
|
||||||
|
/** Model for commit sessions in <provider>/<model> format (empty = inherit task model) */
|
||||||
|
commitModel: string;
|
||||||
|
/** Model for review sessions in <provider>/<model> format (empty = inherit task model) */
|
||||||
|
reviewModel: string;
|
||||||
|
/** Model for task implementation in <provider>/<model> format (empty = inherit parent model; only used in sequential mode when models is empty) */
|
||||||
|
implModel: string;
|
||||||
|
/** Timeout for auto-commit agent sessions in milliseconds */
|
||||||
|
commitTimeoutMs: number;
|
||||||
|
/** Timeout for auto-review agent sessions in milliseconds */
|
||||||
|
reviewTimeoutMs: number;
|
||||||
|
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
|
||||||
|
loopTimeoutMs: number;
|
||||||
};
|
};
|
||||||
prompts: {
|
prompts: {
|
||||||
/** Additional context injected into every task prompt */
|
/** Additional context injected into every task prompt */
|
||||||
@@ -196,11 +210,17 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
|||||||
reflectionsDir: ".ralpi/reflections",
|
reflectionsDir: ".ralpi/reflections",
|
||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
maxRetries: 0,
|
|
||||||
retryDelayMs: 0,
|
|
||||||
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
maxParallel: 3,
|
maxParallel: 3,
|
||||||
models: [],
|
models: [],
|
||||||
|
autoCommit: true,
|
||||||
|
autoReview: false,
|
||||||
|
commitModel: "",
|
||||||
|
reviewModel: "",
|
||||||
|
implModel: "",
|
||||||
|
commitTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
|
reviewTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
|
loopTimeoutMs: 0, // 0 = no limit
|
||||||
},
|
},
|
||||||
prompts: {
|
prompts: {
|
||||||
projectContext: "",
|
projectContext: "",
|
||||||
|
|||||||
121
src/utils.ts
121
src/utils.ts
@@ -13,6 +13,7 @@ import {
|
|||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
getAgentDir,
|
getAgentDir,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
|
SettingsManager,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
// ─── Directory Helpers ───────────────────────────────────────────────────────
|
// ─── Directory Helpers ───────────────────────────────────────────────────────
|
||||||
@@ -152,6 +153,70 @@ export function findProgressFile(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all PRDs from a ProgressState, sorted by lastUpdatedAt descending
|
||||||
|
* (most recent first). Used by resume to offer a selection when multiple
|
||||||
|
* loops have progress simultaneously.
|
||||||
|
*/
|
||||||
|
export function listPRDsSorted(
|
||||||
|
state: ProgressState,
|
||||||
|
): Array<{ key: string; prd: PRDProgress }> {
|
||||||
|
const entries: Array<{ key: string; prd: PRDProgress }> = [];
|
||||||
|
|
||||||
|
if (state.prds) {
|
||||||
|
for (const [key, prd] of Object.entries(state.prds)) {
|
||||||
|
entries.push({ key, prd });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy flat mode — single PRD
|
||||||
|
entries.push({
|
||||||
|
key: "legacy",
|
||||||
|
prd: {
|
||||||
|
sourcePath: state.sourcePath,
|
||||||
|
tasks: state.tasks,
|
||||||
|
startedAt: state.startedAt,
|
||||||
|
lastUpdatedAt: state.lastUpdatedAt,
|
||||||
|
paused: state.paused,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
return (
|
||||||
|
new Date(b.prd.lastUpdatedAt).getTime() -
|
||||||
|
new Date(a.prd.lastUpdatedAt).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model Resolution ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a "<provider>/<model>" spec string via the model registry.
|
||||||
|
* Returns undefined if spec is empty, malformed, or not found.
|
||||||
|
*/
|
||||||
|
export function resolveModelSpec(
|
||||||
|
modelRegistry:
|
||||||
|
| { find(provider: string, modelId: string): unknown }
|
||||||
|
| undefined,
|
||||||
|
spec: string,
|
||||||
|
onWarning?: (msg: string) => void,
|
||||||
|
): unknown | undefined {
|
||||||
|
if (!spec) return undefined;
|
||||||
|
const slashIdx = spec.indexOf("/");
|
||||||
|
if (slashIdx === -1) {
|
||||||
|
onWarning?.(
|
||||||
|
`ralpi config: skipping model "${spec}" — expected <provider>/<model> format`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const provider = spec.slice(0, slashIdx);
|
||||||
|
const modelId = spec.slice(slashIdx + 1);
|
||||||
|
return modelRegistry?.find(provider, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Try to use the `yaml` package (real dependency in package.json).
|
/** Try to use the `yaml` package (real dependency in package.json).
|
||||||
@@ -239,6 +304,15 @@ export function loadConfig(projectDir: string): RalpiConfig {
|
|||||||
const content = fs.readFileSync(filePath, "utf-8");
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
const parsed = parseSimpleYaml(content);
|
const parsed = parseSimpleYaml(content);
|
||||||
Object.assign(acc, mergeConfig(acc, parsed));
|
Object.assign(acc, mergeConfig(acc, parsed));
|
||||||
|
// Track which execution keys were explicitly set in this YAML so the
|
||||||
|
// loop-startup prompts can be skipped for fields the user already set.
|
||||||
|
const exec = parsed?.execution;
|
||||||
|
if (exec && typeof exec === "object" && !Array.isArray(exec)) {
|
||||||
|
acc.execution.explicitKeys ??= new Set<string>();
|
||||||
|
for (const key of Object.keys(exec)) {
|
||||||
|
acc.execution.explicitKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed config — skip silently
|
// Malformed config — skip silently
|
||||||
}
|
}
|
||||||
@@ -434,6 +508,10 @@ export async function runAgentSession(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
model?: unknown,
|
model?: unknown,
|
||||||
thinkingLevel?: unknown,
|
thinkingLevel?: unknown,
|
||||||
|
/** When true, skip loading the skills catalog for this session. Used by
|
||||||
|
* focused follow-up sessions (commit/review) that don't need skills —
|
||||||
|
* keeps the context lean and avoids dragging in unrelated overhead. */
|
||||||
|
noSkills = false,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -466,7 +544,7 @@ export async function runAgentSession(
|
|||||||
cwd,
|
cwd,
|
||||||
agentDir: getAgentDir(),
|
agentDir: getAgentDir(),
|
||||||
noExtensions: true,
|
noExtensions: true,
|
||||||
noSkills: false,
|
noSkills,
|
||||||
noPromptTemplates: true,
|
noPromptTemplates: true,
|
||||||
noThemes: true,
|
noThemes: true,
|
||||||
noContextFiles: true,
|
noContextFiles: true,
|
||||||
@@ -477,6 +555,7 @@ export async function runAgentSession(
|
|||||||
cwd,
|
cwd,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
resourceLoader: loader,
|
resourceLoader: loader,
|
||||||
|
settingsManager: SettingsManager.create(cwd, getAgentDir()),
|
||||||
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
||||||
model: model as any,
|
model: model as any,
|
||||||
thinkingLevel: thinkingLevel as any,
|
thinkingLevel: thinkingLevel as any,
|
||||||
@@ -675,3 +754,43 @@ export function captureGitCommits(projectDir: string): {
|
|||||||
|
|
||||||
return { commitMessages, commitSummary };
|
return { commitMessages, commitSummary };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the diff of the latest commit (HEAD).
|
||||||
|
* Returns the short hash, subject, and full diff (stat + patch).
|
||||||
|
* Used by the auto-review agent to review a commit against the task.
|
||||||
|
*/
|
||||||
|
export function getLatestCommitDiff(
|
||||||
|
projectDir: string,
|
||||||
|
): { hash: string; subject: string; diff: string } | null {
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = execSync("git rev-parse --short HEAD", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const subject = execSync("git log -1 --format=%s", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
// Full diff of the latest commit: stat overview + patch
|
||||||
|
const diff = execSync("git show HEAD --stat --patch", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
return { hash, subject, diff };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user