From 4ad3ddcd50d34c730bd592aa800e709503c72cc9 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 28 Jul 2026 20:58:40 +0000 Subject: [PATCH] feat(plan): run a serial plan in the checkout instead of a worktree per feat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worktree carries only tracked files, so it starts without everything git ignores and a suite needs: node_modules/, .venv/, build caches. On a real project that is not a detail — the session cannot run a gate until it has rebuilt the environment, and it pays that per feat and again per attempt. That cost buys isolation from concurrent peers, which is worth it for a squad and worth nothing for one session: there is no other index to collide with. So the squad size now decides where feats work. At --squad-limit 1 (the default) the run is serial and works in the repository itself; above it the worktrees are unchanged. The git preflight moves with them, deliberately: it resolves the base feats are cut from and merged back into and refuses a repository where that would be ambiguous — questions an in-place run does not ask, because it lands nothing. The plan-session CLAUDE.md is not injected either; that swap works by marking the file skip-worktree, and doing it to the entry file a human maintains in the checkout they work in would hide their own file from them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBSaXgJ7ssW1hvpy6r64y --- README.md | 6 ++-- internal/cli/plan.go | 2 +- internal/plan/runner.go | 50 ++++++++++++++++++++++++--------- internal/plan/tree.go | 22 +++++++++++++++ internal/plan/tree_test.go | 57 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c13daf2..3f3be1d 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,9 @@ Before a feat is dispatched, a **context pass** (`--enrich-model`, default `sonn Feats are handed out in **dependency order**, not table order: the `Depends` column the plan already declares is what schedules the run, so a feat is only dispatched once everything it builds on is delivered. If a feat is blocked, everything downstream of it is reported as unreachable with the root cause named, instead of being dispatched into a tree where its foundation does not exist. -`--squad-limit N` (1..6, default 1) runs up to **N sessions at once, each on its own feat and each in its own git worktree**. Filesystem isolation is what makes it safe: every concurrent session gets a worktree under `.csdd/plan//trees/` on branch `csdd//`, so no two agents ever share an index, a build, or a half-written file. Scheduling is the `Depends` graph alone — the `(P)` column is **not** consulted, because it used to mean "consents to share a working tree" and feats no longer share one. +**A serial run works in your checkout.** At `--squad-limit 1` (the default) one session runs at a time, in the repository itself — the environment your suite needs is already installed there. The worktrees below are what a *squad* pays for isolation: a worktree carries only tracked files, so it starts without `node_modules/`, `.venv/` or any other build cache git ignores, and rebuilding those per feat buys a serial run nothing, since it has no peer to collide with. + +`--squad-limit N` (2..6) runs up to **N sessions at once, each on its own feat and each in its own git worktree**. Filesystem isolation is what makes it safe: every concurrent session gets a worktree under `.csdd/plan//trees/` on branch `csdd//`, so no two agents ever share an index, a build, or a half-written file. Scheduling is the `Depends` graph alone — the `(P)` column is **not** consulted, because it used to mean "consents to share a working tree" and feats no longer share one. Because a plan is a DAG, isolation alone is not enough: `c` depends on `a` and `b`, and a worktree cut from an untouched base would contain neither. So the runner **merges a feat into the run's base the moment its `done` clears the verdict gate**, and every later worktree is cut from that updated base. The branch survives the merge; the worktree is removed. The human PR gate still sits where it always did, after the run, over the base branch — the session commits on its feat's branch and does not open one. @@ -326,7 +328,7 @@ csdd plan status # feats, milestones, what's next csdd plan run # bypass-mode loop — alerts + asks if the sandbox isn't verified csdd plan run --yes # pre-accept the unverified-sandbox alert (non-interactive) --session-budget 5 # per-session USD cap · --max-iterations (100) · --stall (10) - --squad-limit 3 # run up to 3 sessions at once (1..6, default 1), one git worktree each + --squad-limit 3 # 3 sessions at once, one git worktree each (default 1: serial, in this checkout) --enrich-model none # skip the per-feat context pass (default: sonnet) csdd plan brief --feat F # print what the session gets (enriches on first use) csdd plan brief --feat F --refresh # discard the stored context pack and run the pass again diff --git a/internal/cli/plan.go b/internal/cli/plan.go index 6509ecc..7b62e2c 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -78,7 +78,7 @@ func planRun(args []string, templates embed.FS) int { fs.IntVar(&stall, "stall", 10, "Stop early after this many consecutive sessions without a step advancing.") fs.DurationVar(&sessionIdle, "session-idle", 0, "Kill a session that makes no progress — no event stream output and no CPU — for this long (default 15m). Not a time limit: real work of any duration keeps resetting it.") fs.IntVar(&featAttempts, "feat-attempts", 0, "Stop handing out ONE feat after this many sessions and surface it as blocked (default 4). Bounds a feat whose `done` the verdict gate keeps refusing.") - fs.IntVar(&squadLimit, "squad-limit", 0, "Maximum claude sessions running at once, each on its own feat in its own git worktree (1..6, default 1). Feats run together whenever the plan's Depends graph allows it; each delivered feat is merged into the run's base branch. Requires a clean git repository.") + fs.IntVar(&squadLimit, "squad-limit", 0, "Maximum claude sessions running at once (1..6, default 1). At 1 the run is serial and works in this checkout, where the environment your suite needs is already installed. Above 1 each feat gets its own git worktree — cut from and merged back into the run's base branch, which requires a clean repository, and which starts without anything git ignores (node_modules/, .venv/, build caches).") fs.IntVar(&maxRetries, "max-retries", 0, "Deprecated no-op: each iteration is one session, and the next iteration is the retry.") fs.IntVar(&maxRepairs, "max-repairs", 0, "Deprecated no-op: the self-correcting loop replaced repair sessions.") positionals, err := parseFlags(fs, args) diff --git a/internal/plan/runner.go b/internal/plan/runner.go index 4f6381f..e238d6d 100644 --- a/internal/plan/runner.go +++ b/internal/plan/runner.go @@ -200,24 +200,15 @@ func Run(opts RunOptions) (RunSummary, error) { if !h.ClaudeAvailable() { return RunSummary{}, fmt.Errorf("the `claude` CLI is not available on PATH; the runner needs it to spawn sessions") } - // Every feat now works in its own git worktree, so a repository is no longer - // incidental to a run — it is the isolation mechanism. Resolve the base the run - // integrates into and refuse anything that would make merging feats back - // ambiguous. Skipped when a treeKeeper was injected: the loop's own tests drive - // the whole run against plain directories and no git at all. + // Where the feats work. Skipped when a treeKeeper was injected: the loop's own + // tests drive the whole run against plain directories and no git at all. if opts.Hooks.Trees == nil { - base, err := preflightGit(opts.Root) + keeper, err := resolveTrees(opts, logf) if err != nil { return RunSummary{}, err } - opts.Hooks.Trees = gitTrees{root: opts.Root, slug: opts.Slug, base: base, logf: logf, entry: opts.WorktreeEntry} + opts.Hooks.Trees = keeper h = opts.Hooks - logf("worktrees: one per feat, branched from and merged back into %s", base) - if specsAreIgnored(opts.Root) { - logf("⚠ specs/ is gitignored: each feat's spec will stay in the worktree that authored it and be") - logf(" discarded with it. The code lands on %s; the artifacts justifying it will not, so", base) - logf(" `csdd plan status` and the graph will disagree with the ledger after this run.") - } } // Every session runs bypass-mode (--dangerously-skip-permissions), so a // verified sandbox is the expected home (principle 7). When doctor fails, the @@ -1107,6 +1098,39 @@ func journalDone(opts RunOptions, feat, summary string) { journal(opts, feat, "done", summary) } +// resolveTrees picks where the run's feats work, and the squad size is what decides +// it. +// +// A worktree per feat exists to keep concurrent sessions off one shared index. That +// is a real hazard with a squad and no hazard at all with one session — while the +// cost is the same either way, and it is not small: a worktree holds only tracked +// files, so a serial run was reinstalling every gitignored dependency the suite needs +// before it could run a single gate, per feat and again per attempt. So a serial run +// (the default) works in the repository itself, and only a squad pays for isolation. +// +// The git preflight moves with the worktrees, deliberately. It resolves the base +// feats are cut from and merged back into, and refuses a repository where that would +// be ambiguous — questions an in-place run does not ask, because it lands nothing: +// the session commits on the branch the human left it on. +func resolveTrees(opts RunOptions, logf func(string, ...any)) (treeKeeper, error) { + if opts.SquadLimit <= 1 { + logf("worktrees: off — one session at a time, working in %s itself", opts.Root) + logf(" (--squad-limit >1 isolates each feat in its own worktree, cut from and merged back into the base)") + return inPlaceTrees{root: opts.Root}, nil + } + base, err := preflightGit(opts.Root) + if err != nil { + return nil, err + } + logf("worktrees: one per feat, branched from and merged back into %s", base) + if specsAreIgnored(opts.Root) { + logf("⚠ specs/ is gitignored: each feat's spec will stay in the worktree that authored it and be") + logf(" discarded with it. The code lands on %s; the artifacts justifying it will not, so", base) + logf(" `csdd plan status` and the graph will disagree with the ledger after this run.") + } + return gitTrees{root: opts.Root, slug: opts.Slug, base: base, logf: logf, entry: opts.WorktreeEntry}, nil +} + // runLogf is the runner's line-oriented logger onto opts.Out. func runLogf(opts RunOptions) func(string, ...any) { return func(format string, a ...any) { _, _ = fmt.Fprintf(opts.Out, format+"\n", a...) } diff --git a/internal/plan/tree.go b/internal/plan/tree.go index 19734b8..cc3e54d 100644 --- a/internal/plan/tree.go +++ b/internal/plan/tree.go @@ -53,6 +53,28 @@ type treeKeeper interface { Discard(feat string) error } +// inPlaceTrees is the keeper for a serial run: every feat works in the repository +// itself, on the branch the human is already standing on. +// +// A worktree contains only TRACKED files, so everything a suite needs and git +// ignores is absent from it — node_modules/, .venv/, build caches, the lot. On a +// real project that is not a detail: the session cannot run a test, a lint or a +// build until it has rebuilt the environment, and it pays that per feat and again +// per attempt. The isolation this buys is worth it for a squad, where several +// sessions would otherwise share one index. With ONE session there is no peer to +// collide with, so the run pays the cost and gets nothing for it. +// +// Integrate and Discard are therefore no-ops rather than stubs: the work is already +// on the branch (nothing to land) and no directory belongs to the feat (nothing to +// remove). The plan-session CLAUDE.md swap is deliberately absent too — it works by +// marking the file skip-worktree, and doing that to the entry file a human maintains, +// in the checkout they work in, would hide their own file from them. +type inPlaceTrees struct{ root string } + +func (t inPlaceTrees) Ensure(string) (string, error) { return t.root, nil } +func (t inPlaceTrees) Integrate(string) error { return nil } +func (t inPlaceTrees) Discard(string) error { return nil } + // MergeConflictError signals that a delivered feat could not be landed on the run's // base because it conflicts with what is already there. // diff --git a/internal/plan/tree_test.go b/internal/plan/tree_test.go index d3f9999..e5d5f1d 100644 --- a/internal/plan/tree_test.go +++ b/internal/plan/tree_test.go @@ -525,3 +525,60 @@ func containsSubstring(hay []string, needle string) bool { } return false } + +// TestSerialRunWorksInTheCheckoutItself pins which keeper a run gets, because the +// squad size is the only thing that decides it and the difference is not cosmetic. +// +// A worktree carries only tracked files, so a serial run in one was reinstalling +// every gitignored dependency its suite needs — per feat, and again per attempt — +// to buy isolation from peers it does not have. At --squad-limit 1 the feats work +// in the repository itself; above it, isolation is what the concurrency is paying +// for and the worktrees come back. +func TestSerialRunWorksInTheCheckoutItself(t *testing.T) { + root := gitRepo(t) + writeRepoFile(t, root, "README.md", "# repo\n") + commitAll(t, root, "init") + quiet := func(string, ...any) {} + + keeper, err := resolveTrees(RunOptions{Root: root, Slug: "p", SquadLimit: 1}, quiet) + if err != nil { + t.Fatal(err) + } + inPlace, ok := keeper.(inPlaceTrees) + if !ok { + t.Fatalf("a serial run must work in the checkout, got %T", keeper) + } + // Every feat resolves to the repository itself, and neither landing nor cleanup + // has anything to do: the work is already on the branch the human is standing on. + for _, feat := range []string{"a", "b"} { + dir, err := inPlace.Ensure(feat) + if err != nil { + t.Fatal(err) + } + if dir != root { + t.Errorf("feat %s should work in %s, got %s", feat, root, dir) + } + if err := inPlace.Integrate(feat); err != nil { + t.Errorf("integrate should be a no-op: %v", err) + } + if err := inPlace.Discard(feat); err != nil { + t.Errorf("discard should be a no-op: %v", err) + } + } + // Nothing was created under the run's state dir — no worktree, no branch. + if entries, err := os.ReadDir(filepath.Join(stateDir(root, "p"), "trees")); err == nil && len(entries) > 0 { + t.Errorf("a serial run must cut no worktrees, found %d", len(entries)) + } + if out, _ := runGit(root, "branch", "--list", "csdd/*"); strings.TrimSpace(out) != "" { + t.Errorf("a serial run must create no feat branches, got %q", out) + } + + // A squad still gets the worktrees, and with them the git preflight. + squad, err := resolveTrees(RunOptions{Root: root, Slug: "p", SquadLimit: 2}, quiet) + if err != nil { + t.Fatal(err) + } + if _, ok := squad.(gitTrees); !ok { + t.Errorf("a squad must be isolated per feat, got %T", squad) + } +}