Files
web-terminal/docs/plans/w5-fanout-board.md

20 KiB
Raw Permalink Blame History

Worktree fan-out board (parallel agent lanes)

Feature id: w5-fanout-board · Branch base: develop · Effort: L · Depends on Wave 2 (PTY-inject / initialInput) + Wave 4 (worktree create + remove).

Fan one task across N branch/agent lanes of one repo: create N worktrees, spawn N Claude sessions each pre-injected with the same prompt, watch them race side-by-side in the existing split-grid board, approve/kill per lane, then keep the winner (its tab + worktree stay) while losers get worktree-remove. This is ≈90% composition of shipped parts; the only genuinely new code is a small pure launch-command builder, an in-memory (localStorage-persisted) lane-group model, and one thin read-only grouping endpoint.

Byte-shuttle + no-clobber preserved: the server never learns "fan-out" semantics on the terminal stream. Each lane is its own worktree → own PTY (createWorktree at src/http/worktrees.ts:224 gives a fresh dir; attach(null) spawns a fresh PTY there), so two Claudes editing the same repo never touch the same working tree.

New vs reused

Concern Reused (no change) New (thin)
Spawn N worktrees createWorktree (worktrees.ts:224) via POST /projects/worktree (server.ts:908) orchestrator loops the route N× (sequential)
Spawn N sessions w/ prompt openProjectaddEntryinitialInput (tabs.ts:836,704,717) → typed after attached (terminal-session.ts:374, INITIAL_INPUT_DELAY_MS=700 :30); claude "<prompt>" pattern proven at projects.ts:958 buildFanoutCmd(prompt,mode) — pure, shell-quotes the prompt
Watch board setGridLayout('grid-4'|'grid-6') (tabs.ts:1020), per-quadrant approve/maximize/monitor (renderInlineApprove/toggleMaximize/toggleMonitor tabs.ts:1364,1081,1088), statusLine gauges (renderCell :1328, tab-gauge :1451) per-cell "🏆 Keep" button (same pattern as cell-max wiring tabs.ts:776-801)
Discard losers removeWorktreeReq/confirmAndRemoveWorktree (projects.ts:299,349) → DELETE /projects/worktree (server.ts:937) batch-confirm loop in keepFanoutWinner
Grouped discovery manager.list()/GET /live-sessions (manager.ts:185, server.ts:332); LiveSessionInfo.cwd (types.ts:299) GET /live-sessions/grouped + pure groupSessionsByRepo

Merge of the winner: the winner's session/worktree stays open so the user runs the merge inside it (the ROADMAP + task design collapse "keep winner" to "winner session stays"). A one-click merge is out of scope for v1 (needs conflict handling); an optional thin merge() in git-ops.ts is sketched under Effort as a follow-up.


Contract

New HTTP route (src/server.ts) — the one thin server piece

Method / path Guard Response
GET /live-sessions/grouped none (read-only aggregate, same threat model as /live-sessions :332 and /digest :340) 200 SessionGroup[]

Pure read over manager.list(); no Origin guard, no new write path. Grouping is string-only (no git exec): fan-out worktrees always live under <repo>-worktrees/ (createWorktree base, worktrees.ts:151-152), so sessions whose cwd shares that parent cluster into one group. Registered beside /live-sessions (server.ts:332).

src/types.ts (coordination edit — the frozen shared contract)

// group of running sessions sharing a repo/worktree-root (fan-out discovery)
export interface SessionGroup {
  repoRoot: string;              // derived repo dir (parent of the *-worktrees folder, or the cwd)
  label: string;                 // basename(repoRoot)
  sessions: LiveSessionInfo[];   // members, newest-first (already the manager.list order)
}
// launch options the FE passes to TabApp.launchFanout (FE-internal, but typed shared)
export interface FanoutLaunchOpts {
  prompt: string;
  lanes: number;                 // 2..maxFanoutLanes
  branchBase: string;            // slug; lanes get `${branchBase}-lane-${i}`
  mode?: PermissionMode;         // reuse types.ts:445
}

