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

2194
index.ts

File diff suppressed because it is too large Load Diff

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);
});
});