e38eac1a33
Unit 3 renamed bridged.example.yaml to fleetd.example.yaml and taught Fleetd to read fleetd.yaml first. Two things it left behind. bridged/.gitignore still ignored only bridged.yaml. An operator who follows the new comment and copies the example to fleetd.yaml gets an untracked live config holding tokens, and git offers to commit it. Both names are ignored now, because both names work until the cutover. Three design docs still pointed readers at bridged.example.yaml, a file that no longer exists under that name.
184 lines
10 KiB
Markdown
184 lines
10 KiB
Markdown
# CB-301-ext — Worktree provisioning + config-parity overlay
|
||
|
||
**Status:** ✅ shipped — implemented at commit `97ecc71` (per-worker git worktree + config-parity
|
||
overlay). As-built: `session/GitWorktrees.java` behind the `Worktrees` port, wired in
|
||
`Fleetd.main` and configurable via `worktreeRoot` / per-profile `parityOverlay`
|
||
(see `fleetd.example.yaml`). Branch/worktree surface in `fleet_list` landed with CB-304
|
||
(`9fe04bf`); the worker-opened-PR checkpoint landed as CB-302 (`64e70ef`).
|
||
**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`, `FleetConfig.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.fleet.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.fleet.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 — `FleetConfig.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
|
||
|
||
- `fleet_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.
|