Add maxFanoutLanes?: number to UiConfig (types.ts:656) so the FE stepper max is server-controlled.

src/config.ts env var (additive, optional)

Env Field (add to Config types.ts:26 block) Default Parser
MAX_FANOUT_LANES maxFanoutLanes: number 6 (matches grid-6 capacity, grid-layout.ts:30) parseNonNegativeInt (config.ts:83, as maxSessions :297)

Effective N is min(opts.lanes, cfg.maxFanoutLanes, 6, maxSessions liveCount) — bounded by grid capacity and the DoS cap (assertUnderSessionCap, manager.ts:106).

Client-side contract

  • No new ClientMessage/ServerMessage. The stream stays a byte-shuttle. Launch = N existing POST /projects/worktree + N existing WS attaches (via openProject). Discard = existing DELETE /projects/worktree.
  • New ProjectsHooks method (projects.ts:39): onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => void — mirrors the existing onOpenProject hook exactly; wired in tabs.ts constructor (:180) to this.launchFanout(...).
  • New pure launch-cmd builder buildFanoutCmd(prompt, mode, allowAutoMode): stringclaude [--permission-mode <m>] '<shell-quoted, newline-collapsed prompt>'\r. Single-quote wrap with ''\'' escaping; collapse \r?\n→space; cap length (FANOUT_PROMPT_MAX = 4000). Reuses resolveMode/buildClaudeCmd shape (tabs.ts:659,664).

Files to change

Path Concrete change
src/types.ts Coordination edit. Add SessionGroup, FanoutLaunchOpts; add maxFanoutLanes?: number to UiConfig (:656); add maxFanoutLanes: number to Config (:26 block).
src/config.ts Parse MAX_FANOUT_LANES in loadConfig (helper exists, parseNonNegativeInt :83); include in returned Config.
src/http/session-groups.ts (new) Pure deriveRepoRoot(cwd: string | null): string | null (strip a trailing /<name> when the parent basename ends with -worktrees, else return cwd) + groupSessionsByRepo(sessions: LiveSessionInfo[]): SessionGroup[]. No I/O — fully node-unit-testable. High-cohesion small file (per coding-style).
src/server.ts Register GET /live-sessions/groupedres.json(groupSessionsByRepo(manager.list())) beside :332; import groupSessionsByRepo; add maxFanoutLanes to the UiConfig object at /config/ui (:1099).
public/fanout.ts (new) buildFanoutCmd(prompt, mode, allowAutoMode), shellSingleQuote(s), sanitizePrompt(s) (collapse newlines, trim, cap), laneBranch(base, i), slugify(prompt) — all pure/exported for jsdom unit tests. Plus createWorktreeReq(repoPath, branch) thin POST helper if not reusing the inline one in projects.ts:725 (extract it to reuse — see below).
public/projects.ts Add onFanout to ProjectsHooks (:39); add renderFanoutForm(detail, hooks) (sibling of renderNewWorktreeForm :692: prompt <textarea>, N <input type=number min=2 max=maxFanoutLanes>, branch-base <input> prefilled slugify(prompt), permission-mode <select>, "⑃ Fan out N lanes" submit) rendered in renderProjectDetail (:822) just after the New Worktree form (:930); extract the inline POST /projects/worktree body (:725-737) into an exported createWorktreeReq(repoPath, branch) so fan-out and the form share it (DRY).
public/tabs.ts Add launchFanout(repoPath, repoName, opts) + keepFanoutWinner(groupId, winnerSessionId); a fanoutGroups: Map<string, FanoutGroup> field (persisted via a new FANOUT_KEY); extend TabEntry with fanoutGroupId?: string, worktreePath?: string, branch?: string; append a per-cell 🏆 Keep button in addEntry (:776-801) shown only when entry.fanoutGroupId is set (toggled in renderCell :1328); wire onFanout in the constructor (:180). Reuse setGridLayout (:1020), addEntry (:704), countOpenWithTitlePrefix (:849), removeWorktreeReq (projects.ts:299).
public/styles.css (or the FE CSS entry) .proj-fanout-form, .cell-keep (mirror .cell-max tabs.ts:779), .fanout-banner. No new layout — reuses lay-grid-4/6 + term-cell chrome.

