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.
10 KiB
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 (shipped, commit 54d907c).
Realizes: the config-parity requirement in Worker Git Workflow.
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)
- Opt-in, not default. A worktree is provisioned only when the caller requests one. Absent a
request,
acquirebehaves exactly as it does today (shared primary tree) — auditors, smoke tests, and conversational workers are unaffected. Backward compatibility is a hard requirement. - 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 markedgit 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 (sharedinfo/exclude), so no marking is needed. - Branch persists; worktree is disposable.
releaserunsgit 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. - Git behind a seam. SessionManager depends on a
Worktreesinterface (production impl shellsgitviaProcessBuilder; tests use a fake). No livegitin unit tests — mirrors theWorkerService/FakeHerdrseam.
Design
WorktreeRequest (new, nullable = "no worktree")
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)
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—ProcessBuilderper path,redirectErrorStream(true), non-zero exit → aWorktreeException. Worktree location =<worktreeRoot>/<nonce>whereworktreeRootis a daemon setting (default: sibling../.bridged-worktreesof the repo root — outside the repo, never nested). overlayParityper file: skip if absent inrepoRoot; else copy into the worktree; ifgit -C <wt> ls-files --error-unmatch <path>succeeds (tracked), rungit -C <wt> update-index --skip-worktree <path>.
acquire — extended, old signature preserved
// 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:
repoRoot = worktrees.repoRoot(firstNonBlank(requestedCwd, callerCwd)).branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce.path = worktrees.add(repoRoot, branch, wt.baseRef()).worktrees.overlayParity(repoRoot, path, cfg.parityOverlay()).spawn(profile, path, callerCwd)— the worktree path becomes the worker's cwd (highest precedence inWorkerService.resolveCwd).- register the session with
worktree=path, branch=branch. - On any failure in 1–5, unwind: if the worktree was added,
removeit; 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
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> parityOverlayto theWorkerrecord (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). UpdatewithProfile. - 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_spawngains an optionalworktreearg:true, or a ticket slug string. Truthy ⇒ build aWorktreeRequest(slug, null)and call the 5-argacquire.POST /workersgainsworktree(+ optionalticket) in the body/query, same mapping.workerView/view(WorkerSession)includeworktreeandbranchwhen non-null (omit for shared-tree sessions, so existing response assertions for shared-tree spawns are unchanged).
Flow
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)
- Backward-compat:
acquirewith noWorktreeRequestmakes zeroWorktreescalls, spawns withcwd = callerCwd, and recordsworktree == null/branch == null. (Every CB-301 test still passes.) - Provision:
acquire(..., new WorktreeRequest("cb-999", null))callsadd(repoRoot, "worker/cb-999-<nonce>", null), thenspawnreceives the returned worktree path asrequestedCwd; the session records that path + branch. - Overlay:
overlayParityis invoked with the profile'sparityOverlay(default list when unset); the fake asserts tracked paths were--skip-worktree'd and missing paths skipped. - 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 noWorktreescalls. - Failure unwind: a fake
addthat throws ⇒acquirethrows, the session is not registered, and no worker is left running (spawn not reached / torn down). - 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.jsonandwiki/never enter a worker commit..mcp.jsonis 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 surfaceworktree/branchfor the fleet view.