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:
@@ -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.
|
||||
Reference in New Issue
Block a user