Skip to content

fix(jobs): reap a job's branch with its worktree, and sweep the refs that outlived their record - #174

Merged
devswha merged 1 commit into
mainfrom
fix/job-ref-lifecycle
Sep 17, 2026
Merged

devswha merged 1 commit into
mainfrom
fix/job-ref-lifecycle

Conversation

@devswha

@devswha devswha commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Problem

#157: every managed worktree created a job/<id> branch and nothing ever deleted one. worktree.prune answered branchRetained: true unconditionally, and deleting a session did not prune at all. This checkout had 153 job/job-* refs against an empty job store — 92 not merged into main — with no record left to say which carried work.

The rule

Containment, not merge state. A job/* ref is deleted when every commit it points at is reachable from some other local or remote ref (git for-each-ref --contains), and kept when it holds work found nowhere else. The namespace is the app's own, so it needs no user. "Not merged into main" was the wrong question: 80 of the 153 pointed at one commit that checkpoint-c holds.

Changes

Native core (git.rs)

  • worktree.prune removes the checkout and then reaps the branch under the rule above; the reply's branchRetained now means what it says.
  • New worktree.reap: sweeps every job/* ref with no registered worktree and no commit of its own; reports the rest as retained; never touches a branch outside the namespace; rejects params.

Server

  • GjcGitClient.reap(). The orchestrator calls it once per repository per process before the first worktree it creates there; a failure logs and never blocks a run.
  • releaseSessionWorktree (session-worktree-paths.ts): archive the job → prune the checkout (reaps the ref) → on refusal, unarchive. Wired into deleteOrArchiveSessionById(..., { force: true }) through the session-worktrees service. Refusals are AppErrors the route already serialises: SESSION_WORKTREE_RUNNING (409) while the job is reserved|queued|running|aborting, SESSION_WORKTREE_DIRTY (409) while the checkout has uncommitted or ignored-file changes. The session row is untouched in both cases. Archiving a session still keeps checkout and ref (existing test).
  • GJC-LIVE-SPEC.md › Native job authority: the ref lifecycle in one paragraph.

Verification

  • cargo test (git module): prune reaps an empty branch / retains one with unlanded commits; reap handles empty, landed, unlanded and live-worktree cases, is idempotent, rejects params, and ignores feature/* and a branch literally named jobs. npm run check:core clean.
  • gjc-job-orchestrator.worktree.test.ts: real core, real repo — first job reaps job/job-empty and job/job-landed, keeps job/job-unlanded and feature/not-a-job, creates its own branch, and a second turn does not sweep again.
  • session-worktree-runtime.test.ts: four new tests over the real jobs authority — clean delete removes the checkout, reaps the ref, deletes the session and archives the job (visible through the list filter); a dirty checkout is refused and the job is not left archived, then a branch with unlanded commits survives the delete; a running job is refused without touching the checkout; a never-prepared session deletes with nothing to release.
  • This checkout: worktree.reap on the live repo → reaped=153 retained=0. All 35 distinct former tips verified still reachable from a surviving ref.
  • Full npm test: server 1454 pass / 1 skip, client 540, bun all green. typecheck, lint clean.

Closes #157.

…that outlived their record

Every managed worktree created a `job/<id>` branch and nothing ever
deleted one: `worktree.prune` answered `branchRetained: true`
unconditionally, and deleting a session never pruned at all. 153 refs had
piled up in this checkout against an empty job store, 92 of them not
merged into main, with no record left to say which carried work (#157).

The rule is containment, not merge state: a `job/*` ref goes when every
commit it points at is reachable from some other local or remote ref, and
stays when it holds work found nowhere else. The namespace is the app's
own, so no user needs to be asked.

- `worktree.prune` removes the checkout and then reaps the branch under
  that rule; `branchRetained` now reports what happened.
- New `worktree.reap` sweeps orphans: every `job/*` ref with no
  registered worktree and no commit of its own, never a branch outside
  the namespace. The orchestrator runs it once per repository per process
  before the first worktree it creates there, and a failure only logs.
- Deleting a worktree session permanently (`?force=true`) archives its
  job, prunes its checkout and reaps its ref. It refuses with
  `SESSION_WORKTREE_RUNNING` while the job runs and
  `SESSION_WORKTREE_DIRTY` while the checkout has uncommitted changes, and
  the session stays in both cases. Archiving keeps checkout and ref.

Run against this checkout's 153 orphans: all 153 reaped, 0 retained, and
every one of the 35 distinct former tips is still reachable from a
surviving ref (`main`, `origin/*` or `checkpoint-c`) - the 92 "unmerged"
refs pointed at commits other branches already held.

Closes #157.
@devswha
devswha merged commit fed880e into main Sep 17, 2026
6 checks passed
@devswha
devswha deleted the fix/job-ref-lifecycle branch September 17, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(jobs): the app never removes the job/job-* ref it creates (61 accumulated)

1 participant