1788 lines
54 KiB
TypeScript
1788 lines
54 KiB
TypeScript
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
import * as path from "node:path";
|
|
import type {
|
|
Task,
|
|
Project,
|
|
Reflection,
|
|
ToolUsage,
|
|
ReviewResult,
|
|
} from "./types";
|
|
import type { RalpiConfig } from "./types";
|
|
import type { ProgressTracker } from "./progress";
|
|
import type {
|
|
ExtensionContext,
|
|
ModelRuntime,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
buildTaskPrompt,
|
|
buildReviewPrompt,
|
|
buildConflictResolutionPrompt,
|
|
MAX_DIFF_BYTES,
|
|
} from "./prompts";
|
|
import { extractReflection } from "./reflection";
|
|
import {
|
|
extractReview,
|
|
saveReviewToFile as saveReviewJson,
|
|
loadReview as loadReviewJson,
|
|
verdictGlyph,
|
|
verdictSummary,
|
|
} from "./review";
|
|
import {
|
|
createWorktree,
|
|
mergeWorktree,
|
|
removeWorktree,
|
|
reattemptMerge,
|
|
abortMerge,
|
|
hasMergeConflicts,
|
|
completeMerge,
|
|
type WorktreeHandle,
|
|
type MergeResult,
|
|
} from "./worktree";
|
|
import {
|
|
runAgentSession,
|
|
writeFileSafe,
|
|
ensureDir,
|
|
captureGitCommits,
|
|
captureGitHead,
|
|
getCommitRangeDiff,
|
|
hasUncommittedChanges,
|
|
getGitStatusPorcelain,
|
|
getGitDiff,
|
|
resolveModelSpec,
|
|
formatDuration,
|
|
} from "./utils";
|
|
import { updateTaskInFile } from "./parser";
|
|
|
|
/** Optional callback to post a progress message into the chat history. */
|
|
export type SendChatMessage = (
|
|
content: string,
|
|
/** Extra data passed to the message renderer for the expanded view. */
|
|
meta?: {
|
|
toolCalls?: ToolCallEntry[];
|
|
/** Full review body for review messages — renderer shows it in the
|
|
* expanded (Ctrl+O) view so long reviews aren't lost to truncation. */
|
|
reviewText?: string;
|
|
/** Saved file path when the review has been persisted to disk. */
|
|
reviewPath?: string;
|
|
/** Structured review result (when extractReview succeeded). */
|
|
reviewResult?: ReviewResult;
|
|
},
|
|
) => void;
|
|
|
|
export interface ToolCallEntry {
|
|
name: string;
|
|
label: string;
|
|
}
|
|
|
|
/** A merge conflict deferred from executeTask to batch-level resolution. */
|
|
export interface BatchConflict {
|
|
task: Task;
|
|
worktree: WorktreeHandle;
|
|
mergeResult: MergeResult;
|
|
/** The task's run result (reflection, commits, etc.) from executeTask. */
|
|
result: {
|
|
reflection?: Reflection;
|
|
toolUsage?: ToolUsage;
|
|
outputPreview?: string;
|
|
commitMessages?: string[];
|
|
commitSummary?: string;
|
|
durationMs: number;
|
|
};
|
|
}
|
|
|
|
// ─── Widget Expand/Collapse ───────────────────────────────────────────────
|
|
|
|
/** Max tool calls shown in a live widget before truncating. Widgets don't
|
|
* support message-style Ctrl+O expansion (that's only for chat-history
|
|
* messages rendered by registerMessageRenderer). */
|
|
const MAX_COLLAPSED = 3;
|
|
|
|
export const SPINNER_FRAMES = [
|
|
"⠋",
|
|
"⠙",
|
|
"⠹",
|
|
"⠸",
|
|
"⠼",
|
|
"⠴",
|
|
"⠦",
|
|
"⠧",
|
|
"⠇",
|
|
"⠏",
|
|
];
|
|
|
|
// ─── Model Round-Robin ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Round-robin model assignment with slot reuse.
|
|
*
|
|
* With models [A, B, C] and 2 concurrent tasks, only A and B are used.
|
|
* Model C is only touched when a third concurrent task starts.
|
|
* Freed slots are reused before new slots are allocated.
|
|
*/
|
|
class ModelRoundRobin {
|
|
private models: unknown[];
|
|
private freeSlots: number[];
|
|
private nextIndex = 0;
|
|
private assignments = new Map<string, number>();
|
|
|
|
constructor(models: unknown[]) {
|
|
this.models = models;
|
|
this.freeSlots = [];
|
|
}
|
|
|
|
get length(): number {
|
|
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 {
|
|
let index: number;
|
|
if (this.freeSlots.length > 0) {
|
|
// Reuse a freed model slot first
|
|
index = this.freeSlots.shift()!;
|
|
} else if (this.nextIndex < this.models.length) {
|
|
// Allocate a new slot
|
|
index = this.nextIndex++;
|
|
} else {
|
|
// All models in use — wrap around
|
|
index = this.nextIndex % this.models.length;
|
|
this.nextIndex++;
|
|
}
|
|
this.assignments.set(taskId, index);
|
|
return this.models[index];
|
|
}
|
|
|
|
release(taskId: string): void {
|
|
const index = this.assignments.get(taskId);
|
|
if (index !== undefined) {
|
|
this.freeSlots.push(index);
|
|
this.freeSlots.sort((a, b) => a - b);
|
|
this.assignments.delete(taskId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Advance a task to the next model slot without going through freed slots.
|
|
* Used for model failover — when the current model is down, skip to the
|
|
* next one instead of re-assigning the same freed index.
|
|
*/
|
|
advance(taskId: string): unknown {
|
|
const currentIndex = this.assignments.get(taskId);
|
|
if (currentIndex === undefined) {
|
|
// No current assignment — fresh assign (fallback, shouldn't happen)
|
|
return this.assign(taskId);
|
|
}
|
|
// If this index was freed (e.g. from an earlier release call that raced),
|
|
// remove it from freeSlots so it's not handed out to another task.
|
|
const freeIdx = this.freeSlots.indexOf(currentIndex);
|
|
if (freeIdx !== -1) this.freeSlots.splice(freeIdx, 1);
|
|
// Advance to the next index (circular)
|
|
const nextIndex = (currentIndex + 1) % this.models.length;
|
|
this.assignments.set(taskId, nextIndex);
|
|
return this.models[nextIndex];
|
|
}
|
|
}
|
|
|
|
/** Shared state for parallel-batch widget. Each running task writes its
|
|
* tool calls and spinner frame; the batch widget reads them in task-ID order. */
|
|
interface ParallelWidgetEntry {
|
|
taskHeader: string;
|
|
frameIndex: number;
|
|
done: boolean;
|
|
success: boolean;
|
|
toolCalls: ToolCallEntry[];
|
|
}
|
|
|
|
type ParallelWidgetState = Map<string, ParallelWidgetEntry>;
|
|
|
|
// ─── Run Single Task ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Execute a single task by spawning an async Pi agent session.
|
|
* Non-blocking — the TUI remains responsive throughout.
|
|
*/
|
|
export async function runTask(
|
|
task: Task,
|
|
project: Project,
|
|
config: RalpiConfig,
|
|
depReflections: Reflection[],
|
|
ctx: ExtensionContext,
|
|
sendChatMessage?: SendChatMessage,
|
|
projectDir: string = project.sourceDir,
|
|
parallelState?: ParallelWidgetState,
|
|
assignedModel?: unknown,
|
|
batchRender?: () => void,
|
|
/** Review feedback from a rejected review — injected when re-executing
|
|
* a task in review-gated mode so the agent knows what to fix. */
|
|
reviewFeedback?: ReviewResult,
|
|
): Promise<{
|
|
success: boolean;
|
|
reflection?: Reflection;
|
|
error?: string;
|
|
durationMs: number;
|
|
toolUsage?: ToolUsage;
|
|
outputPreview?: string;
|
|
commitMessages?: string[];
|
|
commitSummary?: string;
|
|
}> {
|
|
const startMs = Date.now();
|
|
|
|
// Build prompt
|
|
const prompt = buildTaskPrompt(
|
|
task,
|
|
project,
|
|
depReflections,
|
|
config.prompts.projectContext,
|
|
reviewFeedback,
|
|
);
|
|
|
|
const taskHeader = `${task.id} · ${task.title}`;
|
|
|
|
// When running in parallel, all tasks share a single widget so ordering
|
|
// is deterministic (sorted by task ID). In sequential mode each task gets
|
|
// its own widget.
|
|
const isParallel = !!parallelState;
|
|
const widgetKey = `ralpi-task-${task.id}`;
|
|
let frameIndex = 0;
|
|
const toolCalls: ToolCallEntry[] = [];
|
|
let widgetTui: { requestRender(): void } | null = null;
|
|
|
|
if (isParallel) {
|
|
parallelState!.set(task.id, {
|
|
taskHeader,
|
|
frameIndex: 0,
|
|
done: false,
|
|
success: false,
|
|
toolCalls: [],
|
|
});
|
|
} else {
|
|
// Build widget lines from current state. Live widgets can't expand/collapse
|
|
// like chat messages, so we always truncate to MAX_COLLAPSED recent calls.
|
|
const truncateWidth = 74; // Account for widget container padding
|
|
const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => {
|
|
const effectiveWidth = width
|
|
? Math.min(width, truncateWidth)
|
|
: truncateWidth;
|
|
const frame = t.fg("accent", SPINNER_FRAMES[frameIndex]);
|
|
const lines = [truncateToWidth(`${frame} ${taskHeader}`, 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();
|
|
|
|
// Spinner animation (sequential only — parallel uses a single batch timer)
|
|
let spinnerTimer: NodeJS.Timeout | undefined;
|
|
if (!isParallel) {
|
|
spinnerTimer = setInterval(() => {
|
|
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
|
requestRender();
|
|
}, 100);
|
|
}
|
|
|
|
// Use task-level timeout if set, otherwise fall back to config
|
|
const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs;
|
|
|
|
// Run task asynchronously via Pi SDK — event loop stays responsive
|
|
const output = 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,
|
|
});
|
|
if (isParallel) {
|
|
const entry = parallelState!.get(task.id);
|
|
if (entry) {
|
|
entry.toolCalls.push({ name: event.toolName, label });
|
|
}
|
|
batchRender?.();
|
|
} else {
|
|
requestRender();
|
|
}
|
|
}
|
|
},
|
|
undefined, // no abort signal
|
|
assignedModel ?? config.model,
|
|
config.thinkingLevel,
|
|
false, // noSkills — task sessions need skills
|
|
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
|
);
|
|
|
|
const durationMs = Date.now() - startMs;
|
|
|
|
// Clear progress widget and status after task finishes
|
|
if (spinnerTimer) clearInterval(spinnerTimer);
|
|
if (isParallel) {
|
|
const entry = parallelState!.get(task.id);
|
|
if (entry) {
|
|
entry.done = true;
|
|
entry.success = output.success;
|
|
}
|
|
batchRender?.();
|
|
} else {
|
|
ctx.ui.setWidget(widgetKey, undefined);
|
|
}
|
|
|
|
if (!output.success) {
|
|
// Failure reporting is handled by the caller (executeTask) to avoid
|
|
// duplicate messages when model failover or retry cycling is active.
|
|
return {
|
|
success: false,
|
|
error: output.error,
|
|
durationMs,
|
|
};
|
|
}
|
|
|
|
const agentText = output.text;
|
|
const toolUsage = output.toolUsage;
|
|
|
|
// Capture git commits made during this task
|
|
const { commitMessages, commitSummary } = captureGitCommits(projectDir);
|
|
|
|
// Build output preview (first 500 chars of agent text)
|
|
const outputPreview =
|
|
agentText.length > 500
|
|
? agentText.slice(0, 500) + "\n... (truncated)"
|
|
: agentText;
|
|
|
|
// Extract reflection from agent output
|
|
const reflection = extractReflection(agentText, task.id, task.title);
|
|
|
|
// Post completion chat message — header only, renderer builds the expandable tree
|
|
const dur = formatDuration(durationMs);
|
|
sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls });
|
|
|
|
return {
|
|
success: true,
|
|
reflection: reflection ?? undefined,
|
|
durationMs,
|
|
toolUsage,
|
|
outputPreview,
|
|
commitMessages,
|
|
commitSummary,
|
|
};
|
|
}
|
|
|
|
// ─── Execute Batch ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Execute a batch of tasks (sequentially or in parallel)
|
|
*/
|
|
// ─── Worktree Decision ──────────────────────────────────────────────────────
|
|
|
|
/** Determine if worktree isolation should be used based on config + mode. */
|
|
function shouldUseWorktrees(config: RalpiConfig, isParallel: boolean): boolean {
|
|
switch (config.execution.worktrees) {
|
|
case "always":
|
|
return true;
|
|
case "parallel":
|
|
return isParallel;
|
|
default:
|
|
return false; // "never"
|
|
}
|
|
}
|
|
|
|
export async function executeBatch(
|
|
tasks: Task[],
|
|
project: Project,
|
|
config: RalpiConfig,
|
|
progress: ProgressTracker,
|
|
ctx: ExtensionContext,
|
|
options?: { parallel?: boolean },
|
|
sendChatMessage?: SendChatMessage,
|
|
projectDir?: string,
|
|
): Promise<void> {
|
|
// Defensive: ensure tasks is an iterable array
|
|
if (!Array.isArray(tasks)) {
|
|
throw new Error(
|
|
`executeBatch received invalid tasks: expected array, got ${typeof tasks}`,
|
|
);
|
|
}
|
|
|
|
// Set up model round-robin if configured.
|
|
// Config entries are "<provider>/<model>" strings — resolve via modelRegistry.
|
|
let roundRobin: ModelRoundRobin | null = null;
|
|
if (config.execution.models.length > 0) {
|
|
const resolvedModels: unknown[] = [];
|
|
for (const entry of config.execution.models) {
|
|
const slashIdx = entry.indexOf("/");
|
|
if (slashIdx === -1) {
|
|
ctx.ui.notify(
|
|
`ralpi config: skipping model "${entry}" — expected <provider>/<model> format`,
|
|
"warning",
|
|
);
|
|
continue;
|
|
}
|
|
const provider = entry.slice(0, slashIdx);
|
|
const modelId = entry.slice(slashIdx + 1);
|
|
const resolved = ctx.modelRegistry?.find(provider, modelId);
|
|
if (resolved) {
|
|
resolvedModels.push(resolved);
|
|
} else {
|
|
ctx.ui.notify(
|
|
`ralpi config: model "${entry}" not found in registry — skipping`,
|
|
"warning",
|
|
);
|
|
}
|
|
}
|
|
if (resolvedModels.length > 0) {
|
|
roundRobin = new ModelRoundRobin(resolvedModels);
|
|
}
|
|
}
|
|
|
|
// Check if we should run parallel.
|
|
// Use the parallel path whenever the user selected parallel mode,
|
|
// even for single-task batches produced by DAG dependency chains.
|
|
// Only sequential mode should inherit the parent session model.
|
|
const shouldParallel =
|
|
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
|
|
|
|
const useWorktree = shouldUseWorktrees(config, !!shouldParallel);
|
|
|
|
const conflicts: BatchConflict[] = [];
|
|
|
|
if (shouldParallel) {
|
|
await executeBatchParallel(
|
|
tasks,
|
|
project,
|
|
config,
|
|
progress,
|
|
ctx,
|
|
sendChatMessage,
|
|
projectDir,
|
|
roundRobin,
|
|
useWorktree,
|
|
conflicts,
|
|
);
|
|
} else {
|
|
// Execute sequentially (no round-robin — inherit parent model)
|
|
for (const task of tasks) {
|
|
try {
|
|
await executeTask(
|
|
task,
|
|
project,
|
|
config,
|
|
progress,
|
|
ctx,
|
|
sendChatMessage,
|
|
projectDir,
|
|
undefined, // parallelState
|
|
undefined, // assignedModel
|
|
undefined, // roundRobin
|
|
undefined, // batchRender
|
|
useWorktree,
|
|
conflicts,
|
|
);
|
|
} catch (error) {
|
|
// Task failed — stop the batch. Dependent tasks are blocked by
|
|
// the DAG layer (getBlockedTasks) so they won't appear in this batch.
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
progress.markFailed(task.id, errorMsg);
|
|
// Auto-update the PRD source file checkbox
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
|
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Batch-level conflict resolution ──
|
|
// After all tasks in the batch finish, resolve any deferred merge conflicts
|
|
// by spawning resolution agent sessions. This doesn't block parallel slots.
|
|
if (conflicts.length > 0) {
|
|
ctx.ui.notify(
|
|
`Resolving ${conflicts.length} merge conflict(s) from batch...`,
|
|
"info",
|
|
);
|
|
const dir = projectDir ?? project.sourceDir;
|
|
for (const c of conflicts) {
|
|
await resolveConflictsSession(
|
|
ctx,
|
|
config,
|
|
c.task,
|
|
project,
|
|
dir,
|
|
c.worktree,
|
|
config.model,
|
|
roundRobin,
|
|
progress,
|
|
sendChatMessage,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute tasks in parallel using child processes
|
|
*/
|
|
async function executeBatchParallel(
|
|
tasks: Task[],
|
|
project: Project,
|
|
config: RalpiConfig,
|
|
progress: ProgressTracker,
|
|
ctx: ExtensionContext,
|
|
sendChatMessage?: SendChatMessage,
|
|
projectDir?: string,
|
|
roundRobin?: ModelRoundRobin | null,
|
|
useWorktree?: boolean,
|
|
conflicts?: BatchConflict[],
|
|
): Promise<void> {
|
|
const maxParallel = config.execution.maxParallel;
|
|
const sharedState: ParallelWidgetState = new Map();
|
|
|
|
// Register a single batch widget that renders ALL parallel tasks in ID order.
|
|
const widgetKey = `ralpi-parallel-${Date.now()}`;
|
|
let widgetTui: { requestRender(): void } | null = null;
|
|
|
|
const buildBatchLines = (
|
|
t: typeof ctx.ui.theme,
|
|
width?: number,
|
|
): string[] => {
|
|
const effectiveWidth = width || 74;
|
|
const lines: string[] = [];
|
|
const sortedIds = Array.from(sharedState.keys()).sort();
|
|
|
|
for (const id of sortedIds) {
|
|
const entry = sharedState.get(id)!;
|
|
const frame = entry.done
|
|
? entry.success
|
|
? "✓"
|
|
: "✗"
|
|
: t.fg("accent", SPINNER_FRAMES[entry.frameIndex]);
|
|
lines.push(
|
|
truncateToWidth(`${frame} ${entry.taskHeader}`, effectiveWidth),
|
|
);
|
|
|
|
// Only show tool calls for in-progress tasks; completed/failed
|
|
// tasks already have their tool-call tree in the chat history message.
|
|
if (!entry.done && entry.toolCalls.length > 0) {
|
|
if (entry.toolCalls.length <= MAX_COLLAPSED) {
|
|
for (let i = 0; i < entry.toolCalls.length; i++) {
|
|
const tc = entry.toolCalls[i];
|
|
const isLast = i === entry.toolCalls.length - 1;
|
|
const branch = isLast ? " └── " : " ├── ";
|
|
const tag = t.fg("accent", `[${tc.name}]`);
|
|
lines.push(
|
|
truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth),
|
|
);
|
|
}
|
|
} else {
|
|
const shown = entry.toolCalls.slice(-MAX_COLLAPSED);
|
|
const remaining = entry.toolCalls.length - shown.length;
|
|
lines.push(
|
|
truncateToWidth(
|
|
t.fg("dim", ` ├── …${remaining} earlier`),
|
|
effectiveWidth,
|
|
),
|
|
);
|
|
for (let i = 0; i < shown.length; i++) {
|
|
const tc = shown[i];
|
|
const isLast = i === shown.length - 1;
|
|
const branch = isLast ? " └── " : " ├── ";
|
|
const tag = t.fg("accent", `[${tc.name}]`);
|
|
lines.push(
|
|
truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return lines;
|
|
};
|
|
|
|
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
|
widgetTui = tui;
|
|
return {
|
|
render: (width?: number) => buildBatchLines(t, width),
|
|
invalidate: () => widgetTui?.requestRender(),
|
|
};
|
|
});
|
|
|
|
// Batch-render trigger: re-render on spinner ticks AND content changes.
|
|
// Spinner animation requires requestRender() on every tick; without it,
|
|
// spinner frames advance in memory but the display never updates.
|
|
const requestBatchRender = () => widgetTui?.requestRender();
|
|
|
|
const spinnerTimer = setInterval(() => {
|
|
for (const entry of sharedState.values()) {
|
|
if (!entry.done) {
|
|
entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length;
|
|
}
|
|
}
|
|
requestBatchRender();
|
|
}, 100);
|
|
|
|
// Semaphore-based concurrency control:
|
|
// Start up to maxParallel tasks immediately. When ANY task completes,
|
|
// start the next pending task. This ensures slots fill as soon as they
|
|
// open, instead of blocking on the oldest task (FIFO pattern).
|
|
const pending = [...tasks];
|
|
const running = new Set<Promise<void>>();
|
|
|
|
/** Start the next pending task if a slot is available. */
|
|
const kick = (): void => {
|
|
while (running.size < maxParallel && pending.length > 0) {
|
|
const task = pending.shift()!;
|
|
const assignedModel = roundRobin?.assign(task.id);
|
|
|
|
const p = executeTask(
|
|
task,
|
|
project,
|
|
config,
|
|
progress,
|
|
ctx,
|
|
sendChatMessage,
|
|
projectDir,
|
|
sharedState,
|
|
assignedModel,
|
|
roundRobin,
|
|
requestBatchRender,
|
|
useWorktree,
|
|
conflicts,
|
|
)
|
|
.catch((error) => {
|
|
// Safety net: one task failure should never crash the batch.
|
|
// executeTask already marks failed and notifies, but catch as
|
|
// a last resort so the error doesn't propagate and crash pi.
|
|
roundRobin?.release(task.id);
|
|
requestBatchRender();
|
|
const errorMsg =
|
|
error instanceof Error ? error.message : String(error);
|
|
progress.markFailed(task.id, errorMsg);
|
|
// Auto-update the PRD source file checkbox
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
|
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
|
})
|
|
.finally(() => {
|
|
// Remove from running set and start next pending task
|
|
running.delete(p);
|
|
requestBatchRender();
|
|
kick();
|
|
});
|
|
|
|
running.add(p);
|
|
}
|
|
};
|
|
|
|
// Kick off initial batch of tasks (up to maxParallel)
|
|
kick();
|
|
|
|
// Wait for all tasks to complete (kick() adds new promises to `running`
|
|
// when completed tasks free up slots, so we iterate until the set is empty).
|
|
while (running.size > 0) {
|
|
await Promise.race(running);
|
|
}
|
|
|
|
clearInterval(spinnerTimer);
|
|
ctx.ui.setWidget(widgetKey, undefined);
|
|
}
|
|
|
|
// ─── Execute Single Task with Retry ──────────────────────────────────────────
|
|
|
|
async function executeTask(
|
|
task: Task,
|
|
project: Project,
|
|
config: RalpiConfig,
|
|
progress: ProgressTracker,
|
|
ctx: ExtensionContext,
|
|
sendChatMessage?: SendChatMessage,
|
|
projectDir: string = project.sourceDir,
|
|
parallelState?: ParallelWidgetState,
|
|
assignedModel?: unknown,
|
|
roundRobin?: ModelRoundRobin | null,
|
|
batchRender?: () => void,
|
|
useWorktree?: boolean,
|
|
conflicts?: BatchConflict[],
|
|
): Promise<void> {
|
|
// Model failover: when a provider/API is down, cycle through available models.
|
|
// Pi's built-in retry (via SettingsManager) handles transient errors with
|
|
// exponential backoff within each model. Ralpi only handles model cycling.
|
|
const maxModelAttempts = roundRobin ? roundRobin.length : 1;
|
|
let modelAttempt = 0;
|
|
// 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;
|
|
|
|
// ── Worktree isolation ──
|
|
// When enabled, the task runs in a separate git worktree so parallel tasks
|
|
// can't stomp each other's files, and review/commit see a clean single-task
|
|
// diff. `worktreeDir` is used for agent cwd + git ops; `projectDir` stays as
|
|
// the main repo dir for state saves (reflections, reviews, progress.json).
|
|
const wt = useWorktree
|
|
? createWorktree(
|
|
projectDir,
|
|
config.paths.stateDir,
|
|
task.id,
|
|
progress.getKey(),
|
|
undefined,
|
|
task.title,
|
|
)
|
|
: null;
|
|
const worktreeDir = wt?.dir ?? projectDir;
|
|
|
|
while (modelAttempt < maxModelAttempts) {
|
|
// On subsequent model attempts, advance to the next model.
|
|
// Uses advance() instead of assign() so we don't get stuck on
|
|
// the same freed slot when the current model is down.
|
|
if (modelAttempt > 0 && roundRobin) {
|
|
currentModel = roundRobin.advance(task.id);
|
|
}
|
|
|
|
try {
|
|
// Mark as in progress
|
|
progress.markInProgress(task.id);
|
|
// Auto-update the PRD source file checkbox
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "in_progress");
|
|
} catch {
|
|
// Best-effort: don't fail the task over a checkbox update
|
|
}
|
|
|
|
// Get dependency reflections
|
|
const depReflections = progress.getDependencyReflections(
|
|
task.dependencies || [],
|
|
);
|
|
|
|
// Capture base HEAD before execution so the review-gated loop can diff
|
|
// the complete task output (baseRef..HEAD) across execution + fix attempts.
|
|
const baseRef = config.execution.autoReview
|
|
? 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,
|
|
project,
|
|
config,
|
|
depReflections,
|
|
ctx,
|
|
sendChatMessage,
|
|
worktreeDir,
|
|
parallelState,
|
|
currentModel,
|
|
batchRender,
|
|
priorReview,
|
|
);
|
|
|
|
if (result.success) {
|
|
let finalCommitMessages = result.commitMessages ?? [];
|
|
let finalCommitSummary = result.commitSummary ?? "";
|
|
let finalReview: ReviewResult | undefined;
|
|
let reviewRetries = 0;
|
|
|
|
if (config.execution.autoReview) {
|
|
// ── Review-gated loop: commit → review → re-execute on fail → merge on pass ──
|
|
// The commit is mandated — when the task agent didn't self-commit, a
|
|
// commit session handles it. Then the COMPLETE task diff (baseRef..HEAD)
|
|
// is reviewed. On 'fail' the task is re-executed with the review feedback
|
|
// injected (up to maxReviewRetries); after re-execution changes are
|
|
// committed again and the full diff is re-reviewed with the SAME baseRef
|
|
// so the reviewer sees the complete state, not just incremental fixes.
|
|
// On pass the changes are already committed — the worktree merges next.
|
|
const maxRetries = config.execution.maxReviewRetries;
|
|
let attempt = 0;
|
|
|
|
try {
|
|
// ── Ensure committed (commit session fallback) ──
|
|
// If the task agent didn't self-commit, a commit session handles it.
|
|
if (hasUncommittedChanges(worktreeDir)) {
|
|
const commitResult = await runCommitSession(
|
|
ctx,
|
|
config,
|
|
task,
|
|
worktreeDir,
|
|
currentModel,
|
|
roundRobin,
|
|
sendChatMessage,
|
|
);
|
|
if (commitResult.success) {
|
|
finalCommitMessages = [
|
|
...finalCommitMessages,
|
|
...commitResult.commitMessages,
|
|
];
|
|
finalCommitSummary = finalCommitSummary
|
|
? `${finalCommitSummary}; ${commitResult.commitSummary}`
|
|
: commitResult.commitSummary;
|
|
}
|
|
}
|
|
|
|
// ── Review loop ──
|
|
// baseRef was captured before runTask (above). Each review iteration
|
|
// diffs the range baseRef..HEAD — the complete task output including
|
|
// all fix attempts. On re-execution the same baseRef is reused.
|
|
while (true) {
|
|
const reviewInfo = baseRef
|
|
? getCommitRangeDiff(worktreeDir, baseRef)
|
|
: null;
|
|
if (!reviewInfo || !reviewInfo.diff) {
|
|
const reason = !baseRef
|
|
? "could not capture base ref before execution"
|
|
: "no changes found between base and HEAD";
|
|
sendChatMessage?.(
|
|
`~ review for ${task.id} · ${task.title} — skipping review (${reason})`,
|
|
);
|
|
break;
|
|
}
|
|
|
|
const reviewPrompt = buildReviewPrompt(
|
|
task,
|
|
project,
|
|
reviewInfo.hash,
|
|
reviewInfo.subject,
|
|
reviewInfo.diff,
|
|
config.prompts.projectContext,
|
|
);
|
|
|
|
const reviewModel = resolveFollowUpModel(
|
|
ctx,
|
|
config.execution.reviewModel,
|
|
currentModel,
|
|
);
|
|
const reviewModels = buildFailoverModels(reviewModel, roundRobin);
|
|
|
|
const { result: reviewResult, toolCalls: reviewToolCalls } =
|
|
await runFollowUpSession(
|
|
ctx,
|
|
config,
|
|
reviewPrompt,
|
|
worktreeDir,
|
|
`review for ${task.id} · ${task.title}${
|
|
attempt > 0 ? ` (attempt ${attempt + 1})` : ""
|
|
}`,
|
|
`review-${task.id}`,
|
|
config.execution.reviewTimeoutMs,
|
|
reviewModels,
|
|
);
|
|
|
|
if (!reviewResult.success) {
|
|
sendChatMessage?.(
|
|
`~ review for ${task.id} · ${task.title} — review session failed: ${reviewResult.error}`,
|
|
{ toolCalls: reviewToolCalls },
|
|
);
|
|
break; // proceed with what we have (changes already committed)
|
|
}
|
|
|
|
const reviewText = reviewResult.text.trim();
|
|
const review = extractReview(
|
|
reviewText,
|
|
task.id,
|
|
reviewInfo.hash,
|
|
);
|
|
finalReview = review ?? undefined;
|
|
|
|
// Persist structured review JSON when opted in.
|
|
let reviewPath: string | undefined;
|
|
if (review && config.execution.saveReviews) {
|
|
reviewPath = saveReviewJson(
|
|
projectDir,
|
|
config.paths.reviewsDir,
|
|
review,
|
|
progress.getKey(),
|
|
);
|
|
}
|
|
|
|
if (
|
|
review &&
|
|
(review.verdict === "pass" || review.verdict === "warn")
|
|
) {
|
|
// Review passed — all changes are committed; merge will follow.
|
|
const label = `${verdictGlyph(review.verdict)} ${verdictSummary(review)}`;
|
|
const savedHint = reviewPath ? ` · saved to ${reviewPath}` : "";
|
|
sendChatMessage?.(
|
|
`⚑ review for ${task.id} · ${task.title} — ${label}${savedHint}`,
|
|
{
|
|
toolCalls: reviewToolCalls,
|
|
reviewText,
|
|
reviewPath,
|
|
reviewResult: review,
|
|
},
|
|
);
|
|
break; // good to merge
|
|
}
|
|
|
|
// Review rejected (fail) or verdict not parsed.
|
|
if (review) {
|
|
sendChatMessage?.(
|
|
`⚑ review for ${task.id} · ${task.title} — ${verdictGlyph(review.verdict)} ${verdictSummary(review)}`,
|
|
{
|
|
toolCalls: reviewToolCalls,
|
|
reviewText,
|
|
reviewPath,
|
|
reviewResult: review,
|
|
},
|
|
);
|
|
} else {
|
|
const lines = reviewText.split("\n").filter((l) => l.trim());
|
|
const tail = lines.slice(-3).join("\n");
|
|
const savedHint = reviewPath ? ` · saved to ${reviewPath}` : "";
|
|
sendChatMessage?.(
|
|
`⚑ review for ${task.id} · ${task.title} — verdict not found${savedHint}\n${tail}`,
|
|
{ toolCalls: reviewToolCalls, reviewText, reviewPath },
|
|
);
|
|
}
|
|
|
|
if (attempt >= maxRetries) {
|
|
// Retries exhausted — changes are already committed; proceed.
|
|
if (config.execution.reviewBlockOnFail) {
|
|
sendChatMessage?.(
|
|
`✗ ${task.id} · ${task.title} — review rejected after ${maxRetries} retr${maxRetries === 1 ? "y" : "ies"} (reviewBlockOnFail)`,
|
|
);
|
|
progress.markFailed(
|
|
task.id,
|
|
`Review rejected after ${maxRetries} re-execution attempt(s)`,
|
|
);
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
roundRobin?.release(task.id);
|
|
return;
|
|
}
|
|
sendChatMessage?.(
|
|
`~ review for ${task.id} · ${task.title} — max retries (${maxRetries}) exhausted, proceeding with current state`,
|
|
);
|
|
break; // changes already committed — merge proceeds
|
|
}
|
|
|
|
attempt++;
|
|
reviewRetries++;
|
|
sendChatMessage?.(
|
|
`↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`,
|
|
);
|
|
|
|
// 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 || !fixResult.success) {
|
|
sendChatMessage?.(
|
|
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult?.error}`,
|
|
);
|
|
break; // proceed with what we have
|
|
}
|
|
|
|
// Merge commit messages from the fix attempt.
|
|
finalCommitMessages = [
|
|
...finalCommitMessages,
|
|
...(fixResult.commitMessages ?? []),
|
|
];
|
|
finalCommitSummary = finalCommitSummary
|
|
? `${finalCommitSummary}; ${fixResult.commitSummary ?? ""}`
|
|
: (fixResult.commitSummary ?? "");
|
|
|
|
// Ensure committed after re-execution (same commit fallback).
|
|
if (hasUncommittedChanges(worktreeDir)) {
|
|
const commitResult = await runCommitSession(
|
|
ctx,
|
|
config,
|
|
task,
|
|
worktreeDir,
|
|
currentModel,
|
|
roundRobin,
|
|
sendChatMessage,
|
|
);
|
|
if (commitResult.success) {
|
|
finalCommitMessages = [
|
|
...finalCommitMessages,
|
|
...commitResult.commitMessages,
|
|
];
|
|
finalCommitSummary = finalCommitSummary
|
|
? `${finalCommitSummary}; ${commitResult.commitSummary}`
|
|
: commitResult.commitSummary;
|
|
}
|
|
}
|
|
// Loop back to review with the same baseRef — the reviewer sees the
|
|
// complete diff (original work + fixes), not just incremental changes.
|
|
}
|
|
} catch (error) {
|
|
sendChatMessage?.(
|
|
`~ review/commit for ${task.id} · ${task.title} — error: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
} else if (config.execution.autoCommit) {
|
|
// ── Commit only (no review) ──
|
|
try {
|
|
if (hasUncommittedChanges(worktreeDir)) {
|
|
const commitResult = await runCommitSession(
|
|
ctx,
|
|
config,
|
|
task,
|
|
worktreeDir,
|
|
currentModel,
|
|
roundRobin,
|
|
sendChatMessage,
|
|
);
|
|
if (commitResult.success) {
|
|
finalCommitMessages = [
|
|
...finalCommitMessages,
|
|
...commitResult.commitMessages,
|
|
];
|
|
finalCommitSummary = finalCommitSummary
|
|
? `${finalCommitSummary}; ${commitResult.commitSummary}`
|
|
: commitResult.commitSummary;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
sendChatMessage?.(
|
|
`~ commit for ${task.id} · ${task.title} — auto-commit error: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Save reflection
|
|
if (result.reflection) {
|
|
saveReflectionToFile(
|
|
projectDir,
|
|
config,
|
|
result.reflection,
|
|
progress.getKey(),
|
|
);
|
|
}
|
|
|
|
// ── Merge worktree back to main ──
|
|
// After the commit lands on the worktree branch, merge it into the
|
|
// main repo so downstream tasks see the changes. On conflict, the
|
|
// conflict is deferred to batch-level resolution (the caller collects
|
|
// conflicted worktrees and spawns resolution sessions after the batch).
|
|
if (wt) {
|
|
const mergeResult = mergeWorktree(projectDir, wt.branch);
|
|
if (!mergeResult.success) {
|
|
sendChatMessage?.(
|
|
`⚠ ${task.id} · ${task.title} — merge conflict, deferring to batch resolution\n ${mergeResult.message}`,
|
|
);
|
|
// Defer conflict resolution to the batch level.
|
|
if (conflicts) {
|
|
conflicts.push({
|
|
task,
|
|
worktree: wt,
|
|
mergeResult,
|
|
result: {
|
|
reflection: result.reflection,
|
|
toolUsage: result.toolUsage,
|
|
outputPreview: result.outputPreview,
|
|
commitMessages: finalCommitMessages,
|
|
commitSummary: finalCommitSummary,
|
|
durationMs: result.durationMs,
|
|
},
|
|
});
|
|
} else {
|
|
// No conflict collector — mark failed as fallback.
|
|
progress.markFailed(task.id, mergeResult.message);
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
}
|
|
roundRobin?.release(task.id);
|
|
return;
|
|
}
|
|
// Merge succeeded — clean up the worktree.
|
|
removeWorktree(projectDir, wt);
|
|
sendChatMessage?.(`✓ merged worktree for ${task.id} into main`);
|
|
}
|
|
|
|
// Mark completed with all metadata
|
|
progress.markCompleted(
|
|
task.id,
|
|
result.durationMs,
|
|
result.reflection,
|
|
result.toolUsage,
|
|
result.outputPreview,
|
|
finalCommitMessages,
|
|
finalCommitSummary,
|
|
finalReview,
|
|
reviewRetries,
|
|
);
|
|
// Auto-update the PRD source file checkbox
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "completed");
|
|
} catch {
|
|
// Best-effort: don't fail the task over a checkbox update
|
|
}
|
|
roundRobin?.release(task.id);
|
|
return;
|
|
}
|
|
|
|
// Agent session failed (provider error).
|
|
// Pi's built-in retry already exhausted for this model. Cycle to the next.
|
|
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
|
|
modelAttempt++;
|
|
sendChatMessage?.(
|
|
`~ ${task.id} · ${task.title} — cycling to model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
|
|
);
|
|
continue; // next model in the outer while loop
|
|
}
|
|
|
|
// All models exhausted.
|
|
progress.markFailed(task.id, result.error || "Unknown error");
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`);
|
|
ctx.ui.notify(
|
|
`Task ${task.id} failed across ${maxModelAttempts} models: ${
|
|
result.error || "Unknown error"
|
|
}`,
|
|
"error",
|
|
);
|
|
if (wt) removeWorktree(projectDir, wt);
|
|
roundRobin?.release(task.id);
|
|
return;
|
|
} catch (error) {
|
|
roundRobin?.release(task.id);
|
|
batchRender?.();
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
progress.markFailed(task.id, errorMsg);
|
|
// Auto-update the PRD source file checkbox
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
|
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
|
if (wt) removeWorktree(projectDir, wt);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// All models exhausted — release the slot
|
|
roundRobin?.release(task.id);
|
|
batchRender?.();
|
|
progress.markFailed(task.id, "All configured models exhausted");
|
|
sendChatMessage?.(
|
|
`✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
|
|
);
|
|
ctx.ui.notify(
|
|
`Task ${task.id} failed: all configured models exhausted`,
|
|
"error",
|
|
);
|
|
if (wt) removeWorktree(projectDir, wt);
|
|
}
|
|
|
|
// ─── Save Reflection to File ────────────────────────────────────────────────
|
|
|
|
function saveReflectionToFile(
|
|
sourceDir: string,
|
|
config: RalpiConfig,
|
|
reflection: Reflection,
|
|
prdKey: string,
|
|
): void {
|
|
const reflectionsDir = path.join(
|
|
sourceDir,
|
|
config.paths.reflectionsDir,
|
|
prdKey,
|
|
);
|
|
ensureDir(reflectionsDir);
|
|
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
|
|
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
|
|
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
|
);
|
|
|
|
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 ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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 ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Shorthand type for the model registry's find() shape.
|
|
*/
|
|
type ModelRegistryLike = { find(p: string, m: string): unknown };
|
|
|
|
/**
|
|
* Resolve a model spec for a follow-up session (commit/review), falling back
|
|
* to `currentModel` when the config field is blank or the registry can't
|
|
* resolve it. Warns via `ctx.ui.notify` on resolution failure.
|
|
*/
|
|
function resolveFollowUpModel(
|
|
ctx: ExtensionContext,
|
|
spec: string,
|
|
currentModel: unknown,
|
|
): unknown {
|
|
return (
|
|
resolveModelSpec(
|
|
ctx.modelRegistry as ModelRegistryLike | undefined,
|
|
spec,
|
|
(msg) => ctx.ui.notify(msg, "warning"),
|
|
) ?? currentModel
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run the auto-commit follow-up agent session.
|
|
* Returns the commit messages, summary, tool calls, and success flag.
|
|
*/
|
|
async function runCommitSession(
|
|
ctx: ExtensionContext,
|
|
config: RalpiConfig,
|
|
task: Task,
|
|
projectDir: string,
|
|
currentModel: unknown,
|
|
roundRobin: ModelRoundRobin | null | undefined,
|
|
sendChatMessage?: SendChatMessage,
|
|
): Promise<{
|
|
commitMessages: string[];
|
|
commitSummary: string;
|
|
toolCalls: ToolCallEntry[];
|
|
success: boolean;
|
|
}> {
|
|
const status = getGitStatusPorcelain(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 = [
|
|
`## Auto-Commit for Task ${task.id}: ${task.title}`,
|
|
"",
|
|
"The previous task is complete. There are uncommitted changes in the repository.",
|
|
"",
|
|
"Only commit changes you made while completing this task. Do not commit pre-existing changes, changes from other work, or files unrelated to this task.",
|
|
"Review the git status and diff below to identify which changes are from your work, and stage only those files.",
|
|
"",
|
|
"Stage only the files relevant to this task with `git add <files>`, then create a meaningful git commit.",
|
|
"Use a descriptive commit message and follow conventional commits format.",
|
|
"Do NOT include the task number, task ID, or any ralpi task reference in the commit message. The commit message must describe only the work done — never mention the task ID (e.g. `task 03`, `#3`, etc.).",
|
|
"",
|
|
"### Current Changes (git status --porcelain)",
|
|
"```text",
|
|
status || "(no status output)",
|
|
"```",
|
|
"",
|
|
"### Current Tracked Diff (git diff)",
|
|
"```diff",
|
|
diff || "(no tracked diff output)",
|
|
diffNote,
|
|
"```",
|
|
].join("\n");
|
|
|
|
const commitModel = resolveFollowUpModel(
|
|
ctx,
|
|
config.execution.commitModel,
|
|
currentModel,
|
|
);
|
|
const commitModels = buildFailoverModels(commitModel, roundRobin);
|
|
|
|
const { result: commitResult, toolCalls: commitToolCalls } =
|
|
await runFollowUpSession(
|
|
ctx,
|
|
config,
|
|
commitPrompt,
|
|
projectDir,
|
|
`commit for ${task.id} · ${task.title}`,
|
|
`commit-${task.id}`,
|
|
config.execution.commitTimeoutMs,
|
|
commitModels,
|
|
);
|
|
|
|
if (commitResult.success) {
|
|
const newCommits = captureGitCommits(projectDir);
|
|
const commitMessages =
|
|
newCommits.commitMessages.length > 0 ? newCommits.commitMessages : [];
|
|
const commitSummary = newCommits.commitSummary || "";
|
|
sendChatMessage?.(`✓ commit for ${task.id} · ${task.title}`, {
|
|
toolCalls: commitToolCalls,
|
|
});
|
|
return {
|
|
commitMessages,
|
|
commitSummary,
|
|
toolCalls: commitToolCalls,
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
sendChatMessage?.(
|
|
`~ commit for ${task.id} · ${task.title} — follow-up commit session failed: ${commitResult.error}`,
|
|
{ toolCalls: commitToolCalls },
|
|
);
|
|
return {
|
|
commitMessages: [],
|
|
commitSummary: "",
|
|
toolCalls: commitToolCalls,
|
|
success: false,
|
|
};
|
|
}
|
|
|
|
// ─── Batch Conflict Resolution ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a merge conflict by spawning an agent session in the main repo.
|
|
*
|
|
* The merge was already attempted (and aborted) by `mergeWorktree` during
|
|
* `executeTask`. This function re-attempts the merge to recreate the conflict
|
|
* state, spawns an agent to resolve all conflict markers, stage, and commit,
|
|
* then verifies completion. On success, the worktree is cleaned up and the
|
|
* task is marked completed. On failure, the merge is aborted and the task
|
|
* is marked failed (the worktree is retained for inspection).
|
|
*
|
|
* Runs after all tasks in a batch finish, so parallel task slots aren't
|
|
* blocked waiting for conflict resolution.
|
|
*/
|
|
async function resolveConflictsSession(
|
|
ctx: ExtensionContext,
|
|
config: RalpiConfig,
|
|
task: Task,
|
|
project: Project,
|
|
projectDir: string,
|
|
worktree: WorktreeHandle,
|
|
currentModel: unknown,
|
|
roundRobin: ModelRoundRobin | null | undefined,
|
|
progress: ProgressTracker,
|
|
sendChatMessage?: SendChatMessage,
|
|
): Promise<void> {
|
|
const { branch } = worktree;
|
|
|
|
// Re-attempt the merge to recreate the conflict state in the main repo.
|
|
const attempt = reattemptMerge(projectDir, branch);
|
|
if (attempt.clean) {
|
|
// No conflicts on re-attempt — complete the merge directly.
|
|
if (completeMerge(projectDir)) {
|
|
sendChatMessage?.(
|
|
`✓ conflicts auto-resolved for ${task.id} · ${task.title}`,
|
|
);
|
|
removeWorktree(projectDir, worktree);
|
|
progress.markCompleted(
|
|
task.id,
|
|
0, // duration already tracked in executeTask
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
[],
|
|
"",
|
|
undefined,
|
|
0,
|
|
);
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "completed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
return;
|
|
}
|
|
// completeMerge failed — fall through to mark failed.
|
|
abortMerge(projectDir);
|
|
progress.markFailed(task.id, `Failed to complete merge of ${branch}`);
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Conflicts exist — spawn a resolution agent session.
|
|
const prompt = buildConflictResolutionPrompt(
|
|
task,
|
|
project,
|
|
attempt.conflicts,
|
|
branch,
|
|
config.prompts.projectContext,
|
|
);
|
|
|
|
const commitModel = resolveFollowUpModel(
|
|
ctx,
|
|
config.execution.commitModel,
|
|
currentModel,
|
|
);
|
|
const models = buildFailoverModels(commitModel, roundRobin);
|
|
|
|
sendChatMessage?.(
|
|
`⚑ resolving ${attempt.conflicts.length} conflict(s) for ${task.id} · ${task.title}...`,
|
|
);
|
|
|
|
const { result, toolCalls } = await runFollowUpSession(
|
|
ctx,
|
|
config,
|
|
prompt,
|
|
projectDir,
|
|
`resolve conflicts for ${task.id}`,
|
|
`resolve-${task.id}`,
|
|
config.execution.commitTimeoutMs,
|
|
models,
|
|
);
|
|
|
|
if (!result.success) {
|
|
sendChatMessage?.(
|
|
`~ conflict resolution for ${task.id} · ${task.title} — session failed: ${result.error}`,
|
|
{ toolCalls },
|
|
);
|
|
abortMerge(projectDir);
|
|
progress.markFailed(
|
|
task.id,
|
|
`Conflict resolution session failed: ${result.error}`,
|
|
);
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check if the agent actually resolved all conflicts and committed.
|
|
if (hasMergeConflicts(projectDir)) {
|
|
// Agent didn't resolve everything — abort and fail.
|
|
sendChatMessage?.(
|
|
`✗ ${task.id} · ${task.title} — conflict resolution incomplete, ${attempt.conflicts.length} file(s) still conflicted`,
|
|
{ toolCalls },
|
|
);
|
|
abortMerge(projectDir);
|
|
progress.markFailed(
|
|
task.id,
|
|
`Conflict resolution incomplete — unresolved conflicts remaining`,
|
|
);
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Success — conflicts resolved and merge committed.
|
|
sendChatMessage?.(`✓ conflicts resolved for ${task.id} · ${task.title}`, {
|
|
toolCalls,
|
|
});
|
|
removeWorktree(projectDir, worktree);
|
|
progress.markCompleted(
|
|
task.id,
|
|
0,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
[],
|
|
"",
|
|
undefined,
|
|
0,
|
|
);
|
|
try {
|
|
updateTaskInFile(project.sourcePath, task.id, "completed");
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip control characters and newlines from a display label so it
|
|
* does not break TUI layout (tree branches, text width calculation).
|
|
*/
|
|
function sanitizeLabel(s: string): string {
|
|
// Replace newlines/carriage returns with spaces (multi-line commands
|
|
// must fit on a single tree-branch line), then strip ASCII control
|
|
// characters except \t (which is harmless) and keep printable chars.
|
|
return s
|
|
.replace(/\r?\n/g, " ")
|
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Format a tool call argument into a short label.
|
|
*/
|
|
function formatToolArg(name: string, args: unknown): string {
|
|
const a = args as Record<string, unknown>;
|
|
switch (name) {
|
|
case "bash":
|
|
return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70));
|
|
case "write":
|
|
case "read":
|
|
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
|
|
case "edit":
|
|
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
|
|
case "grep":
|
|
return sanitizeLabel(
|
|
`${a.pattern ?? "?"} — ${truncateMiddle(String(a.path ?? ""), 40)}`,
|
|
);
|
|
case "find":
|
|
return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);
|
|
case "ls":
|
|
return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));
|
|
default:
|
|
return name;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Truncate a long string in the middle, keeping start and end visible.
|
|
*/
|
|
function truncateMiddle(s: string, maxLen: number): string {
|
|
if (s.length <= maxLen) return s;
|
|
const half = Math.floor((maxLen - 3) / 2);
|
|
return s.slice(0, half) + "…" + s.slice(s.length - half);
|
|
}
|