fix: archive completed-units.json on milestone transition and sync metrics.json (#2313) (#2431)

Two bugs fixed:
1. completed-units.json was wiped to [] on milestone transition, losing all
   tracking data. Now archived to completed-units-{MID}.json before reset.
2. metrics.json was never synced between worktree and project root. Added to
   syncStateToProjectRoot, syncWorktreeStateBack, and syncGsdStateToWorktree
   file lists.

Fixes #2313

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-25 00:35:01 -04:00 committed by GitHub
parent 81de9f60c5
commit 5d0c6311f1
4 changed files with 132 additions and 2 deletions

View file

@ -93,6 +93,11 @@ export function syncStateToProjectRoot(
{ force: true },
);
// 3. metrics.json — session cost/token tracking (#2313).
// Without this, metrics accumulated in the worktree are invisible from the
// project root and never appear in the dashboard or skill-health reports.
safeCopy(join(wtGsd, "metrics.json"), join(prGsd, "metrics.json"), { force: true });
// 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
// Without this, a crash during a unit leaves the runtime record only in the
// worktree. If the next session resolves basePath before worktree re-entry,

View file

@ -162,6 +162,7 @@ export function syncGsdStateToWorktree(
"OVERRIDES.md",
"QUEUE.md",
"completed-units.json",
"metrics.json",
];
for (const f of rootFiles) {
const src = join(mainGsd, f);
@ -325,8 +326,9 @@ export function syncWorktreeStateBack(
// ── 1. Sync root-level .gsd/ files back ──────────────────────────────
// The worktree is authoritative — complete-milestone updates REQUIREMENTS,
// PROJECT, etc. These must overwrite main's copies so they survive teardown.
// Also includes QUEUE.md and completed-units.json which are written during
// milestone closeout and lost on teardown without explicit sync (#1787).
// Also includes QUEUE.md, completed-units.json, and metrics.json which are
// written during milestone closeout and lost on teardown without explicit sync
// (#1787, #2313).
const rootFiles = [
"DECISIONS.md",
"REQUIREMENTS.md",
@ -335,6 +337,7 @@ export function syncWorktreeStateBack(
"OVERRIDES.md",
"QUEUE.md",
"completed-units.json",
"metrics.json",
];
for (const f of rootFiles) {
const src = join(wtGsd, f);

View file

@ -28,6 +28,7 @@ import { gsdRoot } from "../paths.js";
import { atomicWriteSync } from "../atomic-write.js";
import { PROJECT_FILES } from "../detection.js";
import { join } from "node:path";
import { existsSync, cpSync } from "node:fs";
// ─── generateMilestoneReport ──────────────────────────────────────────────────
@ -263,9 +264,17 @@ export async function runPreDispatch(
// Reset completed-units tracking for the new milestone — stale entries
// from the previous milestone cause the dispatch loop to skip units
// that haven't actually been completed in the new milestone's context.
// Archive the old completed-units.json instead of wiping it (#2313).
s.completedUnits = [];
try {
const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
if (existsSync(completedKeysPath) && s.currentMilestoneId) {
const archivePath = join(
gsdRoot(s.basePath),
`completed-units-${s.currentMilestoneId}.json`,
);
cpSync(completedKeysPath, archivePath);
}
atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2));
} catch { /* non-fatal */ }

View file

@ -0,0 +1,113 @@
/**
* completed-units-metrics-sync.test.ts Regression tests for #2313.
*
* 1. completed-units.json should be archived (not wiped) on milestone transition
* 2. metrics.json should be in the worktree project root sync file list
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, existsSync, cpSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
// ─── Bug 1: completed-units.json should be archived, not wiped ─────────────
const phasesSrcPath = join(import.meta.dirname, "..", "auto", "phases.ts");
const phasesSrc = readFileSync(phasesSrcPath, "utf-8");
test("#2313: completed-units.json should not be blindly wiped to [] on milestone transition", () => {
// The milestone transition block should NOT write an empty array to completed-units.json
// without first archiving the existing data. Look for the archive/rename pattern.
const transitionIdx = phasesSrc.indexOf("Milestone transition");
assert.ok(transitionIdx !== -1, "Milestone transition section exists");
// Find the completed-units handling block
const completedUnitsIdx = phasesSrc.indexOf("completed-units", transitionIdx);
assert.ok(completedUnitsIdx !== -1, "completed-units handling exists in transition");
// Get a window around the completed-units handling
const windowStart = Math.max(0, completedUnitsIdx - 200);
const windowEnd = Math.min(phasesSrc.length, completedUnitsIdx + 500);
const window = phasesSrc.slice(windowStart, windowEnd);
// Should archive/rename the old file before resetting
const hasArchive = window.includes("archive") ||
window.includes("rename") ||
window.includes("cpSync") ||
window.includes("safeCopy") ||
window.includes("completed-units-");
assert.ok(
hasArchive,
"completed-units.json should be archived before reset during milestone transition",
);
});
// ─── Bug 2: metrics.json should be in the sync file lists ──────────────────
test("#2313: syncStateToProjectRoot should sync metrics.json", () => {
const syncSrcPath = join(import.meta.dirname, "..", "auto-worktree-sync.ts");
const syncSrc = readFileSync(syncSrcPath, "utf-8");
// syncStateToProjectRoot should copy metrics.json from worktree to project root
assert.ok(
syncSrc.includes("metrics.json"),
"auto-worktree-sync.ts should reference metrics.json for sync",
);
});
test("#2313: syncWorktreeStateBack should include metrics.json in root files list", () => {
const autoWorktreeSrcPath = join(import.meta.dirname, "..", "auto-worktree.ts");
const autoWorktreeSrc = readFileSync(autoWorktreeSrcPath, "utf-8");
// Find the rootFiles array in syncWorktreeStateBack
const syncBackIdx = autoWorktreeSrc.indexOf("syncWorktreeStateBack");
assert.ok(syncBackIdx !== -1, "syncWorktreeStateBack exists");
const rootFilesIdx = autoWorktreeSrc.indexOf("rootFiles", syncBackIdx);
assert.ok(rootFilesIdx !== -1, "rootFiles list exists in syncWorktreeStateBack");
// Get the rootFiles array content
const arrayStart = autoWorktreeSrc.indexOf("[", rootFilesIdx);
const arrayEnd = autoWorktreeSrc.indexOf("]", arrayStart);
const rootFilesBlock = autoWorktreeSrc.slice(arrayStart, arrayEnd);
assert.ok(
rootFilesBlock.includes("metrics.json"),
"metrics.json should be in syncWorktreeStateBack rootFiles list",
);
});
// ─── Functional test: completed-units archive ────────────────────────────────
test("#2313: functional — completed-units archive creates milestone-specific file", () => {
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-completed-units-"));
const gsdDir = join(tmpBase, ".gsd");
mkdirSync(gsdDir, { recursive: true });
// Simulate existing completed-units.json with data
const existing = [
{ type: "task", id: "T01" },
{ type: "slice", id: "S01" },
];
const completedKeysPath = join(gsdDir, "completed-units.json");
writeFileSync(completedKeysPath, JSON.stringify(existing, null, 2));
// Simulate the archive behavior: copy to milestone-specific file
const milestoneId = "M001";
const archivePath = join(gsdDir, `completed-units-${milestoneId}.json`);
cpSync(completedKeysPath, archivePath);
// Reset the main file
writeFileSync(completedKeysPath, JSON.stringify([], null, 2));
// Verify archive exists with original data
assert.ok(existsSync(archivePath), "archive file should exist");
const archived = JSON.parse(readFileSync(archivePath, "utf-8"));
assert.deepEqual(archived, existing, "archived data should match original");
// Verify main file is reset
const current = JSON.parse(readFileSync(completedKeysPath, "utf-8"));
assert.deepEqual(current, [], "current completed-units should be empty after transition");
});