fix: resume/restart logic fixed
All checks were successful
port-to-omp / verify (push) Successful in 9s
port-to-omp / publish (push) Successful in 5s

This commit is contained in:
2026-08-12 15:24:34 -04:00
parent c870efa15a
commit 3cc7c0469b
3 changed files with 24 additions and 289 deletions

View File

@@ -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

283
index.ts
View File

@@ -306,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, {
@@ -764,237 +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, ctx) => {
if (event.reason !== "reload") return;
// 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",
@@ -1118,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);
@@ -1196,6 +943,7 @@ async function handleRun(
mode,
sendChatMessage,
projectDir,
!!existingProgress, // preserve in-progress worktrees from a cancelled loop
);
const state = progress.getState();
@@ -1217,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.
*/
@@ -1477,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 (

View File

@@ -343,18 +343,6 @@ const FILE_RULES = {
label: "runAgentSession body",
},
],
"index.ts": [
{
from: 'import type {\n\tExtensionAPI,\n\tExtensionContext,\n} from "@oh-my-pi/pi-coding-agent";',
to: 'import type {\n\tExtensionAPI,\n\tExtensionContext,\n\tSessionStartEvent,\n} from "@oh-my-pi/pi-coding-agent";',
label: "SessionStartEvent import",
},
{
from: '\tpi.on("session_start", async (event, ctx) => {\n\t\tif (event.reason !== "reload") return;',
to: '\tpi.on("session_start", async (event: SessionStartEvent, ctx) => {\n\t\t// omp\'s SessionStartEvent has no reason/reload field; the in_progress-task\n\t\t// check below already scopes recovery to genuinely interrupted loops (a\n\t\t// completed loop has no in_progress tasks), so recovery runs on any start\n\t\t// where a stalled loop marker exists.',
label: "session_start handler",
},
],
"src/executor.ts": [
{
from: 'import type {\n\tExtensionContext,\n\tModelRuntime,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',