fix: prevent run-uat re-dispatch loop when roadmap checkbox update fails (#1063) (#1064)

Two compounding bugs caused auto-mode to re-dispatch run-uat indefinitely
after UAT passed:

1. markSliceDoneInRoadmap regex required dash at line start (^-) but the
   roadmap parser accepts optional leading whitespace (^\s*-). When LLMs
   indented checklist items, the doctor could never mark them done.

2. After run-uat completed, handleAgentEnd ran doctor with fixLevel:"task"
   which explicitly excluded slice-level completion transitions. Since
   run-uat is the terminal unit for a slice, the roadmap checkbox stayed
   unchecked, causing deriveState to return the same slice indefinitely.

Fix: Update markSliceDoneInRoadmap and markTaskDoneInPlan regexes to
accept leading whitespace (matching the parser), preserving indentation
in the replacement. Add run-uat to the set of unit types that use
fixLevel:"all" in handleAgentEnd closeout.
This commit is contained in:
Jeremy McSpadden 2026-03-17 23:00:19 -05:00 committed by GitHub
parent 55769392af
commit 306c205dfc
3 changed files with 96 additions and 5 deletions

View file

@ -1245,12 +1245,16 @@ export async function handleAgentEnd(
// fixLevel:"task" ensures doctor only fixes task-level issues (e.g. marking
// checkboxes). Slice/milestone completion transitions (summary stubs,
// roadmap [x] marking) are left for the complete-slice dispatch unit.
// Exception: after complete-slice itself, use fixLevel:"all" so roadmap
// checkboxes get fixed even if complete-slice crashed (#839).
// Exception: after complete-slice and run-uat, use fixLevel:"all" so roadmap
// checkboxes get fixed. run-uat is the terminal unit for a slice — if the
// roadmap checkbox wasn't marked done by complete-slice (e.g. edit failure),
// fixing it here prevents the state machine from re-dispatching run-uat
// indefinitely (#839, #1063).
try {
const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
const doctorScope = scopeParts.join("/");
const effectiveFixLevel = s.currentUnit.type === "complete-slice" ? "all" as const : "task" as const;
const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]);
const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const;
const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
if (report.fixesApplied.length > 0) {
ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");

View file

@ -308,7 +308,13 @@ async function markTaskDoneInPlan(basePath: string, milestoneId: string, sliceId
if (!planPath) return;
const content = await loadFile(planPath);
if (!content) return;
const updated = content.replace(new RegExp(`^-\\s+\\[ \\]\\s+\\*\\*${taskId}:`, "m"), `- [x] **${taskId}:`);
// Allow optional leading whitespace to match the same patterns the plan parser
// accepts. Capture the leading whitespace + "- " so the replacement preserves
// indentation instead of collapsing it (#1063).
const updated = content.replace(
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${taskId}:`, "m"),
`$1[x] **${taskId}:`,
);
if (updated !== content) {
await saveFile(planPath, updated);
fixesApplied.push(`marked ${taskId} done in ${planPath}`);
@ -320,7 +326,13 @@ async function markSliceDoneInRoadmap(basePath: string, milestoneId: string, sli
if (!roadmapPath) return;
const content = await loadFile(roadmapPath);
if (!content) return;
const updated = content.replace(new RegExp(`^-\\s+\\[ \\]\\s+\\*\\*${sliceId}:`, "m"), `- [x] **${sliceId}:`);
// Allow optional leading whitespace to match the same patterns the roadmap
// parser accepts (^\s*-\s+ in roadmap-slices.ts). Capture the prefix so the
// replacement preserves original indentation (#1063).
const updated = content.replace(
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sliceId}:`, "m"),
`$1[x] **${sliceId}:`,
);
if (updated !== content) {
await saveFile(roadmapPath, updated);
fixesApplied.push(`marked ${sliceId} done in ${roadmapPath}`);

View file

@ -115,6 +115,81 @@ test("fixLevel:all (default) — detects AND fixes completion issues", async ()
}
});
test("fixLevel:all — marks indented roadmap checkboxes done (#1063)", async () => {
const tmp = makeTmp("indented-roadmap");
try {
buildScaffold(tmp);
// Overwrite roadmap with indented checkbox (LLM formatting drift)
writeFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), `# M001: Test
## Slices
- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\`
> Demo text
`);
const report = await runGSDDoctor(tmp, { fix: true });
const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
// Should mark [x] while preserving the leading whitespace
assert.ok(roadmapContent.includes(" - [x] **S01"), "indented roadmap checkbox should be marked done");
// Verify indentation is preserved: line should start with " -", not just "-"
const checkedLine = roadmapContent.split("\n").find(l => l.includes("[x] **S01"));
assert.ok(checkedLine?.startsWith(" -"), `should preserve leading whitespace, got: "${checkedLine}"`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("fixLevel:all — marks indented task checkboxes done (#1063)", async () => {
const tmp = makeTmp("indented-task");
try {
const gsd = join(tmp, ".gsd");
const m = join(gsd, "milestones", "M001");
const s = join(m, "slices", "S01", "tasks");
mkdirSync(s, { recursive: true });
writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Test
## Slices
- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\`
`);
// Plan with indented checkbox
writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
**Goal:** test
## Tasks
- [ ] **T01: Do stuff** \`est:5m\`
`);
writeFileSync(join(s, "T01-SUMMARY.md"), `---
id: T01
parent: S01
milestone: M001
duration: 5m
verification_result: passed
completed_at: 2026-01-01
---
# T01: Do stuff
Done.
`);
const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
const planContent = readFileSync(join(m, "slices", "S01", "S01-PLAN.md"), "utf8");
assert.ok(planContent.includes(" - [x] **T01"), "indented task checkbox should be marked done");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("fixLevel:task — still fixes task-level bookkeeping (checkbox marking)", async () => {
const tmp = makeTmp("task-checkbox");
try {