docs: CB-301-ext spec — worktree provisioning + config-parity overlay

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.
This commit is contained in:
Dai Ha
2026-07-17 06:32:09 +02:00
parent 54d907c314
commit f9073e2320
+179
View File
@@ -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/<slug>-<nonce>`); `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 <repoRoot> worktree add <path> -b <branch> <baseRef|HEAD>. Returns the worktree path. */
String add(String repoRoot, String branch, String baseRef);
/** git -C <repoRoot> worktree remove --force <path>. 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<String> overlay);
/** git -C <cwd> 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 = `<worktreeRoot>/<nonce>` 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 <wt> ls-files --error-unmatch <path>` succeeds (tracked), run `git -C <wt> update-index --skip-worktree <path>`.
### `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<String> 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 → `<repoParent>/.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<br/>requested?"}
B -->|"no (default)"| C["spawn(cwd = callerCwd)<br/>— 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;<br/>--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,<br/>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-<nonce>", 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.