From f9073e23208d1825c3b63a518da0f066b8b47eb0 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 17 Jul 2026 06:32:09 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20CB-301-ext=20spec=20=E2=80=94=20worktre?= =?UTF-8?q?e=20provisioning=20+=20config-parity=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in per-acquire worktree (shared-tree default preserved). git behind a Worktrees seam (ProcessBuilder impl, fake in tests). acquire provisions worktree+branch, overlays local config (copy + --skip-worktree on tracked files so the worker can't commit .mcp.json), spawns with cwd=worktree. release removes the worktree but keeps the branch (holds commits/PR). WorkerSession gains nullable worktree/branch; BridgedConfig.Worker gains parityOverlay. 6 fake-based acceptance tests incl. backward-compat + unwind. --- docs/CB-301-ext-Worktree-Provisioning.md | 179 +++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/CB-301-ext-Worktree-Provisioning.md diff --git a/docs/CB-301-ext-Worktree-Provisioning.md b/docs/CB-301-ext-Worktree-Provisioning.md new file mode 100644 index 0000000..0ebc029 --- /dev/null +++ b/docs/CB-301-ext-Worktree-Provisioning.md @@ -0,0 +1,179 @@ +# CB-301-ext — Worktree provisioning + config-parity overlay + +**Status:** design spec for review → delegate implementation. +**Extends:** [CB-301 Session Manager](CB-301-Session-Manager.md) (shipped, commit `54d907c`). +**Realizes:** the config-parity requirement in [Worker Git Workflow](Worker-Git-Workflow.md). +**Grounded in:** `SessionManager`, `WorkerService.spawn/effectiveCwd`, `BridgedConfig.Worker`, +`inject/…LsofPeerPidLookup` (the `ProcessBuilder` exec pattern). + +## Problem + +CB-301 gives each worker a session record but every worker still runs in the **primary's own +working tree** (`cwd = callerCwd`). One worker at a time is safe; two **parallel implementers** would +stomp each other. We need each implementer session to get an **isolated git worktree on its own +branch** — *without* degrading the worker: a bare worktree checks out **tracked files only**, so it +silently drops the untracked/local config (`.claude/settings.local.json`, the locally-modified +`.mcp.json`, `.env`) that makes a session a full peer of the primary. **CB-301-ext provisions the +worktree AND hydrates it to config parity**, so a worker differs from the primary only in the LLM +provider. + +## Decisions (locked) + +1. **Opt-in, not default.** A worktree is provisioned **only** when the caller requests one. Absent a + request, `acquire` behaves exactly as it does today (shared primary tree) — auditors, smoke tests, + and conversational workers are unaffected. **Backward compatibility is a hard requirement.** +2. **Copy-overlay + `--skip-worktree`, not symlink.** Each parity file is **copied** primary→worktree + (isolation-friendly, no symlink type-change noise on tracked files). For a *tracked* overlay file + (`.mcp.json`) the worktree copy is then marked `git update-index --skip-worktree`, so the worker's + commits can **never** include the parity overlay. Ignored files (`settings.local.json`) stay + ignored in the worktree (shared `info/exclude`), so no marking is needed. +3. **Branch persists; worktree is disposable.** `release` runs `git worktree remove --force` (the + working dir is throwaway) but **never deletes the branch** — the branch holds the worker's commits + and its PR (CB-302). Teardown of the checkout ≠ teardown of the work. +4. **Git behind a seam.** SessionManager depends on a `Worktrees` interface (production impl shells + `git` via `ProcessBuilder`; tests use a fake). No live `git` in unit tests — mirrors the + `WorkerService`/`FakeHerdr` seam. + +## Design + +### `WorktreeRequest` (new, nullable = "no worktree") + +```java +package dev.ltms.bridged.session; +/** Ask acquire() to provision an isolated worktree. null ⇒ run in the shared primary tree. */ +public record WorktreeRequest(String ticketSlug, String baseRef) { + // ticketSlug seeds the branch name; baseRef null/blank ⇒ current HEAD of the repo. +} +``` + +### `WorkerSession` — two nullable fields added + +| Field | Notes | +|---|---| +| `worktree` | absolute path of the provisioned worktree; `null` ⇒ shared tree | +| `branch` | the worker's branch (`worker/-`); `null` ⇒ shared tree | + +Add to the record + `withState`. A `null` worktree keeps every existing test and the shared-tree path +untouched. + +### `Worktrees` seam (new) + +```java +package dev.ltms.bridged.session; +public interface Worktrees { + /** git -C worktree add -b . Returns the worktree path. */ + String add(String repoRoot, String branch, String baseRef); + /** git -C worktree remove --force . Idempotent (already-gone tolerated). */ + void remove(String repoRoot, String worktreePath); + /** Copy each existing overlay path repoRoot→worktree; mark tracked ones --skip-worktree. */ + void overlayParity(String repoRoot, String worktreePath, List overlay); + /** git -C rev-parse --show-toplevel — the repo root that owns cwd. */ + String repoRoot(String cwd); +} +``` + +- **Production impl** `GitWorktrees implements Worktrees` — `ProcessBuilder` per path, `redirectErrorStream(true)`, non-zero exit → a `WorktreeException`. Worktree location = `/` where `worktreeRoot` is a daemon setting (default: sibling `../.bridged-worktrees` of the repo root — **outside** the repo, never nested). +- `overlayParity` per file: skip if absent in `repoRoot`; else copy into the worktree; if `git -C ls-files --error-unmatch ` succeeds (tracked), run `git -C update-index --skip-worktree `. + +### `acquire` — extended, old signature preserved + +```java +// existing (unchanged): shared tree +WorkerSession acquire(String profile, String requestedCwd, String callerCwd, String ownerTerminal); +// new overload: provision a worktree when wt != null +WorkerSession acquire(String profile, String requestedCwd, String callerCwd, String ownerTerminal, + WorktreeRequest wt); +``` + +When `wt != null`: +1. `repoRoot = worktrees.repoRoot(firstNonBlank(requestedCwd, callerCwd))`. +2. `branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce`. +3. `path = worktrees.add(repoRoot, branch, wt.baseRef())`. +4. `worktrees.overlayParity(repoRoot, path, cfg.parityOverlay())`. +5. `spawn(profile, path, callerCwd)` — **the worktree path becomes the worker's cwd** (highest + precedence in `WorkerService.resolveCwd`). +6. register the session with `worktree=path, branch=branch`. +7. **On any failure in 1–5, unwind**: if the worktree was added, `remove` it; do not leave a dangling + registry entry. (Guard/spawn already throw before herdr on a bad base_url — unchanged.) + +### `release` — remove the worktree, keep the branch + +```java +public void release(String paneId) { + WorkerSession s = registry.remove(paneId); + workerService.stop(paneId); // existing + if (s != null && s.worktree() != null) { + worktrees.remove(worktrees.repoRoot(s.cwd()), s.worktree()); // branch is NOT deleted + } +} +``` + +### Config — `BridgedConfig.Worker.parityOverlay` + a `worktreeRoot` + +- Add `List parityOverlay` to the `Worker` record (12th field). Compact-constructor default + when null/empty: `[".mcp.json", ".claude/settings.local.json", ".env", ".envrc"]` (missing paths are + silently skipped, so the default is safe across repos). Update `withProfile`. +- Add a top-level daemon setting `worktreeRoot` (String, nullable → `/.bridged-worktrees`). +- `@JsonIgnoreProperties(ignoreUnknown = true)` already set → additive, no parser breakage. + +### Surface: MCP + REST + +- `bridge_spawn` gains an optional `worktree` arg: `true`, or a ticket slug string. Truthy ⇒ build a + `WorktreeRequest(slug, null)` and call the 5-arg `acquire`. +- `POST /workers` gains `worktree` (+ optional `ticket`) in the body/query, same mapping. +- `workerView`/`view(WorkerSession)` include `worktree` and `branch` **when non-null** (omit for + shared-tree sessions, so existing response assertions for shared-tree spawns are unchanged). + +## Flow + +```mermaid +flowchart TD + A["acquire(profile, ..., WorktreeRequest?)"] --> B{"worktree
requested?"} + B -->|"no (default)"| C["spawn(cwd = callerCwd)
— shared tree, unchanged"] + B -->|yes| D["repoRoot = rev-parse --show-toplevel"] + D --> E["git worktree add path -b branch base"] + E --> F["overlayParity: copy local config in;
--skip-worktree the tracked ones"] + F --> G["spawn(cwd = worktree path)"] + G --> H["register worktree + branch on the session"] + E -.->|"add/overlay/spawn fails"| X["unwind: remove worktree,
no dangling registry entry"]:::warn + C --> R["worker is a full peer of the primary"]:::goal + H --> R + classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff; + classDef goal fill:#2b6cb0,stroke:#2a4365,color:#ffffff; +``` + +*Green = the invariant: whether shared-tree (parity for free) or worktree (parity via overlay), the +worker matches the primary. Amber = the failure-unwind path.* + +## Acceptance (fake `Worktrees`, no live git) + +1. **Backward-compat:** `acquire` with **no** `WorktreeRequest` makes **zero** `Worktrees` calls, + spawns with `cwd = callerCwd`, and records `worktree == null` / `branch == null`. (Every CB-301 + test still passes.) +2. **Provision:** `acquire(..., new WorktreeRequest("cb-999", null))` calls `add(repoRoot, + "worker/cb-999-", null)`, then `spawn` receives the returned worktree path as + `requestedCwd`; the session records that path + branch. +3. **Overlay:** `overlayParity` is invoked with the profile's `parityOverlay` (default list when + unset); the fake asserts tracked paths were `--skip-worktree`'d and missing paths skipped. +4. **Release removes worktree, keeps branch:** releasing a worktree session calls + `Worktrees.remove(repoRoot, path)` and performs **no** branch-delete; a shared-tree session's + release makes no `Worktrees` calls. +5. **Failure unwind:** a fake `add` that throws ⇒ `acquire` throws, the session is **not** registered, + and no worker is left running (spawn not reached / torn down). +6. **Distinct worktrees:** two worktree acquires yield **distinct** branches and paths (no collision). + +## Constraints & exclusions (standing, non-negotiable) + +- **Only a worker sets `ANTHROPIC_BASE_URL`.** Worktrees touch cwd + files only; env path is unchanged + — the guard still runs before any herdr call. +- **Worker cwd stays inside the primary repo** (the worktree is a checkout of it) — never `$HOME`. +- **`.mcp.json` and `wiki/` never enter a worker commit.** `.mcp.json` is overlaid for *reference* but + `--skip-worktree`'d so it can't be staged; `wiki/` is a submodule the worker must not touch. The + CB-302 commit step (and the implementer skill) exclude both. +- **The overlay list stays explicit + minimal** (trust: local secrets flow to an off-subscription + worker). No blanket tree copy. + +## Seams left for later + +- **CB-302** — the worker commit → push → PR checkpoint runs *inside* the worktree on its branch. +- **CB-304** — `roster()` rows surface `worktree`/`branch` for the fleet view.