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`,
|
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
|
||||||
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
|
`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:
|
- `src/` — all logic modules:
|
||||||
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
||||||
YAML formats), dependency + parallel-group + timeout parsing,
|
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):
|
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/loop-active.json` — marker written while a loop runs; drives
|
- `.ralpi/loop-active.json` — marker written while a loop runs; snapshots the
|
||||||
auto-resume after a session reload
|
mode + loop options so `/ralpi-resume` can continue non-interactively
|
||||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||||
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
||||||
`saveReviews` is on)
|
`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/prompts/` — generated prompts (timestamped, for debugging)
|
||||||
- `.ralpi/config.yaml` — project-level config (optional)
|
- `.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 ID convention
|
||||||
|
|
||||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
|
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
|
- `/ralpi` — no args → show plan for `README.md`; first token looks like a
|
||||||
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
||||||
error suggesting the dash commands
|
error suggesting the dash commands
|
||||||
- `/ralpi-run [task-file]` — run tasks (auto-resumes when progress already
|
- `/ralpi-run [task-file]` — run tasks (always prompts for execution mode +
|
||||||
exists for the file; otherwise prompts for execution mode + loop options)
|
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`
|
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
|
||||||
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
||||||
with `expandPromptTemplates: false`, so the extension does its own
|
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 {
|
import type {
|
||||||
ExtensionAPI,
|
ExtensionAPI,
|
||||||
ExtensionContext,
|
ExtensionContext,
|
||||||
SessionStartEvent,
|
|
||||||
} from "@oh-my-pi/pi-coding-agent";
|
} from "@oh-my-pi/pi-coding-agent";
|
||||||
import { Box, Text } from "@oh-my-pi/pi-tui";
|
import { Box, Text } from "@oh-my-pi/pi-tui";
|
||||||
import { parseTaskFile, updateTaskInFile } from "./src/parser";
|
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
|
// Write the loop-active marker so an interrupted loop can be resumed
|
||||||
// loop and resume it (in-process agent sessions die on reload — the marker
|
// non-interactively via /ralpi-resume: it snapshots the execution mode and
|
||||||
// + progress.json in_progress tasks are the signal to re-run them).
|
// loop options (autoCommit/autoReview/saveReviews) that /ralpi-resume
|
||||||
|
// would otherwise re-prompt for.
|
||||||
if (projectDir) {
|
if (projectDir) {
|
||||||
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
||||||
writeLoopActive(projectDir, {
|
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", {
|
pi.registerCommand("ralpi", {
|
||||||
description:
|
description:
|
||||||
"Execute tasks from a task file using DAG-based dependency resolution",
|
"Execute tasks from a task file using DAG-based dependency resolution",
|
||||||
@@ -1122,38 +888,15 @@ async function handleRun(
|
|||||||
const noGitignore = stripNoGitignore(args);
|
const noGitignore = stripNoGitignore(args);
|
||||||
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
|
||||||
|
|
||||||
// If targeting a specific task file and there's existing progress for it,
|
// A cancelled loop for this file is continued below AFTER re-prompting for
|
||||||
// auto-resume instead of starting fresh
|
// 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);
|
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);
|
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;
|
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
|
||||||
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
ensureIgnoredNote(projectDir, ctx, noGitignore);
|
||||||
@@ -1200,6 +943,7 @@ async function handleRun(
|
|||||||
mode,
|
mode,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
|
!!existingProgress, // preserve in-progress worktrees from a cancelled loop
|
||||||
);
|
);
|
||||||
|
|
||||||
const state = progress.getState();
|
const state = progress.getState();
|
||||||
@@ -1221,11 +965,10 @@ async function handleRun(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume core: given a resolved task file, project dir, and PRD key,
|
* Resume core: given a resolved task file, project dir, and PRD key,
|
||||||
* build the remaining plan and execute it. Used by both the explicit
|
* build the remaining plan and execute it.
|
||||||
* `/ralpi resume` command and the auto-resume on session reload.
|
|
||||||
*
|
*
|
||||||
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
|
* `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.
|
* non-interactively using the snapshot stored in loop-active.json.
|
||||||
* When omitted, the user is prompted as usual.
|
* When omitted, the user is prompted as usual.
|
||||||
*/
|
*/
|
||||||
@@ -1481,8 +1224,8 @@ async function handleResume(
|
|||||||
|
|
||||||
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
|
||||||
// persisted when the loop started, so an interrupted loop resumes
|
// persisted when the loop started, so an interrupted loop resumes
|
||||||
// non-interactively — matching the auto-resume-on-reload path. Only fall
|
// non-interactively. Only fall back to interactive prompts when no
|
||||||
// back to interactive prompts when no snapshot is present.
|
// snapshot is present.
|
||||||
const snapshot = readLoopActive(projectDir);
|
const snapshot = readLoopActive(projectDir);
|
||||||
const loopOpts = (() => {
|
const loopOpts = (() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user