launchFanout algorithm (FE orchestration, sequentialgit worktree add takes a repo lock, so parallel adds race):

  1. Clamp N = min(opts.lanes, maxFanoutLanes, 6); validate branchBase via validateBranchNameClient (projects.ts:672); build cmd = buildFanoutCmd(prompt, mode, allowAutoMode) once.
  2. for i in 1..N: const r = await createWorktreeReq(repoPath, laneBranch(base,i)); on r.ok push {branch, worktreePath:r.path} to the group; on failure record the error, continue (partial-success, surfaced in the banner — no auto-rollback in v1).
  3. If ≥1 lane created: create a FanoutGroup {id, repoPath, repoName, prompt, lanes[]}; for each created lane addEntry(null, ${repoName}·${branch}, worktreePath, cmd) (reuse openProject's exact addEntry call shape :842), tag the returned TabEntry with fanoutGroupId/worktreePath/branch; persist.
  4. setGridLayout(N <= 4 ? 'grid-4' : 'grid-6') and activate the first lane. The board's existing per-quadrant approve/maximize/monitor + gauges now cover every lane for free.

keepFanoutWinner(groupId, winnerSessionId):

  1. Resolve the group; single confirm: "Keep <winner branch> and discard the other N1 lanes (delete their worktrees)?".
  2. For each losing lane: closeTab(idx) (detach — PTY reaped by IDLE_TTL) then removeWorktreeReq(repoPath, lane.worktreePath, false); on 409 dirty → removeWorktreeReq(..., true) (already inside the batch confirm — no per-lane prompt). Collect failures into the banner.
  3. Winner tab stays; drop the group from fanoutGroups; if all losers gone, setGridLayout('single') and activate the winner.

TDD steps (ordered — RED→GREEN, matching repo style)

Backend pure — test/http/session-groups.test.ts (new, node env, no I/O; mirror the pure-helper style of test/http/worktrees*.test.ts):

  1. deriveRepoRoot('/a/proj-worktrees/lane-1')/a/proj (parent basename ends -worktrees). deriveRepoRoot('/a/proj')/a/proj. deriveRepoRoot(null)null. → implement.
  2. groupSessionsByRepo([...]): two sessions with cwds /a/proj-worktrees/lane-1 and /lane-2one SessionGroup (repoRoot:/a/proj, 2 members); an unrelated /b/other cwd → its own group; cwd:null → skipped or an "ungrouped" bucket (decide + assert). Members preserve manager.list newest-first order.

Backend config — test/config.test.ts (extend): assert maxFanoutLanes default 6, MAX_FANOUT_LANES=3 override parses, -1 throws (fail-fast, like the MAX_SESSIONS test). Update every all-fields CFG fixture (grep -rl "maxSessions" test/) to add maxFanoutLanes (compile gate).

Backend route — test/integration/*.test.ts (extend an existing live-sessions integration file; reuse startServer + fetch, itPty where a real session is needed): 3. GET /live-sessions/grouped on an empty manager → 200 []. No Origin header required (read-only) — assert it does not 403. 4. itPty: attach two sessions with cwds under one *-worktrees dir → grouped returns one group with both ids. GET /config/ui includes maxFanoutLanes.

FE pure — test/fanout.test.ts (new, jsdom or node; pure functions): 5. shellSingleQuote("it's ok")'it'\''s ok'. sanitizePrompt("a\nb\r\nc")"a b c"; over-length → truncated to FANOUT_PROMPT_MAX. 6. buildFanoutCmd("fix bug","plan",true)claude --permission-mode plan 'fix bug'\r; mode:'default'claude 'fix bug'\r; mode:'auto', allowAutoMode:false → downgraded to no --permission-mode (SEC-M5 parity with resolveMode tabs.ts:659). 7. laneBranch('feat-x',3)feat-x-lane-3; result passes validateBranchNameClient (projects.ts:672).

FE component — test/projects.test.ts (extend; reuse makeDetail/makeHooks, stubbed fetch): 8. renderFanoutForm renders a prompt textarea, N stepper (max = maxFanoutLanes), branch-base input, mode select, submit. Empty prompt → submit disabled / inline error via textContent (SEC-L3/H6, like renderNewWorktreeForm :708). 9. Submitting calls hooks.onFanout(detail.path, detail.name, {prompt, lanes, branchBase, mode}).

FE orchestration — test/tabs.test.ts (extend; the file already stubs TerminalSession/fetch): 10. launchFanout(repo,'repo',{lanes:3,...}) with fetch stubbed to return {ok:true,path:'/wt/lane-i'}3 addEntry calls (assert 3 tabs), each with the same initialInput (buildFanoutCmd output) and its lane cwd; grid becomes grid-4; worktree POSTs happen sequentially (assert call order / that the (k+1)th starts after the kth resolves). 11. Partial failure: 2nd createWorktreeReq rejects → 2 lanes created, banner shows the failure, no throw (never-throw discipline, projects.ts:299). 12. keepFanoutWinner(id, winnerId): confirm=()=>true, fetch {ok:true} → loser tabs closed (assert tabs.length drops to 1), DELETE /projects/worktree called once per loser with the loser's worktreePath; winner tab remains; layout → single. 13. Dirty loser: DELETE first {ok:false,status:409} then {ok:true} → second call body has force:true; no second window.confirm (batch already confirmed). 14. confirm=()=>false → no closeTab, no DELETE (no accidental deletion).

Run npm test; the ~80% gate holds — grouping/config/route are deterministic node tests, and every FE branch (build-cmd modes, sequential launch, partial failure, keep-winner happy/dirty/cancel) is jsdom-exercised. The only PTY-real assertion (grouped over live sessions) is itPty-gated (auto-skips in sandbox, runs in CI).


Edge cases

  • Prompt with quotes / $ / backticks / ; — single-quote wrapping + '\'' escaping means the shell passes it verbatim to claude as one argv element; no command injection into the shell (see Security).
  • Multi-line prompt — a raw newline typed into the PTY submits early; sanitizePrompt collapses \r?\n→space (single-line task descriptions are the use case, like an issue title). Documented in the form's helper text.
  • N exceeds caps — clamped to min(maxFanoutLanes, 6, maxSessionsliveCount); if the DoS cap is hit mid-launch, addEntry→attach throws server-side via assertUnderSessionCap (manager.ts:106, the M4 path) and that lane shows an exit(-1); the banner reports "started K of N".
  • Branch already checked out / dir existscreateWorktree returns 409 (worktrees.ts:200-208); that lane is skipped with a banner note; other lanes proceed (each branch is -lane-i, so collisions only on a re-run — suggest a fresh branchBase).
  • Sequential lock contention — awaiting each createWorktree before the next avoids git's worktree add lock race; total launch is O(N) git adds (bounded, N≤6).
  • Keep-winner on a dirty loserDELETE 409 → auto-retry with force:true inside the single batch confirm (the user already accepted "discard the other lanes"); still never force-removes the main worktree (server rejects isMain 400, worktrees.ts:327) — losers are never main.
  • Winner == current worktree you're viewing — fine; only losers are removed. Removing a loser whose tab is elsewhere just detaches then deletes its dir.
  • Reload mid-racefanoutGroups persisted to FANOUT_KEY (like TABS_KEY tabs.ts:57, written in onSessionId once ids resolve); on boot, reconstruct the board from persisted groups. Fallback discovery: GET /live-sessions/grouped re-clusters live sessions by repo even if localStorage was cleared.
  • Session exits (Claude finished) before you pick a winner — the exited lane keeps its last screen (L1 replay, manager.ts:149) and its gauge greys stale (STATUSLINE_TTL_MS, tabs.ts:64); still selectable as winner (its worktree persists until you keep someone).
  • worktreeEnabled=0 — create/remove routes 403 (server.ts:910,939); launchFanout surfaces the 403 in the banner and creates nothing (the form can hide itself when /config/ui signals disabled — optional).

Security

  • No new trust boundary. Launch reuses POST /projects/worktree and each lane's WS attach; discard reuses DELETE /projects/worktree — all already requireAllowedOrigin-guarded (server.ts:480,909,938). GET /live-sessions/grouped is read-only (session ids, cwds, statuses — same data already in /live-sessions), so no Origin guard, consistent with /live-sessions (:332) and /digest (:340).
  • Prompt → shell injection (the one new risk). The prompt is typed as raw bytes into the lane's shell before claude parses it. shellSingleQuote wraps it in single quotes with ''\'' escaping so the shell treats it as a single literal argv element — no $(...), backtick, ;, &&, or redirection can execute. sanitizePrompt additionally strips newlines (which would submit a partial line) and caps length. This is the load-bearing quoting invariant — unit-test it (steps 5-6) and never String-concat the prompt into the command unquoted. (The threat is limited anyway: the caller already has full shell access via the terminal — this just avoids a surprising early-execution when a prompt contains shell metacharacters.)
  • Branch names — validated client-side (validateBranchNameClient :672) and server-side (validateBranchName worktrees.ts:95, -b <branch> after --); flag-injection (--leading) and traversal already rejected; the worktree dir is containment-checked (computeWorktreeDir M2, :146).
  • Destructive keep-winner — a single explicit confirm before deleting N1 worktrees; removeWorktree's realpath-must-match-a-registered-worktree spine (worktrees.ts:313-323) means only genuine linked worktrees can be deleted, never an arbitrary path, and never main. Errors render via textContent (SEC-L3/H6), never innerHTML.
  • DoS bounds — N capped by maxFanoutLanes/grid-6/maxSessions; each lane is one bounded PTY; grouping endpoint does zero git/FS work (pure string grouping over the in-memory list).
  • Audit — each createWorktree/removeWorktree already logs via sanitizeForLog (server.ts:923,952); no new logging path, prompt bytes are never logged.

Effort & dependencies

  • Rough effort: L (~34 dev-days). Backend is genuinely thin: config field + one pure grouping module + one read-only route (~0.5 d incl. tests). FE carries the weight: public/fanout.ts pure helpers (~0.5 d), renderFanoutForm + onFanout wiring (~0.5 d), launchFanout/keepFanoutWinner/lane-group model + persistence + the 🏆 cell button (~1.5 d), tests (~0.75 d).
  • Depends on (all shipped): W2 initialInput (terminal-session.ts:127,374) + openProject/addEntry (tabs.ts:836,704); W4 createWorktree (worktrees.ts:224) + removeWorktree (:301) + removeWorktreeReq/confirmAndRemoveWorktree (projects.ts:299,349); the split-grid board (grid-layout.ts, tabs.ts applyLayout/renderInlineApprove/renderCell). No node-pty/protocol change, no new WS frame, no DB/migration, no new npm dep.
  • Coordination: the src/types.ts edit (SessionGroup, FanoutLaunchOpts, UiConfig.maxFanoutLanes, Config.maxFanoutLanes) is the only cross-cutting change — freeze it first and update every all-fields CFG test fixture in the same commit (it touches Config).
  • Optional follow-ups (explicitly out of v1 scope): a thin merge(repoPath, branch, opts) in src/http/git-ops.ts (sibling of commit :218/push :312, reusing classifyGitError :81) behind POST /projects/git/merge for one-click "land the winner"; server-side git --git-common-dir grouping for exact (non-heuristic) clustering; auto-rollback of partially-created worktrees on launch failure. Each is additive and can ship after the board proves out.