Files
fleetd/docs/CB-301-ext-Worktree-Provisioning.md
T
Dai Ha e38eac1a33
CI / contract (push) Successful in 48s
CI / build (push) Successful in 1m31s
CB-632: ignore fleetd.yaml too, and fix the docs that named the old example
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.
2026-08-23 07:00:30 +02:00

10 KiB
Raw Blame History

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)

  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")

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 — 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

// 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

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

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.