Skip to content

Furrow task cleanup retains snapshot timelines after deleting completed task directories #1648

Description

@santoshkumarradha

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:sessionThe engine — turns, tasks, the toolbelt, checkpointsbugSomething the code does that it should notsev:seriousWrong or missing behaviour a person meets in ordinary use

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions