feat(dev): lab — per-instance git worktrees #133

Closed
opened 2026-06-08 00:15:10 +02:00 by dominik.polakovics · 1 comment

Problem

Every manual lab instance of a project is spawned in the project's main checkout (~/projects/<project>): handleStart passes dir = projectDir(project) for all instances, so two concurrent instances stomp each other's working tree, index, and branch. Only AFK runs are isolated today (CONTEXT.md: "only AFK runs are isolated from the project's main checkout").

Goal: every instance — manual and AFK — runs in its own git worktree, the way AFK runs already do.

The design below was settled in full. This issue implements it; writing the ADR is the first task.

Design (settled)

Core model

  • Every instance runs in its own worktree on its own branch forked from freshly-fetched origin/<default>. ~/projects/<project> is demoted to the reference repo (scanner target + worktree parent + fetch/branch host); it is never an instance's cwd.
  • No carry-over of uncommitted main-checkout edits (out of scope by design).
  • lab's git ops touch only refs + worktrees in the reference repo, never its HEAD/working tree, so manual work in ~/projects/<project> is undisturbed.

Identity — drop slots, unify on <project>~<label>

  • Session name = <project>~<label>, where <label> is afk-<N> | <userlabel>-<timestamp> | <timestamp>.
  • Everything derives from <label>: kind (parseAFKLabel), branch (afk/<N> or lab/<label>), worktree dir (<project>-<N> for AFK [unchanged], <project>-<label> for manual).
  • parseSessionName collapses to split on the first ~ (project names and labels are both ~-free after sanitising). Delete allocateSlot, takenSlots, and the Slot field; AFK's "take slot ≥2 to reserve slot 1" rule goes away.
  • Rows render: AFK → AFK #N (unchanged); manual → label · 15:30 (time-only when unlabelled). The old slot-1 = main rendering is removed.
  • <timestamp> format e.g. 20260608-1530 (readable, sortable); existence-check-and-bump on a same-second collision.

Start (synchronous, fail-loud)

auth check → git fetch origin → base origin/<default> → git worktree add -b lab/<label>-<ts> <wt> origin/<default> → SeedTrust(<wt>) → spawn in <wt>

  • Any failure (no origin, fetch fails, origin/<default> unresolvable, worktree add fails) aborts Start with the git cause surfaced in the banner, rolling back any partial worktree (mirror AFK's teardownClaim).
  • Add a timeout to the worktree-creation git ops so a network stall fails loudly instead of hanging the request.
  • No fallback base: local-only / uncredentialed-remote repos can't launch until their remote is fixed — by design ("fail loud so I can see something's wrong").

Teardown — ONE guarded rule everywhere

Applies to Stop, the AFK reaper (success and failure), startup orphan reconciliation, and the runtime sweep:

  • dirty worktree → keep worktree + branch.
  • clean worktree → remove worktree; delete branch iff merged into origin/<default>, else keep branch.

AFK keeps only its outcome accounting: the consecutive-failure counter and the budget-clock neutrality (afkRunsMu / drop from afkStarts) so a hand-Stop is never reaped as a death. The unmerged afk/<N> branch is kept on Stop, so the claim/park (ADR-0013) survives.

Cleanup

  • Startup: reconcile — every orphan worktree (<project>-<label> with no live <project>~<label> session) gets guarded teardown; every merged lab//afk/ branch + its worktree is deleted.
  • Runtime: piggyback the existing reaper (throttled — not every 30s tick); git branch --merged origin/<default> after a best-effort fetch; auto-delete merged lab/ and afk/ branches + their clean worktrees. Never touches dirty/unmerged.
  • This finally GCs merged afk/<N> branches, which lab currently keeps forever.

Parked-work view (new UI)

  • A per-project collapsible <details> "Parked" strip on the card (same idiom as the ⋯ menu), collapsed by default showing a cheap count (git for-each-ref refs/heads/lab refs/heads/afk + git worktree list).
  • Expand → a dedicated lazy endpoint computes per-entry: branch name, dirty ● / clean ○, commits ahead of base, age, best-effort PR badge, worktree path (copyable). Shows both lab/ and afk/, tagged by kind. Keep it off the ~4s fragment poll.
  • Per-entry Discard: remove the worktree (if any) + delete the branch, behind the two-step confirm used by "Stop all"; the entry shows ahead/unpushed state so unpushed commits aren't nuked blindly.
  • No Resume.
  • Note: discarding a parked afk/<N> deletes its claim branch → the issue becomes claimable again (a manual requeue; consistent with ADR-0013).

Tasks

  1. ADR — write docs/adr/00NN-lab-per-instance-worktrees.md capturing the above; add a superseding note to ADR-0007 (manual Stop now applies guarded teardown to AFK runs; neutrality-on-failure unchanged). Update CONTEXT.md's Instance entry (drop "only AFK runs are isolated…").
  2. Identity refactorinstance.go: drop slots; <project>~<label> scheme; parseSessionName = split-on-first-~; label = <userlabel>-<timestamp> / <timestamp>; helpers to derive branch + worktree path from a session name (AFK + manual).
  3. Starthandlers.go / git.go / sessions.go: synchronous fail-loud worktree creation off origin/<default> with rollback + timeout; spawn in the worktree; SeedTrust(<wt>).
  4. Teardown — extract the single guarded-teardown helper; wire it into Stop (manual path), the reaper (success+failure), startup reconciliation, and the runtime sweep. Keep AFK's outcome accounting intact.
  5. Cleanup — startup orphan + merged reconciliation; throttled runtime merged-sweep folded into the reaper.
  6. Parked view — template <details> + cheap count on the poll path; lazy details endpoint; Discard action with confirm.
  7. Tests — Go unit tests for: split-on-first-~ parsing, label/branch/worktree derivation, guarded-teardown decision table, orphan detection, merged detection, parked-view enumeration. Run go test ./... locally — the eval-only pre-commit does not build/run Go.
  8. default.nix — bump the version date string.

Acceptance

  • Two concurrent instances of the same project never share a working tree.
  • Stopping a clean instance removes its worktree and keeps the branch only if unmerged; a dirty instance keeps both and appears under Parked.
  • Crash/reboot leaves no clean orphan worktrees after the next startup; merged lab//afk/ branches get GC'd at runtime.
  • A repo with no usable origin shows a clear Start error rather than silently doing anything.
  • AFK runs still: claim via afk/<N>, reap on PR/death/timeout, count failures, and treat a manual Stop as neutral.

Out of scope

  • Merge-back affordances in lab (work lands via the normal PR flow + land-pr; merged branches auto-clean).
  • Seed prompts for manual instances (human-driven; only AFK runs are seeded).
  • Resume / durable-workspace re-entry.

Design settled via a /grill-me session; see CONTEXT.md ("Instance", "AFK run"), ADR-0007 (lab drives AFK runs), ADR-0013 (claim is the branch).

## Problem Every manual `lab` instance of a project is spawned in the project's **main checkout** (`~/projects/<project>`): `handleStart` passes `dir = projectDir(project)` for all instances, so two concurrent instances stomp each other's working tree, index, and branch. Only AFK runs are isolated today (CONTEXT.md: *"only AFK runs are isolated from the project's main checkout"*). **Goal:** every instance — manual *and* AFK — runs in its own `git worktree`, the way AFK runs already do. The design below was settled in full. This issue implements it; **writing the ADR is the first task.** ## Design (settled) ### Core model - Every instance runs in its own worktree on its own branch forked from freshly-fetched `origin/<default>`. `~/projects/<project>` is demoted to the **reference repo** (scanner target + worktree parent + fetch/branch host); it is never an instance's cwd. - No carry-over of uncommitted main-checkout edits (out of scope by design). - lab's git ops touch only refs + worktrees in the reference repo, never its HEAD/working tree, so manual work in `~/projects/<project>` is undisturbed. ### Identity — drop slots, unify on `<project>~<label>` - Session name = `<project>~<label>`, where `<label>` is `afk-<N>` | `<userlabel>-<timestamp>` | `<timestamp>`. - Everything derives from `<label>`: kind (`parseAFKLabel`), branch (`afk/<N>` or `lab/<label>`), worktree dir (`<project>-<N>` for AFK [unchanged], `<project>-<label>` for manual). - `parseSessionName` collapses to *split on the first `~`* (project names and labels are both `~`-free after sanitising). Delete `allocateSlot`, `takenSlots`, and the `Slot` field; AFK's "take slot ≥2 to reserve slot 1" rule goes away. - Rows render: AFK → `AFK #N` (unchanged); manual → `label · 15:30` (time-only when unlabelled). The old slot-1 = `main` rendering is removed. - `<timestamp>` format e.g. `20260608-1530` (readable, sortable); existence-check-and-bump on a same-second collision. ### Start (synchronous, fail-loud) `auth check → git fetch origin → base origin/<default> → git worktree add -b lab/<label>-<ts> <wt> origin/<default> → SeedTrust(<wt>) → spawn in <wt>` - Any failure (no `origin`, fetch fails, `origin/<default>` unresolvable, `worktree add` fails) **aborts Start** with the git cause surfaced in the banner, rolling back any partial worktree (mirror AFK's `teardownClaim`). - Add a timeout to the worktree-creation git ops so a network stall fails loudly instead of hanging the request. - **No fallback base:** local-only / uncredentialed-remote repos can't launch until their remote is fixed — by design ("fail loud so I can see something's wrong"). ### Teardown — ONE guarded rule everywhere Applies to Stop, the AFK reaper (success **and** failure), startup orphan reconciliation, and the runtime sweep: - **dirty** worktree → keep worktree + branch. - **clean** worktree → remove worktree; delete branch iff merged into `origin/<default>`, else keep branch. AFK keeps only its **outcome accounting**: the consecutive-failure counter and the budget-clock neutrality (`afkRunsMu` / drop from `afkStarts`) so a hand-Stop is never reaped as a death. The unmerged `afk/<N>` branch is kept on Stop, so the claim/park (ADR-0013) survives. ### Cleanup - **Startup:** reconcile — every orphan worktree (`<project>-<label>` with no live `<project>~<label>` session) gets guarded teardown; every merged `lab/`/`afk/` branch + its worktree is deleted. - **Runtime:** piggyback the existing reaper (throttled — *not* every 30s tick); `git branch --merged origin/<default>` after a best-effort fetch; auto-delete merged `lab/` **and** `afk/` branches + their clean worktrees. Never touches dirty/unmerged. - This finally GCs merged `afk/<N>` branches, which lab currently keeps forever. ### Parked-work view (new UI) - A per-project collapsible `<details>` "Parked" strip on the card (same idiom as the ⋯ menu), collapsed by default showing a cheap count (`git for-each-ref refs/heads/lab refs/heads/afk` + `git worktree list`). - Expand → a dedicated **lazy** endpoint computes per-entry: branch name, **dirty ● / clean ○**, commits **ahead** of base, age, best-effort **PR** badge, worktree path (copyable). Shows both `lab/` and `afk/`, tagged by kind. Keep it off the ~4s fragment poll. - Per-entry **Discard:** remove the worktree (if any) + delete the branch, behind the two-step confirm used by "Stop all"; the entry shows ahead/unpushed state so unpushed commits aren't nuked blindly. - **No Resume.** - Note: discarding a parked `afk/<N>` deletes its claim branch → the issue becomes claimable again (a manual requeue; consistent with ADR-0013). ## Tasks 1. **ADR** — write `docs/adr/00NN-lab-per-instance-worktrees.md` capturing the above; add a *superseding note* to ADR-0007 (manual Stop now applies guarded teardown to AFK runs; neutrality-on-failure unchanged). Update CONTEXT.md's **Instance** entry (drop "only AFK runs are isolated…"). 2. **Identity refactor** — `instance.go`: drop slots; `<project>~<label>` scheme; `parseSessionName` = split-on-first-`~`; label = `<userlabel>-<timestamp>` / `<timestamp>`; helpers to derive branch + worktree path from a session name (AFK + manual). 3. **Start** — `handlers.go` / `git.go` / `sessions.go`: synchronous fail-loud worktree creation off `origin/<default>` with rollback + timeout; spawn in the worktree; `SeedTrust(<wt>)`. 4. **Teardown** — extract the single guarded-teardown helper; wire it into Stop (manual path), the reaper (success+failure), startup reconciliation, and the runtime sweep. Keep AFK's outcome accounting intact. 5. **Cleanup** — startup orphan + merged reconciliation; throttled runtime merged-sweep folded into the reaper. 6. **Parked view** — template `<details>` + cheap count on the poll path; lazy details endpoint; Discard action with confirm. 7. **Tests** — Go unit tests for: split-on-first-`~` parsing, label/branch/worktree derivation, guarded-teardown decision table, orphan detection, merged detection, parked-view enumeration. Run `go test ./...` locally — the eval-only pre-commit does **not** build/run Go. 8. `default.nix` — bump the `version` date string. ## Acceptance - Two concurrent instances of the same project never share a working tree. - Stopping a *clean* instance removes its worktree and keeps the branch only if unmerged; a *dirty* instance keeps both and appears under Parked. - Crash/reboot leaves no clean orphan worktrees after the next startup; merged `lab/`/`afk/` branches get GC'd at runtime. - A repo with no usable `origin` shows a clear Start error rather than silently doing anything. - AFK runs still: claim via `afk/<N>`, reap on PR/death/timeout, count failures, and treat a manual Stop as neutral. ## Out of scope - Merge-back affordances in lab (work lands via the normal PR flow + `land-pr`; merged branches auto-clean). - Seed prompts for manual instances (human-driven; only AFK runs are seeded). - Resume / durable-workspace re-entry. --- *Design settled via a `/grill-me` session; see CONTEXT.md ("Instance", "AFK run"), ADR-0007 (lab drives AFK runs), ADR-0013 (claim is the branch).*
Author
Owner

Superseded by tracer-bullet slices #134 (per-instance worktrees + unified identity), #135 (unified teardown + worktree/branch cleanup), and #136 (parked-work view). The full settled design stays here for reference. Closing in favour of those.

Superseded by tracer-bullet slices #134 (per-instance worktrees + unified identity), #135 (unified teardown + worktree/branch cleanup), and #136 (parked-work view). The full settled design stays here for reference. Closing in favour of those.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos#133
No description provided.