feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)

Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
This commit is contained in:
Yaojia Wang
2026-07-13 05:09:19 +02:00
parent c81821b890
commit 9683a16f4f
20 changed files with 1225 additions and 8 deletions

View File

@@ -156,7 +156,26 @@ const mountProjects = vi.fn(() => ({
},
refresh: vi.fn(),
}))
vi.mock('../public/projects.js', () => ({ mountProjects }))
// W5: TabApp imports these worktree helpers from projects.js for launchFanout /
// keepFanoutWinner. Mocked so we drive them without real fetch and assert routing.
const createWorktreeReqMock = vi.fn(
async (_repoPath: string, branch: string) => ({ ok: true, path: `/wt/${branch}` }) as {
ok: boolean
path?: string
status?: number
error?: string
},
)
const removeWorktreeReqMock = vi.fn(
async () => ({ ok: true }) as { ok: boolean; status?: number; error?: string },
)
const validateBranchNameClientMock = vi.fn((_b: string): string | null => null)
vi.mock('../public/projects.js', () => ({
mountProjects,
createWorktreeReq: (...a: unknown[]) => createWorktreeReqMock(...(a as [string, string])),
removeWorktreeReq: (...a: unknown[]) => removeWorktreeReqMock(...(a as [])),
validateBranchNameClient: (...a: unknown[]) => validateBranchNameClientMock(...(a as [string])),
}))
// settings.js is light but imports nothing heavy; let it load for real.
@@ -200,6 +219,16 @@ beforeEach(() => {
quickReply.onSend = undefined
quickReply.onQueue = undefined
enqueueFollowupMock.mockClear()
// W5 fan-out mocks: reset to permissive defaults each test.
createWorktreeReqMock.mockReset()
createWorktreeReqMock.mockImplementation(async (_repoPath: string, branch: string) => ({
ok: true,
path: `/wt/${branch}`,
}))
removeWorktreeReqMock.mockReset()
removeWorktreeReqMock.mockResolvedValue({ ok: true })
validateBranchNameClientMock.mockReset()
validateBranchNameClientMock.mockReturnValue(null)
voice.onTranscript = undefined
voice.onInterim = undefined
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
@@ -208,6 +237,7 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks() // restore window.confirm etc. spied via vi.spyOn (W5 tests)
})
describe('TabApp — v0.5 launcher chooser', () => {
@@ -1718,3 +1748,169 @@ describe('TabApp — W2 inject queue', () => {
expect(enqueueFollowupMock).not.toHaveBeenCalled()
})
})
// ── W5: fan-out board (launchFanout + keepFanoutWinner) ──────────────────────
describe('TabApp — W5 fan-out board', () => {
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
/** Fan out N lanes and stamp server ids on the resulting sessions. */
async function launched(
app: InstanceType<typeof TabApp>,
lanes: number,
branchBase = 'feat',
): Promise<void> {
await app.launchFanout('/repo', 'repo', { prompt: 'add feature', lanes, branchBase, mode: 'default' })
FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`))
}
const keepBtnOf = (i: number): HTMLButtonElement =>
FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-keep')!
it('launchFanout creates N worktrees SEQUENTIALLY, one tab per lane, on a grid-4 board', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await app.launchFanout('/repo', 'repo', {
prompt: 'add feature',
lanes: 3,
branchBase: 'feat',
mode: 'default',
})
expect(app.snapshot()).toHaveLength(3)
expect(FakeTerminalSession.instances).toHaveLength(3)
// Sequential, in lane order.
expect(createWorktreeReqMock).toHaveBeenCalledTimes(3)
expect(createWorktreeReqMock.mock.calls.map((c) => c[1])).toEqual([
'feat-lane-1',
'feat-lane-2',
'feat-lane-3',
])
// Same shell-quoted launch command injected into EVERY lane.
for (const inst of FakeTerminalSession.instances) {
expect(inst.initialInput).toBe("claude 'add feature'\r")
}
// Each lane spawns in its own worktree cwd.
const cwds = FakeTerminalSession.instances.map(
(i) => (i.cbs as Record<string, unknown>)['cwd'],
)
expect(cwds).toEqual(['/wt/feat-lane-1', '/wt/feat-lane-2', '/wt/feat-lane-3'])
expect(app.getGridLayout()).toBe('grid-4')
})
it('launchFanout uses grid-6 for more than four lanes', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 5)
expect(app.snapshot()).toHaveLength(5)
expect(app.getGridLayout()).toBe('grid-6')
})
it('launchFanout refuses an invalid branch base (no worktrees, banner shown)', async () => {
validateBranchNameClientMock.mockReturnValue('bad branch')
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: '-bad', mode: 'default' })
expect(createWorktreeReqMock).not.toHaveBeenCalled()
expect(app.snapshot()).toHaveLength(0)
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
})
it('launchFanout tolerates a partial failure — records it in the banner, never throws', async () => {
createWorktreeReqMock.mockImplementation(async (_repo: string, branch: string) =>
branch.endsWith('-2')
? { ok: false, status: 500, error: 'boom' }
: { ok: true, path: `/wt/${branch}` },
)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await expect(
app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' }),
).resolves.toBeUndefined()
expect(app.snapshot()).toHaveLength(2) // lanes 1 and 3 created
const banner = document.getElementById('fanout-banner')!
expect(banner.style.display).toBe('block')
expect(banner.textContent).toContain('Started 2 of 3')
})
it('every created lane cell shows a 🏆 Keep button (fan-out lanes only)', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 3)
for (let i = 0; i < 3; i++) expect(keepBtnOf(i).style.display).not.toBe('none')
})
it('a plain (non-fan-out) tab has no visible Keep button', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const cell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')!
expect((cell.querySelector('.cell-keep') as HTMLElement).style.display).toBe('none')
})
it('keepFanoutWinner keeps the winner, closes losers, removes their worktrees, layout → single', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 3)
vi.spyOn(window, 'confirm').mockReturnValue(true)
keepBtnOf(0).click() // keep sess-0 (feat-lane-1)
await flush()
expect(app.snapshot()).toHaveLength(1) // only the winner remains
expect(app.activeSessionId()).toBe('sess-0')
// One DELETE per loser, force=false, with the loser's worktree path.
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
const paths = removeWorktreeReqMock.mock.calls.map((c) => (c as unknown[])[1]).sort()
expect(paths).toEqual(['/wt/feat-lane-2', '/wt/feat-lane-3'])
expect(removeWorktreeReqMock.mock.calls.every((c) => (c as unknown[])[2] === false)).toBe(true)
expect(app.getGridLayout()).toBe('single')
})
it('keepFanoutWinner force-removes a dirty (409) loser without a second confirm', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 2)
removeWorktreeReqMock
.mockResolvedValueOnce({ ok: false, status: 409, error: 'dirty' })
.mockResolvedValueOnce({ ok: true })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
keepBtnOf(0).click() // keep sess-0; one loser (sess-1)
await flush()
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
expect((removeWorktreeReqMock.mock.calls[0] as unknown[])[2]).toBe(false) // first try
expect((removeWorktreeReqMock.mock.calls[1] as unknown[])[2]).toBe(true) // forced retry
expect(confirmSpy).toHaveBeenCalledTimes(1) // only the batch confirm
})
it('keepFanoutWinner cancelled → no tabs closed, no worktree removed', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 3)
vi.spyOn(window, 'confirm').mockReturnValue(false)
keepBtnOf(0).click()
await flush()
expect(app.snapshot()).toHaveLength(3) // nothing closed
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
})
it('keepFanoutWinner refuses when the winner has not attached (null id) — deletes nothing', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
// Fan out but do NOT stamp ids (sessions still null).
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
keepBtnOf(0).click() // entry.session.id is null → winnerSessionId ''
await flush()
expect(confirmSpy).not.toHaveBeenCalled() // refused before the confirm
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
expect(app.snapshot()).toHaveLength(3)
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
})
})