port: sync from Mike/ralpi@3cc7c046
This commit is contained in:
18
AGENTS.md
18
AGENTS.md
@@ -36,7 +36,7 @@ is unavailable).
|
||||
|
||||
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
|
||||
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
|
||||
prompts, reload auto-resume via `session_start`, progress message renderer
|
||||
prompts, progress message renderer
|
||||
- `src/` — all logic modules:
|
||||
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
||||
YAML formats), dependency + parallel-group + timeout parsing,
|
||||
@@ -69,18 +69,16 @@ is unavailable).
|
||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||
|
||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||
- `.ralpi/loop-active.json` — marker written while a loop runs; drives
|
||||
auto-resume after a session reload
|
||||
- `.ralpi/loop-active.json` — marker written while a loop runs; snapshots the
|
||||
mode + loop options so `/ralpi-resume` can continue non-interactively
|
||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
||||
`saveReviews` is on)
|
||||
- `.ralpi/sessions/` — agent session JSONL files per task (persisted so an
|
||||
interrupted task can be resumed from its prior conversation)
|
||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||
- `.ralpi/config.yaml` — project-level config (optional)
|
||||
|
||||
There is no `.ralpi/sessions/` directory anymore — full task output is shown
|
||||
inline via expandable `ralpi-progress` chat messages, and review output is
|
||||
persisted under `.ralpi/reviews/`.
|
||||
|
||||
## Task ID convention
|
||||
|
||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
|
||||
@@ -93,8 +91,10 @@ use raw numeric IDs.
|
||||
- `/ralpi` — no args → show plan for `README.md`; first token looks like a
|
||||
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
||||
error suggesting the dash commands
|
||||
- `/ralpi-run [task-file]` — run tasks (auto-resumes when progress already
|
||||
exists for the file; otherwise prompts for execution mode + loop options)
|
||||
- `/ralpi-run [task-file]` — run tasks (always prompts for execution mode +
|
||||
loop options; a cancelled loop for the file is continued from its progress
|
||||
with the newly chosen settings — it never silently resumes with the old
|
||||
loop's settings, only `/ralpi-resume` does that)
|
||||
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
|
||||
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
||||
with `expandPromptTemplates: false`, so the extension does its own
|
||||
|
||||
287
index.ts
287
index.ts
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
SessionStartEvent,
|
||||
} from "@oh-my-pi/pi-coding-agent";
|
||||
import { Box, Text } from "@oh-my-pi/pi-tui";
|
||||
import { parseTaskFile, updateTaskInFile } from "./src/parser";
|
||||
@@ -307,9 +306,10 @@ async function executePlanBatches(
|
||||
);
|
||||
}
|
||||
|
||||
// Write loop-active marker so a session reload can detect an interrupted
|
||||
// loop and resume it (in-process agent sessions die on reload — the marker
|
||||
// + progress.json in_progress tasks are the signal to re-run them).
|
||||
// Write the loop-active marker so an interrupted loop can be resumed
|
||||
// non-interactively via /ralpi-resume: it snapshots the execution mode and
|
||||
// loop options (autoCommit/autoReview/saveReviews) that /ralpi-resume
|
||||
// would otherwise re-prompt for.
|
||||
if (projectDir) {
|
||||
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
||||
writeLoopActive(projectDir, {
|
||||
@@ -765,240 +765,6 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Reload detection: resume interrupted loops when session reloads ──
|
||||
//
|
||||
// ralpi runs task agent sessions in-process (createAgentSession), so they
|
||||
// do NOT survive a /reload. When the new session starts, this handler
|
||||
// reads the persisted loop-active marker + progress.json: if any task is
|
||||
// still `in_progress`, the loop was interrupted mid-task and we resume it
|
||||
// (resetting those tasks to pending so the DAG re-schedules them), using
|
||||
// the mode + loop options snapshotted in loop-active.json so the resume is
|
||||
// non-interactive.
|
||||
pi.on("session_start", async (event: SessionStartEvent, ctx) => {
|
||||
// omp's SessionStartEvent has no reason/reload field; the in_progress-task
|
||||
// check below already scopes recovery to genuinely interrupted loops (a
|
||||
// completed loop has no in_progress tasks), so recovery runs on any start
|
||||
// where a stalled loop marker exists.
|
||||
|
||||
// Find the ralpi project directory
|
||||
const projectDir = findRalpiDir(ctx.cwd);
|
||||
if (!projectDir) return;
|
||||
|
||||
// Check if a task execution loop was active before the reload
|
||||
const loopState = readLoopActive(projectDir);
|
||||
if (!loopState) return;
|
||||
|
||||
// The auto-resume path has no CLI flag, so the gitignore guard is
|
||||
// always on: keep `.ralpi/` out of the user's repo on reload too.
|
||||
ensureRalpiIgnored(projectDir);
|
||||
|
||||
// Load progress state
|
||||
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
||||
|
||||
/** Re-read progress from disk. */
|
||||
const readTasks = (): Record<string, { status: string }> | null => {
|
||||
try {
|
||||
const raw = fs.readFileSync(progressPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, any>;
|
||||
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ralpi agent sessions run in-process (createAgentSession), so they do
|
||||
// NOT survive a session reload. Any task left `in_progress` is therefore
|
||||
// stalled — its agent died with the previous session. Detect that state
|
||||
// and actively resume the loop instead of passively polling (which would
|
||||
// spin forever waiting for a dead task to complete).
|
||||
const initialTasks = readTasks();
|
||||
if (initialTasks) {
|
||||
const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
||||
t.status === "in_progress" ? [id] : [],
|
||||
);
|
||||
|
||||
// Build the sendProgress wrapper so resumed task messages render the
|
||||
// same expandable tool-call tree as an interactive run. Defined before
|
||||
// the finalize path below so it can report self-healed merges.
|
||||
const sendProgress: SendChatMessage = (
|
||||
content: string,
|
||||
meta?: {
|
||||
toolCalls?: Array<{ name: string; label: string }>;
|
||||
reviewText?: string;
|
||||
reviewPath?: string;
|
||||
reviewResult?: ReviewResult;
|
||||
},
|
||||
) => {
|
||||
pi.sendMessage({
|
||||
customType: "ralpi-progress",
|
||||
content,
|
||||
display: true,
|
||||
details: {
|
||||
phase: "progress",
|
||||
toolCalls: meta?.toolCalls,
|
||||
reviewText: meta?.reviewText,
|
||||
reviewPath: meta?.reviewPath,
|
||||
reviewResult: meta?.reviewResult,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (inProgressIds.length === 0) {
|
||||
// Nothing was mid-flight — the loop either finished cleanly between
|
||||
// the reload landing and this handler running, or was stopped
|
||||
// between tasks. Either way, committed worktree branches from an
|
||||
// interrupted loop may still be unmerged (e.g. a prior resume
|
||||
// attempt reset tasks to pending before it was itself interrupted).
|
||||
// Finalize those first so committed code lands in the workspace,
|
||||
// persist the state to progress.json, update the PRD file, THEN
|
||||
// clean up the stale marker.
|
||||
try {
|
||||
const config = loadConfig(projectDir);
|
||||
// Clear any half-done merge left by an interrupted
|
||||
// conflict-resolution session (it would block every merge below).
|
||||
abortMerge(projectDir);
|
||||
const allIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
||||
t.status !== "failed" && t.status !== "pending" ? [id] : [],
|
||||
);
|
||||
const fin = finalizeCommittedWorktrees(
|
||||
projectDir,
|
||||
config.paths.stateDir,
|
||||
loopState.prdKey,
|
||||
allIds,
|
||||
);
|
||||
// Persist finalized tasks to progress.json + PRD file so the
|
||||
// state is correct for subsequent /ralpi resume calls.
|
||||
const stateDir = config.paths.stateDir;
|
||||
const progressPath = path.join(projectDir, stateDir, "progress.json");
|
||||
// Batch-update progress.json and PRD file for all finalized tasks
|
||||
if (fin.finalized.length > 0) {
|
||||
const progressRaw = fs.existsSync(progressPath)
|
||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
||||
: null;
|
||||
for (const id of fin.finalized) {
|
||||
sendProgress?.(
|
||||
`✓ ${id} — finalized on resume (committed branch merged into main)`,
|
||||
);
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "completed";
|
||||
tasks[id].completedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
try {
|
||||
const prdPath = loopState.taskFile;
|
||||
if (fs.existsSync(prdPath)) {
|
||||
updateTaskInFile(prdPath, id, "completed");
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (progressRaw) {
|
||||
fs.writeFileSync(
|
||||
progressPath,
|
||||
JSON.stringify(progressRaw, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
// ── Handle conflicted tasks ──
|
||||
// Same logic as resumeLoop: reset to pending so the DAG can
|
||||
// re-schedule them, keep the worktree for in-place re-run.
|
||||
const conflictIds = Object.keys(fin.conflicts);
|
||||
if (conflictIds.length > 0) {
|
||||
const detail = conflictIds
|
||||
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
|
||||
.join("; ");
|
||||
// Batch-reset all conflicted tasks to pending, then write once
|
||||
const progressRaw = fs.existsSync(progressPath)
|
||||
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
|
||||
: null;
|
||||
for (const id of conflictIds) {
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "pending";
|
||||
delete tasks[id].startedAt;
|
||||
delete tasks[id].error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const prdPath = loopState.taskFile;
|
||||
if (fs.existsSync(prdPath)) {
|
||||
updateTaskInFile(prdPath, id, "pending");
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (progressRaw) {
|
||||
fs.writeFileSync(
|
||||
progressPath,
|
||||
JSON.stringify(progressRaw, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — the marker is removed either way; the worktrees
|
||||
// stay on disk for a manual /ralpi-resume.
|
||||
}
|
||||
ctx.ui.notify(
|
||||
"ralpi loop has no in-progress task to resume — marking complete.",
|
||||
"info",
|
||||
);
|
||||
deleteLoopActive(projectDir);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskCount = loopState.taskIds.length;
|
||||
ctx.ui.notify(
|
||||
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
|
||||
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Load config from the project directory so model + thinking level
|
||||
// resolve the same way the interactive command handler does.
|
||||
const config = loadConfig(projectDir);
|
||||
|
||||
try {
|
||||
await resumeLoop(
|
||||
ctx,
|
||||
loopState.taskFile,
|
||||
projectDir,
|
||||
loopState.prdKey,
|
||||
sendProgress,
|
||||
config.model ?? ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
{
|
||||
mode: loopState.mode,
|
||||
autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
|
||||
autoReview: loopState.autoReview ?? config.execution.autoReview,
|
||||
saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
|
||||
skipFinalStatus: false,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error");
|
||||
// Leave loop-active.json in place so the user can retry via
|
||||
// /ralpi resume after addressing the underlying error.
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("ralpi", {
|
||||
description:
|
||||
"Execute tasks from a task file using DAG-based dependency resolution",
|
||||
@@ -1122,38 +888,15 @@ async function handleRun(
|
||||
const noGitignore = stripNoGitignore(args);
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||
|
||||
// If targeting a specific task file and there's existing progress for it,
|
||||
// auto-resume instead of starting fresh
|
||||
// A cancelled loop for this file is continued below AFTER re-prompting for
|
||||
// settings — /ralpi-run never silently resumes with the old loop's
|
||||
// settings (only /ralpi-resume does that). isResume below keeps the
|
||||
// interrupted task's worktree so it continues rather than restarts.
|
||||
const existingProgress = findProgressFile(ctx.cwd, taskFile);
|
||||
if (existingProgress) {
|
||||
return handleResume(
|
||||
ctx,
|
||||
args.slice(0, 1),
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
|
||||
// No existing progress for this task — check for any progress at all
|
||||
// Resolve the project root from any existing progress so running from a
|
||||
// subdirectory still targets the loop's project.
|
||||
const found = findProgressFile(ctx.cwd);
|
||||
if (found && !args[0]) {
|
||||
// Offer to resume instead of starting fresh
|
||||
const shouldResume = await ctx.ui.select(
|
||||
"Found existing ralpi progress. Resume?",
|
||||
["Yes, resume", "No, start fresh"],
|
||||
);
|
||||
|
||||
if (shouldResume?.startsWith("Yes")) {
|
||||
return handleResume(
|
||||
ctx,
|
||||
[],
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
||||
@@ -1200,6 +943,7 @@ async function handleRun(
|
||||
mode,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
!!existingProgress, // preserve in-progress worktrees from a cancelled loop
|
||||
);
|
||||
|
||||
const state = progress.getState();
|
||||
@@ -1221,11 +965,10 @@ async function handleRun(
|
||||
|
||||
/**
|
||||
* Resume core: given a resolved task file, project dir, and PRD key,
|
||||
* build the remaining plan and execute it. Used by both the explicit
|
||||
* `/ralpi resume` command and the auto-resume on session reload.
|
||||
* build the remaining plan and execute it.
|
||||
*
|
||||
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
|
||||
* passed to skip interactive prompts — this is how a reload resumes
|
||||
* passed to skip interactive prompts — this is how /ralpi-resume resumes
|
||||
* non-interactively using the snapshot stored in loop-active.json.
|
||||
* When omitted, the user is prompted as usual.
|
||||
*/
|
||||
@@ -1481,8 +1224,8 @@ async function handleResume(
|
||||
|
||||
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
||||
// persisted when the loop started, so an interrupted loop resumes
|
||||
// non-interactively — matching the auto-resume-on-reload path. Only fall
|
||||
// back to interactive prompts when no snapshot is present.
|
||||
// non-interactively. Only fall back to interactive prompts when no
|
||||
// snapshot is present.
|
||||
const snapshot = readLoopActive(projectDir);
|
||||
const loopOpts = (() => {
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user