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", "user.email=fixture@example.invalid", "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"
)}))
PY
Observed 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.releaseLanded and taskTree.dropUniverse: directory removal precedes timeline retirement; retirement errors are discarded on the landing path.
Workspace.DropFork: passes --keep-files despite expecting timeline removal.
dropSweptForks in internal/session/sweep.go uses the same retirement helper and needs the same contract coverage.
- In Furrow 0.1.0,
FurrowRepository::remove_fork only 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 forks output 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.
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 byfurrow 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 documents
--keep-filesas preserving both the directory and timeline. CodeAF'sDropForkcomments 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@847a22dda8bef61d5872582cf7f135656d54ad75as 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 isolatedFURROW_DATA_DIRstores; no existing workspace, credentials, or model calls are needed.Observed results from the equivalent isolated reproduction:
fork-rm --keep-filesExact 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.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-rmwhile 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-filesfrom 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
furrow forksoutput is insufficient.