diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java index f2b2345..a80d461 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -234,7 +234,14 @@ public final class BridgedApp { List> out = sessions.roster().stream() .map(s -> SessionManager.rosterView(s, live.get(s.terminalId()))) .toList(); - ctx.status(200).json(Map.of("workers", out)); + Map body = new LinkedHashMap<>(); + body.put("workers", out); + // CB-586: operator visibility for the refs/wip snapshot store without shelling into the + // repo — how many snapshot refs exist and roughly what they cost. Present only once a + // worktree session has established the repo, so a never-snapshotted fleet reports nothing. + sessions.wipRefs().ifPresent(st -> body.put("wipRefs", + Map.of("count", st.count(), "costBytes", st.costBytes()))); + ctx.status(200).json(body); } /** The configured worker profiles and which one a no-argument spawn uses. */ diff --git a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java index db17250..37f6ce5 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java @@ -12,9 +12,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; @@ -299,6 +302,129 @@ public final class GitWorktrees implements Worktrees { return index; } + /** + * One {@code refs/wip/} snapshot ref as read by {@link #listWipRefs}: its full ref name, + * the snapshot commit's sha, and that commit's committer time in unix millis (the age of the + * snapshot — a snapshot is written once and never rewritten, so the commit date is the ref's). + */ + private record WipRef(String refName, String sha, long committerMillis) { + String branch() { + return refName.substring("refs/wip/".length()); + } + } + + @Override + public WipRefStats wipRefs(String repoRoot) { + List refs = listWipRefs(repoRoot); + long costBytes = 0; + for (WipRef ref : refs) { + costBytes += treeSize(repoRoot, ref.sha()); + } + return new WipRefStats(refs.size(), costBytes); + } + + @Override + public int pruneWipRefs(String repoRoot, long minAgeMillis) { + // The rule is documented on Worktrees#pruneWipRefs: delete only a snapshot whose tree + // content is already reachable from main AND that is older than minAgeMillis. Reachability + // is the floor that keeps a worker's last copy; the age floor keeps a just-written snapshot + // from being swept while a lead may still be looking at it. + List refs = listWipRefs(repoRoot); + if (refs.isEmpty()) { + return 0; + } + long nowMillis = System.currentTimeMillis(); + // Resolve what main carries once per sweep, not once per ref. + Set mainObjects = reachableObjectsFromMain(repoRoot); + int deleted = 0; + for (WipRef ref : refs) { + long ageMillis = nowMillis - ref.committerMillis(); + if (ageMillis <= minAgeMillis) { + continue; // too recent — never swept, even if it looks recoverable (CB-586) + } + String tree = exec("git", "-C", repoRoot, "rev-parse", ref.sha() + "^{tree}").trim(); + if (!mainObjects.contains(tree)) { + // Last copy of the snapshot's content — the worker's work exists nowhere else. + // Never delete automatically (CB-586 criterion 2). + continue; + } + exec("git", "-C", repoRoot, "update-ref", "-d", ref.refName()); + deleted++; + log.info("pruned snapshot ref refs/wip/{} commit={} (age {}h): its tree is already " + + "reachable from main, so the work is preserved; recover from reflog via " + + "git update-ref refs/wip/{} {}", + ref.branch(), ref.sha(), TimeUnit.MILLISECONDS.toHours(ageMillis), + ref.branch(), ref.sha()); + } + return deleted; + } + + /** + * Every {@code refs/wip/*} ref (see {@link WipRef}). The committer date is read as a unix + * count of seconds and converted to millis. {@code %00} (NUL) separates the fields because a + * branch name may contain spaces. + */ + private List listWipRefs(String repoRoot) { + String out = exec("git", "-C", repoRoot, "for-each-ref", + "--format=%(refname)%00%(objectname)%00%(committerdate:unix)", "refs/wip/"); + List refs = new ArrayList<>(); + for (String line : out.split("\\R")) { + if (line.isBlank()) { + continue; + } + String[] parts = line.split("\u0000", -1); + if (parts.length == 3 && !parts[1].isBlank()) { + refs.add(new WipRef(parts[0], parts[1], Long.parseLong(parts[2]) * 1000L)); + } + } + return refs; + } + + /** + * The set of object shas reachable from {@code main}, or an empty set when {@code main} cannot + * be resolved. An empty set is the safe direction: the retention sweep then concludes nothing + * is recoverable, so it deletes nothing — a repo with no {@code main} must never cause a + * worker's last copy of a snapshot to be dropped on a reachability misreading. + */ + private Set reachableObjectsFromMain(String repoRoot) { + if (exitCode("git", "-C", repoRoot, "rev-parse", "--verify", "main") != 0) { + log.debug("refs/wip retention: no 'main' ref in {} — treating nothing as reachable", repoRoot); + return Set.of(); + } + String out = exec("git", "-C", repoRoot, "rev-list", "--objects", "main"); + Set objects = new HashSet<>(); + for (String line : out.split("\\R")) { + if (line.isBlank()) { + continue; + } + int sp = line.indexOf(' '); + objects.add(sp < 0 ? line : line.substring(0, sp)); + } + return objects; + } + + /** Approximate cost of a snapshot: the sum of every blob's size in its committed tree. */ + private long treeSize(String repoRoot, String sha) { + String out = exec("git", "-C", repoRoot, "ls-tree", "-r", "-l", sha); + long total = 0; + for (String line : out.split("\\R")) { + if (line.isBlank()) { + continue; + } + // ls-tree -l row: " \t"; the size is only numeric for + // blobs (trees read "-"), so gate on the type token and take the 4th whitespace field. + String[] parts = line.split("\\s+"); + if (parts.length >= 4 && "blob".equals(parts[1])) { + try { + total += Long.parseLong(parts[3]); + } catch (NumberFormatException ignored) { + // a '-' size (or any anomaly) contributes nothing to the rough figure + } + } + } + return total; + } + /** Resolve the directory that will hold per-session worktree checkouts. */ private Path resolveRoot(String repoRoot) { if (configuredRoot != null && !configuredRoot.isBlank()) { diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java index 2b417ed..92bdefa 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -53,6 +53,14 @@ public final class SessionManager implements TurnListener { private final int contextCap; private final boolean clearAfterTurn; private volatile MemberLifecycle memberLifecycle = MemberLifecycle.NONE; + /** + * CB-586: the repo root the fleet actually works in, remembered the first time a worktree + * session is spawned (worktrees are checkouts of it). {@code refs/wip/*} live there, and this + * single cached value is what the snapshot retention sweep and the operator-visible census run + * against. The daemon is bridged into one project at a time, so "the first worktree's repo" is + * the repo; {@code null} until any worktree is spawned, meaning nothing to sweep or measure. + */ + private volatile String fleetRepoRoot; /** CB-520: notified with a terminalId on every acquire; no-op until wired. */ private final List> acquireListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -450,6 +458,11 @@ public final class SessionManager implements TurnListener { // The non-worktree path always used this chain; only this branch was missed. String repoRoot = worktrees.repoRoot( launcher.effectiveCwd(new SpawnRequest(preResolvedProfile, requestedCwd, callerCwd))); + if (fleetRepoRoot == null) { + // CB-586: remember the repo whose worktrees the fleet spawns — its refs/wip/* are the + // snapshot store the retention sweep and the operator census operate on. + fleetRepoRoot = repoRoot; + } String branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce(); String path = null; PeerHandle handle; @@ -740,6 +753,26 @@ public final class SessionManager implements TurnListener { return registry.size(); } + /** + * CB-586: the operator-visible census of {@code refs/wip/*} in the repo the fleet works in — + * how many snapshot refs exist and roughly what they cost. Empty (no repo known) until at + * least one worktree session has been spawned, exactly so a fleet that has never snapshotted + * anything surfaces nothing new, as it did before CB-586. + */ + public Optional wipRefs() { + String repo = fleetRepoRoot; + return repo == null ? Optional.empty() : Optional.of(worktrees.wipRefs(repo)); + } + + /** + * CB-586: run the snapshot retention sweep in the fleet's repo (a no-op until a worktree has + * been spawned, which establishes the repo). Returns how many {@code refs/wip/*} it deleted. + */ + public int sweepWipRefs(long minAgeMillis) { + String repo = fleetRepoRoot; + return repo == null ? 0 : worktrees.pruneWipRefs(repo, minAgeMillis); + } + /** * The registered session owning {@code terminalId}, or {@code null} if none does. * diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionReaper.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionReaper.java index 43bb764..8f8b643 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionReaper.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionReaper.java @@ -14,12 +14,20 @@ public final class SessionReaper { private static final Logger log = LoggerFactory.getLogger(SessionReaper.class); private static final long DEFAULT_INTERVAL_MILLIS = 5000; + /** CB-586: the refs/wip age floor — never sweep a snapshot younger than 24h (the CB-586 rule). */ + private static final long WIP_MIN_AGE_MILLIS = TimeUnit.HOURS.toMillis(24); + /** + * CB-586: how often the retention sweep runs. Given the 24h age floor, running it every few + * hours means a ref is dropped within hours of becoming eligible, never within minutes. + */ + private static final long WIP_SWEEP_INTERVAL_NANOS = TimeUnit.HOURS.toNanos(6); private final SessionManager sessions; private final long idleTtlNanos; private final long intervalMillis; private volatile boolean running; private Thread thread; + private volatile long lastWipSweepNanos = Long.MIN_VALUE; /** Construct a reaper with the default 5-second polling interval. */ public SessionReaper(SessionManager sessions, long idleTtlSeconds) { @@ -49,10 +57,32 @@ public final class SessionReaper { } catch (RuntimeException e) { log.warn("session reaper iteration failed; continuing", e); } + maybeSweepWipRefs(); sleep(); } } + /** + * CB-586: run the refs/wip retention sweep on a slow cadence (hours, not the per-iteration + * millisecond loop). Best-effort — a failure must never take the idle-reap loop down with it. + */ + private void maybeSweepWipRefs() { + long now = System.nanoTime(); + if (now - lastWipSweepNanos < WIP_SWEEP_INTERVAL_NANOS) { + return; + } + try { + int deleted = sessions.sweepWipRefs(WIP_MIN_AGE_MILLIS); + if (deleted > 0) { + log.info("refs/wip retention sweep deleted {} snapshot ref(s) older than 24h whose " + + "content was already reachable from main", deleted); + } + } catch (RuntimeException e) { + log.warn("refs/wip retention sweep failed; continuing", e); + } + lastWipSweepNanos = now; + } + private void sleep() { try { Thread.sleep(intervalMillis); diff --git a/bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java b/bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java index d7fb0f0..5cf827f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/Worktrees.java @@ -54,4 +54,48 @@ public interface Worktrees { * tolerance — a worktree that is gone holds nothing to snapshot) */ Optional snapshot(String worktreePath, String branch, String message); + + /** + * CB-586: how many {@code refs/wip/*} snapshot refs exist in {@code repoRoot} and roughly what + * they cost. This is the operator-visible surface for the snapshot growth CB-578 stage C left + * behind — counts of refs alone hide that each one pins a whole tree for {@code git gc}. + * + * @param repoRoot the repository to scan + * @return count of snapshot refs, and {@code costBytes} = the approximate total working-tree + * size of every snapshot's committed content (summed per ref, so shared objects are + * counted once per ref that carries them) + */ + WipRefStats wipRefs(String repoRoot); + + /** + * CB-586: run the {@code refs/wip/*} retention sweep and return how many refs it deleted. + * + *

The retention rule is reachability plus an age floor. A snapshot ref is deleted + * only when both hold: + *

    + *
  1. its commit's tree content is already reachable from {@code main} — the work + * the snapshot preserved has been recovered, so dropping the ref loses nothing; and
  2. + *
  3. the ref is older than {@code minAgeMillis} — a very recent snapshot is never swept + * while a lead may still be looking at it.
  4. + *
+ * + *

Reachability is the safety property. A snapshot exists precisely because the work was not + * committed anywhere else, so a snapshot whose content is not reachable from + * {@code main} is the last copy of a worker's work and must never be deleted + * automatically — that is the failure CB-576 and CB-578 stage C were built to stop. Age alone + * must never drive a deletion, because age-based sweeping is exactly how the last copy gets + * destroyed. (Both numbers and the rule are CB-586's decision; this method only implements it.) + * + *

