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:
44
index.ts
44
index.ts
@@ -35,6 +35,7 @@ import {
|
||||
readLoopActive,
|
||||
findRalpiDir,
|
||||
listPRDsSorted,
|
||||
countPRDResumeStats,
|
||||
formatDuration,
|
||||
} from "./src/utils";
|
||||
|
||||
@@ -216,19 +217,17 @@ async function selectPRDToResume(
|
||||
|
||||
// Multiple PRDs — show selection sorted by most recent first
|
||||
const options = prds.map((entry) => {
|
||||
const tasks = entry.prd.tasks;
|
||||
const total = Object.keys(tasks).length;
|
||||
const completed = Object.values(tasks).filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
const failed = Object.values(tasks).filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
// Total/completed must come from the parsed PRD file, not just the
|
||||
// progress map: the tracker only records TOUCHED tasks (started/
|
||||
// completed/failed), so never-started tasks would be silently missing
|
||||
// from a naive Object.keys() count and the totals would under-report.
|
||||
const { total, completed, failed } = countPRDResumeStats(
|
||||
entry.prd,
|
||||
entry.prd.sourcePath,
|
||||
);
|
||||
const relPath = path.relative(ctx.cwd, entry.prd.sourcePath);
|
||||
const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString();
|
||||
return `${relPath} — ${completed}/${total} done${
|
||||
failed ? `, ${failed} failed` : ""
|
||||
} · ${updated}`;
|
||||
return `${relPath} — ${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`;
|
||||
});
|
||||
|
||||
const selected = await ctx.ui.select(
|
||||
@@ -650,7 +649,8 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
);
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ?? progressRaw.tasks;
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "completed";
|
||||
tasks[id].completedAt = new Date().toISOString();
|
||||
@@ -666,7 +666,11 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
}
|
||||
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 ──
|
||||
@@ -684,7 +688,8 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
for (const id of conflictIds) {
|
||||
if (progressRaw) {
|
||||
const tasks =
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ?? progressRaw.tasks;
|
||||
progressRaw.prds?.[loopState.prdKey]?.tasks ??
|
||||
progressRaw.tasks;
|
||||
if (tasks && tasks[id]) {
|
||||
tasks[id].status = "pending";
|
||||
delete tasks[id].startedAt;
|
||||
@@ -701,7 +706,11 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
}
|
||||
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(
|
||||
`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
|
||||
// the repo isn't mid-merge.
|
||||
abortMerge(projectDir);
|
||||
const finalizeCandidateIds = Object.entries(progress.getState().tasks)
|
||||
.flatMap(([id, t]) =>
|
||||
const finalizeCandidateIds = Object.entries(
|
||||
progress.getState().tasks,
|
||||
).flatMap(([id, t]) =>
|
||||
t.status !== "failed" && t.status !== "pending" ? [id] : [],
|
||||
);
|
||||
if (finalizeCandidateIds.length > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
|
||||
44
src/utils.ts
44
src/utils.ts
@@ -7,6 +7,7 @@ import type {
|
||||
ToolUsage,
|
||||
} from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
import { parseTaskFile } from "./parser";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
createAgentSession,
|
||||
@@ -198,6 +199,49 @@ export function listPRDsSorted(
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
87
tests/resume-stats.test.ts
Normal file
87
tests/resume-stats.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user