Repository navigation
Furrow task cleanup retains snapshot timelines after deleting completed task directories #1648
Description
What happened
Completed Furrow-backed tasks can leave their snapshot timelines permanently registered after CodeAF removes the task directory. The fork disappears from furrow forks, but its captured contents remain reachable and cannot be reclaimed by furrow gc. Repeated task completion can therefore accumulate repository and dependency data in the content store.
CodeAF's cleanup sequence deletes the directory and then calls:
furrow --json fork-rm <name> --keep-files
Furrow documents --keep-files as preserving both the directory and timeline. CodeAF's DropFork comments instead expect it to remove the timeline while leaving file deletion to CodeAF. This is a cleanup contract mismatch, not a failure of GC to collect unreachable objects.
Verified against Furrow 0.1.0. The affected CodeAF sequence is still present on dev@847a22dda8bef61d5872582cf7f135656d54ad75 as of September 28, 2026.
Related: #195 addressed leftover fork-list records. This report concerns retained workspace timelines and GC reachability even when the fork-list entry has been removed successfully.
Replication
Deterministic (no model). Requires Python 3, Git, and Furrow 0.1.0 on PATH. This reproduces the lifecycle sequence used by CodeAF, with a normal-removal control. It creates its own disposable repositories and isolated FURROW_DATA_DIR stores; no existing workspace, credentials, or model calls are needed.
python3 - <<'PY'
import json, os, pathlib, shutil, subprocess, tempfile
with tempfile.TemporaryDirectory(prefix="furrow-retirement-repro-") as tmp:
for mode in ("normal_remove", "codeaf_sequence"):
case = pathlib.Path(tmp) / mode
root, child = case / "root", case / "child"
root.mkdir(parents=True)
(root / "source.txt").write_text("fixture\n")
env = dict(os.environ, FURROW_DATA_DIR=str(case / "store"))
def run(*args):
return subprocess.check_output(
args, env=env, text=True, stderr=subprocess.PIPE, timeout=90
)
def furrow(repo, *args):
return run("furrow", "--json", "--repo", str(repo), *args)
run("git", "init", "-q", str(root))
run("git", "-C", str(root), "add", "source.txt")
run("git", "-C", str(root), "-c", "user.name=Fixture",
"-c", "[email protected]", "commit", "-qm", "fixture")
furrow(root, "watch", "--no-daemon")
furrow(root, "fork", "child", "--destination", str(child))
(child / "unique-child.bin").write_bytes(os.urandom(1024 * 1024))
furrow(child, "snap")
if mode == "codeaf_sequence":
shutil.rmtree(child)
furrow(root, "fork-rm", "child", "--keep-files")
else:
furrow(root, "fork-rm", "child")
# Fixture only: remove the parent's history so only a leaked child
# can keep content reachable. Never do this to an existing project.
furrow(root, "forget", "--purge")
report = json.loads(furrow(root, "gc", "--dry-run"))
print(mode, json.dumps({k: report[k] for k in (
"roots", "reachable_objects", "reachable_payload_bytes"
)}))
PYObserved results from the equivalent isolated reproduction:
| Sequence | Retained GC roots | Reachable objects | Reachable payload |
|---|---|---|---|
| Normal removal while the child exists | 0 | 0 | 0 bytes |
Delete child, then fork-rm --keep-files |
2 | 76 | 1,095,947 bytes |
Exact object counts and byte totals can vary with Git metadata; the invariant is zero reachable child content in the control versus retained child content after the CodeAF sequence.
Field (real models). The affected path is successful landing of a task that actually used the Furrow workspace rung, followed by task cleanup. No real-model run is required to reproduce this storage-lifecycle defect. The reproduction above exercises the real Furrow CLI contract; end-to-end CodeAF coverage is part of acceptance below.
Where
taskTree.releaseLandedandtaskTree.dropUniverse: directory removal precedes timeline retirement; retirement errors are discarded on the landing path.Workspace.DropFork: passes--keep-filesdespite expecting timeline removal.dropSweptForksininternal/session/sweep.gouses the same retirement helper and needs the same contract coverage.- In Furrow 0.1.0,
FurrowRepository::remove_forkonly purges the child in the!keep_files && destination.exists()branch. GC retains registered workspace timelines regardless of whether their directories still exist.
The fix
Retire the owned task fork's timeline before removing the directory needed to identify it. Use a Furrow operation that actually purges the completed fork's registration and history; ordinary fork-rm while the verified child still exists is the existing operation to evaluate. Coordinate directory ownership so CodeAF does not remove the directory first.
Simply removing --keep-files from the current command is insufficient: when the directory is already absent, Furrow 0.1.0 also skips child timeline retirement.
If retirement fails, retain enough identity to retry and report the failure instead of treating it as a harmless listing artifact. Successful task work must remain landed, and cleanup must not touch active forks, parent workspace history, or unrelated snapshots.
Existing orphan recovery is a separate migration concern. Use verified fork/workspace identities; do not indiscriminately purge every workspace with a missing directory, since retained history may still be recoverable user work.
Acceptance
- e2e: Exercise a Furrow-backed task through CodeAF's real task execution and landing path, using a deterministic worker/provider fixture and the real pinned Furrow binary with an isolated store. Assert the task exits successfully and its committed result is present in the parent. After cleanup, assert both the fork-list entry and child workspace timeline are gone. Child-only fixture data must become reclaimable by GC while parent history remains usable.
- e2e: Repeat task completion in the same fixture. Completed child registrations must not accumulate. Checking only
furrow forksoutput is insufficient. - e2e: Exercise interrupted-session sweeping, a failed retirement followed by retry, and an already-missing destination. Cleanup must either retire the verified timeline or leave an explicit retryable failure; it must never report retirement while silently retaining an orphan.
- Control: Active forks, deliberately retained history, unrelated workspaces, and the non-Furrow task paths remain unchanged.
- Unit: Assert retirement happens before destructive directory cleanup and all task-fork cleanup callers use the same lifecycle contract.
- Add a change entry correcting the assumption that removing a fork-list entry makes its captured contents reclaimable.
Activity
santoshkumarradha commentedon Oct 25, 2026
Fixed on
dev@3ba1d8f76by #1651, preserved in #1632.Cleanup retires the verified fork before deleting its directory;
DropForkno longer uses--keep-files. Failed retirement preserves retryable identity and does not undo the successful landing.Fresh verification used real Furrow 0.1.0 with isolated stores:
See the real lifecycle tests. Full
internal/furrowtests also passed. Closing as fixed; this does not claim a migration that purges pre-existing orphan history.