Every deletion logs the ref name and the commit sha, so an operator who finds they lost + * the wrong thing can still recover it from git's reflog. + * + * @param repoRoot the repository whose {@code refs/wip/*} to sweep + * @param minAgeMillis the age floor; a ref younger than this is never touched + * @return the number of snapshot refs deleted + */ + int pruneWipRefs(String repoRoot, long minAgeMillis); + + /** CB-586: the operator-visible census of {@code refs/wip/*} in one repository. */ + record WipRefStats(int count, long costBytes) { + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java b/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java index f6be1c4..4aba71e 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java @@ -27,11 +27,15 @@ public final class FakeWorktrees implements Worktrees { public record SnapshotCall(String worktreePath, String branch, String message) { } + public record PruneCall(String repoRoot, long minAgeMillis) { + } + private final List addCalls = new CopyOnWriteArrayList<>(); private final List removeCalls = new CopyOnWriteArrayList<>(); private final List overlayCalls = new CopyOnWriteArrayList<>(); private final List repoRootCalls = new CopyOnWriteArrayList<>(); private final List snapshotCalls = new CopyOnWriteArrayList<>(); + private final List pruneCalls = new CopyOnWriteArrayList<>(); private final Set existingPaths = ConcurrentHashMap.newKeySet(); private final Set trackedPaths = ConcurrentHashMap.newKeySet(); private final AtomicLong snapshotSeq = new AtomicLong(); @@ -40,6 +44,8 @@ public final class FakeWorktrees implements Worktrees { private volatile boolean dirty = false; private volatile String repoRoot = "/repo"; private volatile String prefix = "/worktrees"; + private volatile WipRefStats wipRefs = new WipRefStats(0, 0L); + private volatile int pruneResult = 0; public FakeWorktrees withRepoRoot(String root) { this.repoRoot = root; @@ -82,6 +88,18 @@ public final class FakeWorktrees implements Worktrees { return this; } + /** Configure the value returned by {@link #wipRefs}. */ + public FakeWorktrees withWipRefs(WipRefStats stats) { + this.wipRefs = stats; + return this; + } + + /** Configure the value returned by {@link #pruneWipRefs}. */ + public FakeWorktrees withPruneResult(int deleted) { + this.pruneResult = deleted; + return this; + } + @Override public String add(String repoRoot, String branch, String baseRef) { addCalls.add(new AddCall(repoRoot, branch, baseRef)); @@ -135,6 +153,17 @@ public final class FakeWorktrees implements Worktrees { return Optional.of("wip" + snapshotSeq.incrementAndGet()); } + @Override + public WipRefStats wipRefs(String repoRoot) { + return wipRefs; + } + + @Override + public int pruneWipRefs(String repoRoot, long minAgeMillis) { + pruneCalls.add(new PruneCall(repoRoot, minAgeMillis)); + return pruneResult; + } + public List addCalls() { return List.copyOf(addCalls); } @@ -167,6 +196,10 @@ public final class FakeWorktrees implements Worktrees { return List.copyOf(snapshotCalls); } + public List pruneCalls() { + return List.copyOf(pruneCalls); + } + public SnapshotCall lastSnapshot() { return snapshotCalls.isEmpty() ? null : snapshotCalls.getLast(); } diff --git a/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java b/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java index 86f9f61..370c5bd 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/GitWorktreesTest.java @@ -3,6 +3,7 @@ package dev.ltms.bridged.session; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; @@ -138,6 +139,53 @@ class GitWorktreesTest { return out; } + /** Write {@code content} as a blob into the object database; returns its sha. */ + private static String blobOf(Path cwd, String content) throws Exception { + Process p = new ProcessBuilder("git", "-C", cwd.toString(), "hash-object", "-w", "--stdin") + .redirectErrorStream(true).start(); + p.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8)); + p.getOutputStream().close(); + String out = new String(p.getInputStream().readAllBytes()).trim(); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git hash-object timed out"); + assertEquals(0, p.exitValue(), "git hash-object failed:\n" + out); + return out; + } + + /** Build a single-file tree object from {@code blob}; returns the tree's sha. */ + private static String treeOf(Path cwd, String path, String blob) throws Exception { + Process p = new ProcessBuilder("git", "-C", cwd.toString(), "mktree") + .redirectErrorStream(true).start(); + p.getOutputStream().write(("100644 blob " + blob + "\t" + path + "\n").getBytes(StandardCharsets.UTF_8)); + p.getOutputStream().close(); + String out = new String(p.getInputStream().readAllBytes()).trim(); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git mktree timed out"); + assertEquals(0, p.exitValue(), "git mktree failed:\n" + out); + return out; + } + + /** {@code git commit-tree} rooted at {@code tree} with a chosen committer date; returns the sha. */ + private static String commitTree(Path cwd, String tree, String parent, String committerDate, + String message) throws Exception { + ProcessBuilder pb = new ProcessBuilder("git", "-C", cwd.toString(), "commit-tree", + tree, "-p", parent, "-m", message); + pb.environment().put("GIT_COMMITTER_DATE", committerDate); + Process p = pb.redirectErrorStream(true).start(); + String out = new String(p.getInputStream().readAllBytes()).trim(); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git commit-tree timed out"); + assertEquals(0, p.exitValue(), "git commit-tree failed:\n" + out); + return out; + } + + /** {@code git update-ref } — create the snapshot ref directly. */ + private static void updateRef(Path cwd, String ref, String sha) throws Exception { + git(cwd, "update-ref", ref, sha); + } + + /** True when {@code ref} exists in the repo (for-each-ref on a missing ref is empty, not an error). */ + private static boolean refExists(Path cwd, String ref) throws Exception { + return !forEachRef(cwd, ref).trim().isEmpty(); + } + /** * The heart of CB-525: a provisioned worktree must not inherit the primary's MCP servers. Without * the isolation step the checked-out {@code .mcp.json} carries them in, and a worker navigating @@ -474,4 +522,104 @@ class GitWorktreesTest { assertThrows(WorktreeException.class, () -> gitWorktrees.snapshot(wt, branch, "test snapshot"), "an unresolvable real index must fail loudly, not silently snapshot from an empty index"); } + + /** + * CB-586, criterion 2. A snapshot whose content is NOT reachable from {@code main} is the last + * copy of a worker's work, and must never be deleted automatically — even when it is old and + * even when the caller passes a zero age floor. Uses the real snapshot path on a dirty worktree, + * so the unreachable tree is exactly the shape CB-576/CB-578 stage C exist to protect. + */ + @Test + void anUnreachableSnapshotIsNeverPruned(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString()); + String branch = "cb-586-unreachable"; + String wt = gitWorktrees.add(repo.toString(), branch, "HEAD"); + Files.writeString(Path.of(wt).resolve("worker-draft.txt"), "work that exists nowhere else\n"); + + Optional ref = gitWorktrees.snapshot(wt, branch, "snapshot with unreachable content"); + assertTrue(ref.isPresent()); + + // Age floor 0 makes age a non-issue: only reachability can save it — and it must. + assertEquals(0, gitWorktrees.pruneWipRefs(repo.toString(), 0), + "the unreachable snapshot is the last copy and must not be pruned"); + assertTrue(refExists(repo, "refs/wip/" + branch), + "an unreachable snapshot must survive the sweep"); + } + + /** + * CB-586, criterion 1 (the reachable half). A snapshot whose tree content IS already reachable + * from {@code main} and which is older than the age floor is pure duplication — the work is + * recovered — so it must be pruned. + */ + @Test + void aReachableSnapshotOlderThanTheFloorIsPruned(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString()); + + // A snapshot whose tree is exactly main's current tree: fully reachable from main. + String mainTree = revParse(repo, "main^{tree}"); + String old = commitTree(repo, mainTree, revParse(repo, "HEAD"), "2020-01-01T00:00:00", "snapshot"); + updateRef(repo, "refs/wip/recovered", old); + + assertEquals(1, gitWorktrees.pruneWipRefs(repo.toString(), TimeUnit.HOURS.toMillis(24)), + "an old, main-reachable snapshot must be pruned"); + assertFalse(refExists(repo, "refs/wip/recovered"), + "the reachable snapshot's ref must be gone after the sweep"); + } + + /** + * CB-586, the age floor. A snapshot whose content IS reachable from {@code main} but which is + * younger than the age floor must not be swept — a lead may still be looking at it. + */ + @Test + void aReachableButRecentSnapshotIsNotPruned(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString()); + + // Reachable from main, but committed "now" — a fresh snapshot. The 24h floor must protect it. + String mainTree = revParse(repo, "main^{tree}"); + String fresh = commitTree(repo, mainTree, revParse(repo, "HEAD"), + "2038-01-01T00:00:00", "snapshot just taken"); + updateRef(repo, "refs/wip/fresh", fresh); + + assertEquals(0, gitWorktrees.pruneWipRefs(repo.toString(), TimeUnit.HOURS.toMillis(24)), + "a recent snapshot must be kept even when reachable"); + assertTrue(refExists(repo, "refs/wip/fresh"), + "the recent reachable snapshot must survive the sweep"); + } + + /** + * CB-586, criterion 5. A fleet that has never snapshotted anything has no {@code refs/wip/*}, + * so a sweep is a no-op and the census reports none — identical to before CB-586 existed. + */ + @Test + void aFleetWithNoSnapshotsPrunesNothingAndReportsNothing(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString()); + + assertEquals(0, gitWorktrees.pruneWipRefs(repo.toString(), 0), + "no snapshot refs means nothing to prune"); + Worktrees.WipRefStats stats = gitWorktrees.wipRefs(repo.toString()); + assertEquals(0, stats.count(), "a never-snapshotted fleet has zero refs/wip refs"); + assertEquals(0L, stats.costBytes(), "a never-snapshotted fleet costs zero bytes"); + } + + /** CB-586, criterion 4: the census reports how many refs exist and roughly what they cost. */ + @Test + void wipRefsReportsCountAndCost(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString()); + + String blob = blobOf(repo, "a recoverable snapshot's worth of content"); + String tree = treeOf(repo, "snapshot.txt", blob); + updateRef(repo, "refs/wip/one", commitTree(repo, tree, revParse(repo, "HEAD"), + "2020-01-01T00:00:00", "snapshot")); + updateRef(repo, "refs/wip/two", commitTree(repo, tree, revParse(repo, "HEAD"), + "2020-01-02T00:00:00", "snapshot")); + + Worktrees.WipRefStats stats = gitWorktrees.wipRefs(repo.toString()); + assertEquals(2, stats.count(), "two snapshot refs are reported"); + assertTrue(stats.costBytes() > 0, "the cost of the snapshots is a positive byte count"); + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java index 8a5472b..3c482e4 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -138,6 +138,16 @@ class SessionManagerTest { return java.util.Optional.of("wip" + snapshotSeq.incrementAndGet()); } + @Override + public WipRefStats wipRefs(String repoRoot) { + return new WipRefStats(0, 0L); + } + + @Override + public int pruneWipRefs(String repoRoot, long minAgeMillis) { + return 0; + } + List removeCalls() { return List.copyOf(removeCalls); } diff --git a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java index e3c008d..abc2523 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java @@ -444,4 +444,37 @@ class WorktreeSessionManagerTest { "a failed snapshot leaves no ref to report"); } + /** + * CB-586, criteria 4 and 1. Once a worktree session is spawned the repo is known, so the + * operator census and the retention sweep delegate to that repo's {@code refs/wip/*}. + */ + @Test + void wipRefsAndSweepDelegateToTheFleetRepoOnceKnown() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt") + .withWipRefs(new Worktrees.WipRefStats(3, 42L)).withPruneResult(2); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + + assertTrue(sessions.wipRefs().isEmpty(), + "no worktree spawned yet means no repo is known and nothing to report"); + assertEquals(0, sessions.sweepWipRefs(TimeUnit.HOURS.toMillis(24)), + "no worktree spawned yet means the sweep is a no-op"); + + sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-586", null)); + + Worktrees.WipRefStats stats = sessions.wipRefs().orElseThrow(); + assertEquals(3, stats.count(), "the census comes from the fleet repo"); + assertEquals(42L, stats.costBytes(), "the cost comes from the fleet repo"); + assertEquals(2, sessions.sweepWipRefs(TimeUnit.HOURS.toMillis(24)), + "the sweep runs against the fleet repo"); + + List prunes = worktrees.pruneCalls(); + assertEquals(1, prunes.size(), "the no-op short-circuits before reaching the seam, so only " + + "the repo-known sweep issues a call"); + assertEquals("/repo", prunes.getFirst().repoRoot(), "the sweep targets the fleet repo"); + assertEquals(TimeUnit.HOURS.toMillis(24), prunes.getFirst().minAgeMillis(), + "the caller's age floor is passed through"); + } + }