fix: resume selection under-reports task totals with multiple loops

The resume prompt counted total/completed from progress.tasks, which only
records TOUCHED tasks (started/completed/failed) — never-started tasks were
silently missing, and file-checkbox completions weren't counted unless
markCompleted had run. With e.g. 10 tasks where 3 completed and 5 untouched,
it showed 3/5 done instead of 3/10 done.

- add countPRDResumeStats (src/utils.ts): total from parseTaskFile of the
  PRD source; completed = progress completions ∪ PRD checkbox completions,
  deduped by task id; failed from progress. Falls back to touched-task
  counts when the source file is missing/unparseable.
- selectPRDToResume (index.ts) uses the helper instead of Object.keys().
- tests/resume-stats.test.ts: 4 regression tests.

Includes pre-existing uncommitted changes: index.ts tab-reformat (matches
src/ style) and package.json version bump 0.4.2 -> 0.4.3.
This commit is contained in:
2026-08-03 16:31:09 -04:00
parent f9b57ec2ed
commit b9a12931fe
4 changed files with 1234 additions and 1093 deletions

View File

@@ -35,6 +35,7 @@ import {
readLoopActive, readLoopActive,
findRalpiDir, findRalpiDir,
listPRDsSorted, listPRDsSorted,
countPRDResumeStats,
formatDuration, formatDuration,
} from "./src/utils"; } from "./src/utils";
@@ -216,19 +217,17 @@ async function selectPRDToResume(
// Multiple PRDs — show selection sorted by most recent first // Multiple PRDs — show selection sorted by most recent first
const options = prds.map((entry) => { const options = prds.map((entry) => {
const tasks = entry.prd.tasks; // Total/completed must come from the parsed PRD file, not just the
const total = Object.keys(tasks).length; // progress map: the tracker only records TOUCHED tasks (started/
const completed = Object.values(tasks).filter( // completed/failed), so never-started tasks would be silently missing
(t) => t.status === "completed", // from a naive Object.keys() count and the totals would under-report.
).length; const { total, completed, failed } = countPRDResumeStats(
const failed = Object.values(tasks).filter( entry.prd,
(t) => t.status === "failed", entry.prd.sourcePath,
).length; );
const relPath = path.relative(ctx.cwd, entry.prd.sourcePath); const relPath = path.relative(ctx.cwd, entry.prd.sourcePath);
const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString(); const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString();
return `${relPath}${completed}/${total} done${ return `${relPath}${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`;
failed ? `, ${failed} failed` : ""
} · ${updated}`;
}); });
const selected = await ctx.ui.select( const selected = await ctx.ui.select(
@@ -650,7 +649,8 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
); );
if (progressRaw) { if (progressRaw) {
const tasks = const tasks =
progressRaw.prds?.[loopState.prdKey]?.tasks ?? progressRaw.tasks; progressRaw.prds?.[loopState.prdKey]?.tasks ??
progressRaw.tasks;
if (tasks && tasks[id]) { if (tasks && tasks[id]) {
tasks[id].status = "completed"; tasks[id].status = "completed";
tasks[id].completedAt = new Date().toISOString(); tasks[id].completedAt = new Date().toISOString();
@@ -666,7 +666,11 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
} }
} }
if (progressRaw) { if (progressRaw) {
fs.writeFileSync(progressPath, JSON.stringify(progressRaw, null, 2), "utf-8"); fs.writeFileSync(
progressPath,
JSON.stringify(progressRaw, null, 2),
"utf-8",
);
} }
} }
// ── Handle conflicted tasks ── // ── Handle conflicted tasks ──
@@ -684,7 +688,8 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
for (const id of conflictIds) { for (const id of conflictIds) {
if (progressRaw) { if (progressRaw) {
const tasks = const tasks =
progressRaw.prds?.[loopState.prdKey]?.tasks ?? progressRaw.tasks; progressRaw.prds?.[loopState.prdKey]?.tasks ??
progressRaw.tasks;
if (tasks && tasks[id]) { if (tasks && tasks[id]) {
tasks[id].status = "pending"; tasks[id].status = "pending";
delete tasks[id].startedAt; delete tasks[id].startedAt;
@@ -701,7 +706,11 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
} }
} }
if (progressRaw) { if (progressRaw) {
fs.writeFileSync(progressPath, JSON.stringify(progressRaw, null, 2), "utf-8"); fs.writeFileSync(
progressPath,
JSON.stringify(progressRaw, null, 2),
"utf-8",
);
} }
ctx.ui.notify( ctx.ui.notify(
`Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`, `Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`,
@@ -1040,8 +1049,9 @@ async function resumeLoop(
// (`git merge` refuses while a merge is already in progress). No-op when // (`git merge` refuses while a merge is already in progress). No-op when
// the repo isn't mid-merge. // the repo isn't mid-merge.
abortMerge(projectDir); abortMerge(projectDir);
const finalizeCandidateIds = Object.entries(progress.getState().tasks) const finalizeCandidateIds = Object.entries(
.flatMap(([id, t]) => progress.getState().tasks,
).flatMap(([id, t]) =>
t.status !== "failed" && t.status !== "pending" ? [id] : [], t.status !== "failed" && t.status !== "pending" ? [id] : [],
); );
if (finalizeCandidateIds.length > 0) { if (finalizeCandidateIds.length > 0) {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mikefreno/ralpi", "name": "@mikefreno/ralpi",
"version": "0.4.2", "version": "0.4.3",
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
"keywords": [ "keywords": [
"pi-package", "pi-package",

View File

@@ -7,6 +7,7 @@ import type {
ToolUsage, ToolUsage,
} from "./types"; } from "./types";
import { DEFAULT_CONFIG } from "./types"; import { DEFAULT_CONFIG } from "./types";
import { parseTaskFile } from "./parser";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { import {
createAgentSession, createAgentSession,
@@ -198,6 +199,49 @@ export function listPRDsSorted(
return entries; return entries;
} }
export interface PRDResumeSummary {
total: number;
completed: number;
failed: number;
}
/**
* Count tasks for the resume-selection display.
*
* The progress tracker only records tasks that were TOUCHED (started,
* completed, or failed) — never-started tasks are absent from `prd.tasks`,
* so a naive Object.keys(prd.tasks).length under-reports the real total.
* The true total comes from parsing the PRD source file. Completed counts
* both progress-marked completions and PRD checkbox completions (a task
* checked off in the file is done even if the loop was interrupted before
* markCompleted), deduped by task id. Falls back to touched-task counts
* when the source file is missing or unparseable.
*/
export function countPRDResumeStats(
prd: PRDProgress,
sourcePath: string,
): PRDResumeSummary {
const touched = Object.entries(prd.tasks);
const failed = touched.filter(([, t]) => t.status === "failed").length;
const completedIds = new Set(
touched.filter(([, t]) => t.status === "completed").map(([id]) => id),
);
let total: number;
try {
const project = parseTaskFile(sourcePath);
total = project.tasks.length;
for (const task of project.tasks) {
if (task.status === "completed") completedIds.add(task.id);
}
} catch {
// PRD file missing/unparseable — fall back to touched-task counts
total = touched.length;
}
return { total, completed: completedIds.size, failed };
}
// ─── Model Resolution ─────────────────────────────────────────────────────── // ─── Model Resolution ───────────────────────────────────────────────────────
/** /**

View File

@@ -0,0 +1,87 @@
/// <reference types="bun-types" />
import { describe, it, expect, beforeEach } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { ProgressTracker } from "../src/progress";
import { countPRDResumeStats } from "../src/utils";
/**
* Regression test: the resume-selection prompt under-reported task totals
* when multiple loop histories existed. The progress tracker only records
* TOUCHED tasks (started/completed/failed) — never-started tasks are absent
* from prd.tasks, so a naive Object.keys() count missed them entirely, and
* file-checked completions were ignored unless markCompleted had run.
* countPRDResumeStats derives the true total from the parsed PRD file and
* counts checkbox completions too.
*/
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-stats-test-"));
});
const PRD_CONTENT = `# Test PRD
## Tasks
- [ ] Task one
- [x] Task two
- [ ] Task three
- [ ] Task four
`;
function writePRD(rel: string): string {
const p = path.join(root, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, PRD_CONTENT, "utf-8");
return p;
}
describe("countPRDResumeStats", () => {
it("reports the full task total from the PRD file, not just touched tasks", () => {
const sourcePath = writePRD("tasks/a/README.md");
const progress = new ProgressTracker(root, sourcePath);
// Simple-checkbox format assigns sequential ids 00-03. Only 00
// (completed) and 02 (failed) were touched by the loop; 01 is checked
// off in the file; 03 was never started.
progress.markCompleted("00", 1000);
progress.markFailed("02", "boom");
const stats = countPRDResumeStats(progress.getState(), sourcePath);
expect(stats.total).toBe(4); // old code reported 2
expect(stats.completed).toBe(2); // 00 via progress + 01 via checkbox
expect(stats.failed).toBe(1);
});
it("does not double-count a task that is both progress-completed and file-checked", () => {
const sourcePath = writePRD("tasks/b/README.md");
const progress = new ProgressTracker(root, sourcePath);
progress.markCompleted("01", 500); // 01 already [x] in the file
const stats = countPRDResumeStats(progress.getState(), sourcePath);
expect(stats.completed).toBe(1);
});
it("falls back to touched-task counts when the PRD file is missing", () => {
const missing = path.join(root, "tasks/gone/README.md");
const progress = new ProgressTracker(root, missing);
progress.markCompleted("01", 1000);
progress.markFailed("02", "nope");
const stats = countPRDResumeStats(progress.getState(), missing);
expect(stats.total).toBe(2);
expect(stats.completed).toBe(1);
expect(stats.failed).toBe(1);
});
it("reports zero for a never-touched PRD with no file", () => {
const missing = path.join(root, "tasks/none/README.md");
const progress = new ProgressTracker(root, missing);
const stats = countPRDResumeStats(progress.getState(), missing);
expect(stats.total).toBe(0);
expect(stats.completed).toBe(0);
expect(stats.failed).toBe(0);
});
});