From 81d46800f08e3eccc5dbbe3ada369d306628a479 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 00:03:03 -0600 Subject: [PATCH 1/3] feat: extract spawn operation to core/operations/spawn.ts Move worktree creation + agent spawning pipeline from commands/spawn.ts into core/operations/spawn.ts as performSpawn() with typed options and result. Commands layer now handles only arg parsing + output formatting. Closes #62 --- src/commands/spawn.ts | 489 +----------------------------- src/core/operations/spawn.test.ts | 388 ++++++++++++++++++++++++ src/core/operations/spawn.ts | 453 +++++++++++++++++++++++++++ 3 files changed, 854 insertions(+), 476 deletions(-) create mode 100644 src/core/operations/spawn.test.ts create mode 100644 src/core/operations/spawn.ts diff --git a/src/commands/spawn.ts b/src/commands/spawn.ts index 873aaa3..d1d3120 100644 --- a/src/commands/spawn.ts +++ b/src/commands/spawn.ts @@ -1,495 +1,32 @@ -import fs from 'node:fs/promises'; -import { loadConfig, resolveAgentConfig } from '../core/config.js'; -import { readManifest, updateManifest, resolveWorktree } from '../core/manifest.js'; -import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../core/worktree.js'; -import { setupWorktreeEnv } from '../core/env.js'; -import { loadTemplate, renderTemplate, type TemplateContext } from '../core/template.js'; -import { spawnAgent } from '../core/agent.js'; -import * as tmux from '../core/tmux.js'; -import { openTerminalWindow } from '../core/terminal.js'; -import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; -import { manifestPath } from '../lib/paths.js'; -import { PpgError, NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js'; +import { performSpawn, type PerformSpawnOptions, type SpawnResult } from '../core/operations/spawn.js'; import { output, success, info } from '../lib/output.js'; -import { normalizeName } from '../lib/name.js'; -import { parseVars } from '../lib/vars.js'; -import type { WorktreeEntry, AgentEntry } from '../types/manifest.js'; -import type { Config, AgentConfig } from '../types/config.js'; -export interface SpawnOptions { - name?: string; - agent?: string; - prompt?: string; - promptFile?: string; - template?: string; - var?: string[]; - base?: string; - branch?: string; - worktree?: string; - count?: number; - split?: boolean; - open?: boolean; +export interface SpawnOptions extends PerformSpawnOptions { json?: boolean; } export async function spawnCommand(options: SpawnOptions): Promise { - const projectRoot = await getRepoRoot(); - const config = await loadConfig(projectRoot); + const { json, ...spawnOpts } = options; - // Verify initialized (lightweight file check instead of full manifest read) - try { - await fs.access(manifestPath(projectRoot)); - } catch { - throw new NotInitializedError(projectRoot); - } - - const agentConfig = resolveAgentConfig(config, options.agent); - const count = options.count ?? 1; - - // Validate vars early — before any side effects (worktree/tmux creation) - const userVars = parseVars(options.var ?? []); - - // Resolve prompt - const promptText = await resolvePrompt(options, projectRoot); - - // Validate conflicting flags - if (options.branch && options.worktree) { - throw new PpgError('--branch and --worktree are mutually exclusive', 'INVALID_ARGS'); - } - if (options.branch && options.base) { - throw new PpgError('--branch and --base are mutually exclusive (--base is for new branches)', 'INVALID_ARGS'); - } - - if (options.worktree) { - // Add agent(s) to existing worktree - await spawnIntoExistingWorktree( - projectRoot, - agentConfig, - options.worktree, - promptText, - count, - options, - userVars, - ); - } else if (options.branch) { - // Create worktree from existing branch - await spawnOnExistingBranch( - projectRoot, - config, - agentConfig, - options.branch, - promptText, - count, - options, - userVars, - ); - } else { - // Create new worktree + agent(s) - await spawnNewWorktree( - projectRoot, - config, - agentConfig, - promptText, - count, - options, - userVars, - ); - } -} - -async function resolvePrompt(options: SpawnOptions, projectRoot: string): Promise { - if (options.prompt) return options.prompt; - - if (options.promptFile) { - return fs.readFile(options.promptFile, 'utf-8'); - } - - if (options.template) { - return loadTemplate(projectRoot, options.template); - } - - throw new PpgError('One of --prompt, --prompt-file, or --template is required', 'INVALID_ARGS'); -} + const result = await performSpawn(spawnOpts); -interface SpawnBatchOptions { - projectRoot: string; - agentConfig: AgentConfig; - promptText: string; - userVars: Record; - count: number; - split: boolean; - worktreePath: string; - branch: string; - taskName: string; - sessionName: string; - windowTarget: string; - windowNamePrefix: string; - reuseWindowForFirstAgent: boolean; - onAgentSpawned?: (agent: AgentEntry) => Promise; + emitSpawnResult(result, json); } -interface SpawnTargetOptions { - index: number; - split: boolean; - reuseWindowForFirstAgent: boolean; - windowTarget: string; - sessionName: string; - windowNamePrefix: string; - worktreePath: string; -} - -async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { - if (opts.index === 0 && opts.reuseWindowForFirstAgent) { - return opts.windowTarget; - } - if (opts.split) { - const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; - const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); - return pane.target; - } - return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); -} - -async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { - const agents: AgentEntry[] = []; - for (let i = 0; i < opts.count; i++) { - const aId = genAgentId(); - const target = await resolveAgentTarget({ - index: i, - split: opts.split, - reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent, - windowTarget: opts.windowTarget, - sessionName: opts.sessionName, - windowNamePrefix: opts.windowNamePrefix, - worktreePath: opts.worktreePath, - }); - - const ctx: TemplateContext = { - WORKTREE_PATH: opts.worktreePath, - BRANCH: opts.branch, - AGENT_ID: aId, - PROJECT_ROOT: opts.projectRoot, - TASK_NAME: opts.taskName, - PROMPT: opts.promptText, - ...opts.userVars, - }; - - const agentEntry = await spawnAgent({ - agentId: aId, - agentConfig: opts.agentConfig, - prompt: renderTemplate(opts.promptText, ctx), - worktreePath: opts.worktreePath, - tmuxTarget: target, - projectRoot: opts.projectRoot, - branch: opts.branch, - sessionId: genSessionId(), - }); - - agents.push(agentEntry); - if (opts.onAgentSpawned) { - await opts.onAgentSpawned(agentEntry); - } - } - - return agents; -} - -interface EmitSpawnResultOptions { - json: boolean | undefined; - successMessage: string; - worktree: { - id: string; - name: string; - branch: string; - path: string; - tmuxWindow: string; - }; - agents: AgentEntry[]; - attachRef?: string; -} - -function emitSpawnResult(opts: EmitSpawnResultOptions): void { - if (opts.json) { +function emitSpawnResult(result: SpawnResult, json: boolean | undefined): void { + if (json) { output({ success: true, - worktree: opts.worktree, - agents: opts.agents.map((a) => ({ - id: a.id, - tmuxTarget: a.tmuxTarget, - sessionId: a.sessionId, - })), + worktree: result.worktree, + agents: result.agents, }, true); return; } - success(opts.successMessage); - for (const a of opts.agents) { + const agentCount = result.agents.length; + success(`Spawned worktree ${result.worktree.id} with ${agentCount} agent(s)`); + for (const a of result.agents) { info(` Agent ${a.id} → ${a.tmuxTarget}`); } - if (opts.attachRef) { - info(`Attach: ppg attach ${opts.attachRef}`); - } -} - -async function spawnNewWorktree( - projectRoot: string, - config: Config, - agentConfig: AgentConfig, - promptText: string, - count: number, - options: SpawnOptions, - userVars: Record, -): Promise { - const baseBranch = options.base ?? await getCurrentBranch(projectRoot); - const wtId = genWorktreeId(); - const name = options.name ? normalizeName(options.name, wtId) : wtId; - const branchName = `ppg/${name}`; - - // Create git worktree - info(`Creating worktree ${wtId} on branch ${branchName}`); - const wtPath = await createWorktree(projectRoot, wtId, { - branch: branchName, - base: baseBranch, - }); - - // Setup env - await setupWorktreeEnv(projectRoot, wtPath, config); - - // Ensure tmux session (manifest is the source of truth for session name) - const manifest = await readManifest(projectRoot); - const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); - - // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); - - // Register skeleton worktree in manifest before spawning agents - // so partial failures leave a record for cleanup - const worktreeEntry: WorktreeEntry = { - id: wtId, - name, - path: wtPath, - branch: branchName, - baseBranch, - status: 'active', - tmuxWindow: windowTarget, - agents: {}, - createdAt: new Date().toISOString(), - }; - - await updateManifest(projectRoot, (m) => { - m.worktrees[wtId] = worktreeEntry; - return m; - }); - - // Spawn agents — one tmux window per agent (default), or split panes (--split) - const agents = await spawnAgentBatch({ - projectRoot, - agentConfig, - promptText, - userVars, - count, - split: options.split === true, - worktreePath: wtPath, - branch: branchName, - taskName: name, - sessionName, - windowTarget, - windowNamePrefix: name, - reuseWindowForFirstAgent: true, - onAgentSpawned: async (agentEntry) => { - // Update manifest incrementally after each agent spawn. - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wtId]) { - m.worktrees[wtId].agents[agentEntry.id] = agentEntry; - } - return m; - }); - }, - }); - - // Only open Terminal window when explicitly requested via --open (fire-and-forget) - if (options.open === true) { - openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); - } - - emitSpawnResult({ - json: options.json, - successMessage: `Spawned worktree ${wtId} with ${agents.length} agent(s)`, - worktree: { - id: wtId, - name, - branch: branchName, - path: wtPath, - tmuxWindow: windowTarget, - }, - agents, - attachRef: wtId, - }); -} - -async function spawnOnExistingBranch( - projectRoot: string, - config: Config, - agentConfig: AgentConfig, - branch: string, - promptText: string, - count: number, - options: SpawnOptions, - userVars: Record, -): Promise { - const baseBranch = await getCurrentBranch(projectRoot); - const wtId = genWorktreeId(); - - // Derive name from branch if --name not provided (strip ppg/ prefix if present) - const derivedName = branch.startsWith('ppg/') ? branch.slice(4) : branch; - const name = options.name ? normalizeName(options.name, wtId) : normalizeName(derivedName, wtId); - - // Create git worktree from existing branch (no -b flag) - info(`Creating worktree ${wtId} from existing branch ${branch}`); - const wtPath = await adoptWorktree(projectRoot, wtId, branch); - - // Setup env - await setupWorktreeEnv(projectRoot, wtPath, config); - - // Ensure tmux session - const manifest = await readManifest(projectRoot); - const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); - - // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); - - // Register worktree in manifest - const worktreeEntry: WorktreeEntry = { - id: wtId, - name, - path: wtPath, - branch, - baseBranch, - status: 'active', - tmuxWindow: windowTarget, - agents: {}, - createdAt: new Date().toISOString(), - }; - - await updateManifest(projectRoot, (m) => { - m.worktrees[wtId] = worktreeEntry; - return m; - }); - - const agents = await spawnAgentBatch({ - projectRoot, - agentConfig, - promptText, - userVars, - count, - split: options.split === true, - worktreePath: wtPath, - branch, - taskName: name, - sessionName, - windowTarget, - windowNamePrefix: name, - reuseWindowForFirstAgent: true, - onAgentSpawned: async (agentEntry) => { - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wtId]) { - m.worktrees[wtId].agents[agentEntry.id] = agentEntry; - } - return m; - }); - }, - }); - - if (options.open === true) { - openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); - } - - emitSpawnResult({ - json: options.json, - successMessage: `Spawned worktree ${wtId} from branch ${branch} with ${agents.length} agent(s)`, - worktree: { - id: wtId, - name, - branch, - path: wtPath, - tmuxWindow: windowTarget, - }, - agents, - attachRef: wtId, - }); -} - -async function spawnIntoExistingWorktree( - projectRoot: string, - agentConfig: AgentConfig, - worktreeRef: string, - promptText: string, - count: number, - options: SpawnOptions, - userVars: Record, -): Promise { - const manifest = await readManifest(projectRoot); - const wt = resolveWorktree(manifest, worktreeRef); - - if (!wt) throw new WorktreeNotFoundError(worktreeRef); - - // Lazily create tmux window if worktree has none (standalone worktree) - let windowTarget = wt.tmuxWindow; - if (!windowTarget) { - await tmux.ensureSession(manifest.sessionName); - windowTarget = await tmux.createWindow(manifest.sessionName, wt.name, wt.path); - - // Persist tmux window before spawning agents so partial failures are tracked. - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (!mWt) return m; - mWt.tmuxWindow = windowTarget; - return m; - }); - } - - const agents = await spawnAgentBatch({ - projectRoot, - agentConfig, - promptText, - userVars, - count, - split: options.split === true, - worktreePath: wt.path, - branch: wt.branch, - taskName: wt.name, - sessionName: manifest.sessionName, - windowTarget, - windowNamePrefix: `${wt.name}-agent`, - // For existing worktrees, only reuse the primary pane when explicitly splitting. - reuseWindowForFirstAgent: options.split === true, - onAgentSpawned: async (agentEntry) => { - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (!mWt) return m; - mWt.agents[agentEntry.id] = agentEntry; - return m; - }); - }, - }); - - // Only open Terminal window when explicitly requested via --open (fire-and-forget) - if (options.open === true) { - openTerminalWindow(manifest.sessionName, windowTarget, wt.name).catch(() => {}); - } - - emitSpawnResult({ - json: options.json, - successMessage: `Added ${agents.length} agent(s) to worktree ${wt.id}`, - worktree: { - id: wt.id, - name: wt.name, - branch: wt.branch, - path: wt.path, - tmuxWindow: windowTarget, - }, - agents, - }); + info(`Attach: ppg attach ${result.worktree.id}`); } diff --git a/src/core/operations/spawn.test.ts b/src/core/operations/spawn.test.ts new file mode 100644 index 0000000..61a29bf --- /dev/null +++ b/src/core/operations/spawn.test.ts @@ -0,0 +1,388 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { Manifest } from '../../types/manifest.js'; +import type { Config } from '../../types/config.js'; + +// --- Mocks --- + +vi.mock('node:fs/promises', () => ({ + default: { + access: vi.fn(), + readFile: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + }, +})); + +vi.mock('../config.js', () => ({ + loadConfig: vi.fn(), + resolveAgentConfig: vi.fn(), +})); + +vi.mock('../manifest.js', () => ({ + readManifest: vi.fn(), + updateManifest: vi.fn(), + resolveWorktree: vi.fn(), +})); + +vi.mock('../worktree.js', () => ({ + getRepoRoot: vi.fn(), + getCurrentBranch: vi.fn(), + createWorktree: vi.fn(), + adoptWorktree: vi.fn(), +})); + +vi.mock('../env.js', () => ({ + setupWorktreeEnv: vi.fn(), +})); + +vi.mock('../template.js', () => ({ + loadTemplate: vi.fn(), + renderTemplate: vi.fn((content: string) => content), +})); + +vi.mock('../agent.js', () => ({ + spawnAgent: vi.fn(), +})); + +vi.mock('../tmux.js', () => ({ + ensureSession: vi.fn(), + createWindow: vi.fn(), + splitPane: vi.fn(), + sendKeys: vi.fn(), +})); + +vi.mock('../terminal.js', () => ({ + openTerminalWindow: vi.fn(), +})); + +vi.mock('../../lib/id.js', () => ({ + worktreeId: vi.fn(), + agentId: vi.fn(), + sessionId: vi.fn(), +})); + +vi.mock('../../lib/paths.js', () => ({ + manifestPath: vi.fn((root: string) => `${root}/.ppg/manifest.json`), +})); + +vi.mock('../../lib/name.js', () => ({ + normalizeName: vi.fn((name: string) => name), +})); + +vi.mock('../../lib/vars.js', () => ({ + parseVars: vi.fn(() => ({})), +})); + +// --- Imports (after mocks) --- + +import fs from 'node:fs/promises'; +import { loadConfig, resolveAgentConfig } from '../config.js'; +import { readManifest, updateManifest, resolveWorktree } from '../manifest.js'; +import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../worktree.js'; +import { setupWorktreeEnv } from '../env.js'; +import { spawnAgent } from '../agent.js'; +import * as tmux from '../tmux.js'; +import { openTerminalWindow } from '../terminal.js'; +import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { performSpawn } from './spawn.js'; + +const mockedFs = vi.mocked(fs); +const mockedGetRepoRoot = vi.mocked(getRepoRoot); +const mockedLoadConfig = vi.mocked(loadConfig); +const mockedResolveAgentConfig = vi.mocked(resolveAgentConfig); +const mockedReadManifest = vi.mocked(readManifest); +const mockedUpdateManifest = vi.mocked(updateManifest); +const mockedResolveWorktree = vi.mocked(resolveWorktree); +const mockedGetCurrentBranch = vi.mocked(getCurrentBranch); +const mockedCreateWorktree = vi.mocked(createWorktree); +const mockedAdoptWorktree = vi.mocked(adoptWorktree); +const mockedSetupWorktreeEnv = vi.mocked(setupWorktreeEnv); +const mockedSpawnAgent = vi.mocked(spawnAgent); +const mockedEnsureSession = vi.mocked(tmux.ensureSession); +const mockedCreateWindow = vi.mocked(tmux.createWindow); +const mockedSplitPane = vi.mocked(tmux.splitPane); +const mockedOpenTerminalWindow = vi.mocked(openTerminalWindow); +const mockedGenWorktreeId = vi.mocked(genWorktreeId); +const mockedGenAgentId = vi.mocked(genAgentId); +const mockedGenSessionId = vi.mocked(genSessionId); + +const PROJECT_ROOT = '/tmp/project'; +const SESSION_NAME = 'ppg-test'; + +const DEFAULT_CONFIG: Config = { + sessionName: SESSION_NAME, + defaultAgent: 'claude', + agents: { + claude: { name: 'claude', command: 'claude', interactive: true }, + }, + envFiles: ['.env'], + symlinkNodeModules: true, +}; + +const AGENT_CONFIG = { name: 'claude', command: 'claude', interactive: true }; + +const DEFAULT_MANIFEST: Manifest = { + version: 1, + projectRoot: PROJECT_ROOT, + sessionName: SESSION_NAME, + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +function setupDefaultMocks() { + mockedGetRepoRoot.mockResolvedValue(PROJECT_ROOT); + mockedLoadConfig.mockResolvedValue(DEFAULT_CONFIG); + mockedResolveAgentConfig.mockReturnValue(AGENT_CONFIG); + mockedFs.access.mockResolvedValue(undefined); + mockedReadManifest.mockResolvedValue({ ...DEFAULT_MANIFEST }); + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + const m = { ...DEFAULT_MANIFEST, worktrees: { ...DEFAULT_MANIFEST.worktrees } }; + return updater(m); + }); + mockedGetCurrentBranch.mockResolvedValue('main'); + mockedGenWorktreeId.mockReturnValue('wt-abc123'); + mockedGenAgentId.mockReturnValue('ag-test0001'); + mockedGenSessionId.mockReturnValue('session-uuid-1'); + mockedCreateWorktree.mockResolvedValue(`${PROJECT_ROOT}/.worktrees/wt-abc123`); + mockedAdoptWorktree.mockResolvedValue(`${PROJECT_ROOT}/.worktrees/wt-abc123`); + mockedEnsureSession.mockResolvedValue(undefined); + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:1`); + mockedSetupWorktreeEnv.mockResolvedValue(undefined); + mockedSpawnAgent.mockResolvedValue({ + id: 'ag-test0001', + name: 'claude', + agentType: 'claude', + status: 'running', + tmuxTarget: `${SESSION_NAME}:1`, + prompt: 'Do the task', + startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); +}); + +describe('performSpawn', () => { + describe('new worktree (default path)', () => { + test('given prompt option, should create worktree, setup env, create tmux, spawn agent, return result', async () => { + const result = await performSpawn({ prompt: 'Do the task', name: 'feature-x' }); + + expect(mockedGetRepoRoot).toHaveBeenCalled(); + expect(mockedLoadConfig).toHaveBeenCalledWith(PROJECT_ROOT); + expect(mockedCreateWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', { + branch: 'ppg/feature-x', + base: 'main', + }); + expect(mockedSetupWorktreeEnv).toHaveBeenCalledWith( + PROJECT_ROOT, + `${PROJECT_ROOT}/.worktrees/wt-abc123`, + DEFAULT_CONFIG, + ); + expect(mockedEnsureSession).toHaveBeenCalledWith(SESSION_NAME); + expect(mockedCreateWindow).toHaveBeenCalledWith( + SESSION_NAME, + 'feature-x', + `${PROJECT_ROOT}/.worktrees/wt-abc123`, + ); + expect(mockedSpawnAgent).toHaveBeenCalledWith(expect.objectContaining({ + agentId: 'ag-test0001', + agentConfig: AGENT_CONFIG, + projectRoot: PROJECT_ROOT, + })); + expect(mockedUpdateManifest).toHaveBeenCalled(); + + expect(result).toEqual({ + worktree: { + id: 'wt-abc123', + name: 'feature-x', + branch: 'ppg/feature-x', + path: `${PROJECT_ROOT}/.worktrees/wt-abc123`, + tmuxWindow: `${SESSION_NAME}:1`, + }, + agents: [{ + id: 'ag-test0001', + tmuxTarget: `${SESSION_NAME}:1`, + sessionId: 'session-uuid-1', + }], + }); + }); + + test('given no name, should use worktree ID as name', async () => { + await performSpawn({ prompt: 'Do the task' }); + + expect(mockedCreateWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', { + branch: 'ppg/wt-abc123', + base: 'main', + }); + }); + + test('given --base option, should use it instead of current branch', async () => { + await performSpawn({ prompt: 'Do the task', base: 'develop' }); + + expect(mockedCreateWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', { + branch: 'ppg/wt-abc123', + base: 'develop', + }); + expect(mockedGetCurrentBranch).not.toHaveBeenCalled(); + }); + + test('given --open, should call openTerminalWindow', async () => { + mockedOpenTerminalWindow.mockResolvedValue(undefined); + + await performSpawn({ prompt: 'Do the task', open: true }); + + expect(mockedOpenTerminalWindow).toHaveBeenCalledWith( + SESSION_NAME, + `${SESSION_NAME}:1`, + 'wt-abc123', + ); + }); + + test('given count=2 with --split, should split pane for second agent', async () => { + let agentCallCount = 0; + mockedGenAgentId.mockImplementation(() => { + agentCallCount++; + return `ag-test000${agentCallCount}`; + }); + mockedSplitPane.mockResolvedValue({ paneId: '%2', target: `${SESSION_NAME}:1.1` }); + mockedSpawnAgent + .mockResolvedValueOnce({ + id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:1`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }) + .mockResolvedValueOnce({ + id: 'ag-test0002', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:1.1`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); + + const result = await performSpawn({ prompt: 'Do the task', count: 2, split: true }); + + expect(mockedSplitPane).toHaveBeenCalledWith(`${SESSION_NAME}:1`, 'horizontal', expect.any(String)); + expect(result.agents).toHaveLength(2); + }); + }); + + describe('existing branch (--branch)', () => { + test('given --branch, should adopt worktree from existing branch', async () => { + const result = await performSpawn({ prompt: 'Do the task', branch: 'ppg/fix-bug' }); + + expect(mockedAdoptWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', 'ppg/fix-bug'); + expect(mockedCreateWorktree).not.toHaveBeenCalled(); + expect(result.worktree.branch).toBe('ppg/fix-bug'); + }); + }); + + describe('existing worktree (--worktree)', () => { + test('given --worktree, should add agent to existing worktree', async () => { + const existingWt = { + id: 'wt-exist1', + name: 'existing', + path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, + branch: 'ppg/existing', + baseBranch: 'main', + status: 'active' as const, + tmuxWindow: `${SESSION_NAME}:2`, + agents: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }; + mockedResolveWorktree.mockReturnValue(existingWt); + + // For existing worktree, the new agent window is created (not reused) + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:3`); + mockedSpawnAgent.mockResolvedValue({ + id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:3`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); + + const result = await performSpawn({ prompt: 'Do the task', worktree: 'wt-exist1' }); + + expect(mockedCreateWorktree).not.toHaveBeenCalled(); + expect(mockedAdoptWorktree).not.toHaveBeenCalled(); + expect(result.worktree.id).toBe('wt-exist1'); + expect(result.agents).toHaveLength(1); + }); + + test('given --worktree with no tmux window, should lazily create one', async () => { + const existingWt = { + id: 'wt-exist1', + name: 'existing', + path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, + branch: 'ppg/existing', + baseBranch: 'main', + status: 'active' as const, + tmuxWindow: '', // no window + agents: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }; + mockedResolveWorktree.mockReturnValue(existingWt); + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:5`); + mockedSpawnAgent.mockResolvedValue({ + id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:5`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); + + const result = await performSpawn({ prompt: 'Do the task', worktree: 'wt-exist1' }); + + expect(mockedEnsureSession).toHaveBeenCalledWith(SESSION_NAME); + expect(mockedCreateWindow).toHaveBeenCalledWith(SESSION_NAME, 'existing', existingWt.path); + expect(result.worktree.tmuxWindow).toBe(`${SESSION_NAME}:5`); + }); + + test('given unknown worktree ref, should throw WorktreeNotFoundError', async () => { + mockedResolveWorktree.mockReturnValue(undefined); + + await expect(performSpawn({ prompt: 'Do the task', worktree: 'nonexistent' })) + .rejects.toThrow('Worktree not found: nonexistent'); + }); + }); + + describe('validation', () => { + test('given --branch and --worktree, should throw INVALID_ARGS', async () => { + await expect(performSpawn({ prompt: 'Do the task', branch: 'foo', worktree: 'bar' })) + .rejects.toThrow('--branch and --worktree are mutually exclusive'); + }); + + test('given --branch and --base, should throw INVALID_ARGS', async () => { + await expect(performSpawn({ prompt: 'Do the task', branch: 'foo', base: 'bar' })) + .rejects.toThrow('--branch and --base are mutually exclusive'); + }); + + test('given no prompt/promptFile/template, should throw INVALID_ARGS', async () => { + await expect(performSpawn({})) + .rejects.toThrow('One of --prompt, --prompt-file, or --template is required'); + }); + + test('given --prompt-file, should read prompt from file', async () => { + mockedFs.readFile.mockResolvedValue('File prompt content'); + + await performSpawn({ promptFile: '/tmp/prompt.md' }); + + expect(mockedFs.readFile).toHaveBeenCalledWith('/tmp/prompt.md', 'utf-8'); + }); + }); + + describe('result shape', () => { + test('should return SpawnResult with worktree and agents', async () => { + const result = await performSpawn({ prompt: 'Task' }); + + expect(result).toHaveProperty('worktree'); + expect(result).toHaveProperty('agents'); + expect(result.worktree).toHaveProperty('id'); + expect(result.worktree).toHaveProperty('name'); + expect(result.worktree).toHaveProperty('branch'); + expect(result.worktree).toHaveProperty('path'); + expect(result.worktree).toHaveProperty('tmuxWindow'); + expect(result.agents[0]).toHaveProperty('id'); + expect(result.agents[0]).toHaveProperty('tmuxTarget'); + expect(result.agents[0]).toHaveProperty('sessionId'); + }); + }); +}); diff --git a/src/core/operations/spawn.ts b/src/core/operations/spawn.ts new file mode 100644 index 0000000..d2713ed --- /dev/null +++ b/src/core/operations/spawn.ts @@ -0,0 +1,453 @@ +import fs from 'node:fs/promises'; +import { loadConfig, resolveAgentConfig } from '../config.js'; +import { readManifest, updateManifest, resolveWorktree } from '../manifest.js'; +import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../worktree.js'; +import { setupWorktreeEnv } from '../env.js'; +import { loadTemplate, renderTemplate, type TemplateContext } from '../template.js'; +import { spawnAgent } from '../agent.js'; +import * as tmux from '../tmux.js'; +import { openTerminalWindow } from '../terminal.js'; +import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { manifestPath } from '../../lib/paths.js'; +import { PpgError, NotInitializedError, WorktreeNotFoundError } from '../../lib/errors.js'; +import { normalizeName } from '../../lib/name.js'; +import { parseVars } from '../../lib/vars.js'; +import type { WorktreeEntry, AgentEntry } from '../../types/manifest.js'; +import type { AgentConfig } from '../../types/config.js'; + +export interface PerformSpawnOptions { + name?: string; + agent?: string; + prompt?: string; + promptFile?: string; + template?: string; + var?: string[]; + base?: string; + branch?: string; + worktree?: string; + count?: number; + split?: boolean; + open?: boolean; +} + +export interface SpawnResult { + worktree: { + id: string; + name: string; + branch: string; + path: string; + tmuxWindow: string; + }; + agents: Array<{ + id: string; + tmuxTarget: string; + sessionId?: string; + }>; +} + +export async function performSpawn(options: PerformSpawnOptions): Promise { + const projectRoot = await getRepoRoot(); + const config = await loadConfig(projectRoot); + + // Verify initialized (lightweight file check instead of full manifest read) + try { + await fs.access(manifestPath(projectRoot)); + } catch { + throw new NotInitializedError(projectRoot); + } + + const agentConfig = resolveAgentConfig(config, options.agent); + const count = options.count ?? 1; + + // Validate vars early — before any side effects (worktree/tmux creation) + const userVars = parseVars(options.var ?? []); + + // Resolve prompt + const promptText = await resolvePrompt(options, projectRoot); + + // Validate conflicting flags + if (options.branch && options.worktree) { + throw new PpgError('--branch and --worktree are mutually exclusive', 'INVALID_ARGS'); + } + if (options.branch && options.base) { + throw new PpgError('--branch and --base are mutually exclusive (--base is for new branches)', 'INVALID_ARGS'); + } + + if (options.worktree) { + return spawnIntoExistingWorktree( + projectRoot, + agentConfig, + options.worktree, + promptText, + count, + options, + userVars, + ); + } else if (options.branch) { + return spawnOnExistingBranch( + projectRoot, + config, + agentConfig, + options.branch, + promptText, + count, + options, + userVars, + ); + } else { + return spawnNewWorktree( + projectRoot, + config, + agentConfig, + promptText, + count, + options, + userVars, + ); + } +} + +async function resolvePrompt(options: PerformSpawnOptions, projectRoot: string): Promise { + if (options.prompt) return options.prompt; + + if (options.promptFile) { + return fs.readFile(options.promptFile, 'utf-8'); + } + + if (options.template) { + return loadTemplate(projectRoot, options.template); + } + + throw new PpgError('One of --prompt, --prompt-file, or --template is required', 'INVALID_ARGS'); +} + +interface SpawnBatchOptions { + projectRoot: string; + agentConfig: AgentConfig; + promptText: string; + userVars: Record; + count: number; + split: boolean; + worktreePath: string; + branch: string; + taskName: string; + sessionName: string; + windowTarget: string; + windowNamePrefix: string; + reuseWindowForFirstAgent: boolean; + onAgentSpawned?: (agent: AgentEntry) => Promise; +} + +interface SpawnTargetOptions { + index: number; + split: boolean; + reuseWindowForFirstAgent: boolean; + windowTarget: string; + sessionName: string; + windowNamePrefix: string; + worktreePath: string; +} + +async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { + if (opts.index === 0 && opts.reuseWindowForFirstAgent) { + return opts.windowTarget; + } + if (opts.split) { + const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; + const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); + return pane.target; + } + return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); +} + +async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { + const agents: AgentEntry[] = []; + for (let i = 0; i < opts.count; i++) { + const aId = genAgentId(); + const target = await resolveAgentTarget({ + index: i, + split: opts.split, + reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent, + windowTarget: opts.windowTarget, + sessionName: opts.sessionName, + windowNamePrefix: opts.windowNamePrefix, + worktreePath: opts.worktreePath, + }); + + const ctx: TemplateContext = { + WORKTREE_PATH: opts.worktreePath, + BRANCH: opts.branch, + AGENT_ID: aId, + PROJECT_ROOT: opts.projectRoot, + TASK_NAME: opts.taskName, + PROMPT: opts.promptText, + ...opts.userVars, + }; + + const agentEntry = await spawnAgent({ + agentId: aId, + agentConfig: opts.agentConfig, + prompt: renderTemplate(opts.promptText, ctx), + worktreePath: opts.worktreePath, + tmuxTarget: target, + projectRoot: opts.projectRoot, + branch: opts.branch, + sessionId: genSessionId(), + }); + + agents.push(agentEntry); + if (opts.onAgentSpawned) { + await opts.onAgentSpawned(agentEntry); + } + } + + return agents; +} + +function toSpawnResult( + worktree: { id: string; name: string; branch: string; path: string; tmuxWindow: string }, + agents: AgentEntry[], +): SpawnResult { + return { + worktree, + agents: agents.map((a) => ({ + id: a.id, + tmuxTarget: a.tmuxTarget, + sessionId: a.sessionId, + })), + }; +} + +async function spawnNewWorktree( + projectRoot: string, + config: import('../../types/config.js').Config, + agentConfig: AgentConfig, + promptText: string, + count: number, + options: PerformSpawnOptions, + userVars: Record, +): Promise { + const baseBranch = options.base ?? await getCurrentBranch(projectRoot); + const wtId = genWorktreeId(); + const name = options.name ? normalizeName(options.name, wtId) : wtId; + const branchName = `ppg/${name}`; + + // Create git worktree + const wtPath = await createWorktree(projectRoot, wtId, { + branch: branchName, + base: baseBranch, + }); + + // Setup env + await setupWorktreeEnv(projectRoot, wtPath, config); + + // Ensure tmux session (manifest is the source of truth for session name) + const manifest = await readManifest(projectRoot); + const sessionName = manifest.sessionName; + await tmux.ensureSession(sessionName); + + // Create tmux window + const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + + // Register skeleton worktree in manifest before spawning agents + // so partial failures leave a record for cleanup + const worktreeEntry: WorktreeEntry = { + id: wtId, + name, + path: wtPath, + branch: branchName, + baseBranch, + status: 'active', + tmuxWindow: windowTarget, + agents: {}, + createdAt: new Date().toISOString(), + }; + + await updateManifest(projectRoot, (m) => { + m.worktrees[wtId] = worktreeEntry; + return m; + }); + + // Spawn agents — one tmux window per agent (default), or split panes (--split) + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText, + userVars, + count, + split: options.split === true, + worktreePath: wtPath, + branch: branchName, + taskName: name, + sessionName, + windowTarget, + windowNamePrefix: name, + reuseWindowForFirstAgent: true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wtId]) { + m.worktrees[wtId].agents[agentEntry.id] = agentEntry; + } + return m; + }); + }, + }); + + // Only open Terminal window when explicitly requested via --open (fire-and-forget) + if (options.open === true) { + openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); + } + + return toSpawnResult( + { id: wtId, name, branch: branchName, path: wtPath, tmuxWindow: windowTarget }, + agents, + ); +} + +async function spawnOnExistingBranch( + projectRoot: string, + config: import('../../types/config.js').Config, + agentConfig: AgentConfig, + branch: string, + promptText: string, + count: number, + options: PerformSpawnOptions, + userVars: Record, +): Promise { + const baseBranch = await getCurrentBranch(projectRoot); + const wtId = genWorktreeId(); + + // Derive name from branch if --name not provided (strip ppg/ prefix if present) + const derivedName = branch.startsWith('ppg/') ? branch.slice(4) : branch; + const name = options.name ? normalizeName(options.name, wtId) : normalizeName(derivedName, wtId); + + // Create git worktree from existing branch (no -b flag) + const wtPath = await adoptWorktree(projectRoot, wtId, branch); + + // Setup env + await setupWorktreeEnv(projectRoot, wtPath, config); + + // Ensure tmux session + const manifest = await readManifest(projectRoot); + const sessionName = manifest.sessionName; + await tmux.ensureSession(sessionName); + + // Create tmux window + const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + + // Register worktree in manifest + const worktreeEntry: WorktreeEntry = { + id: wtId, + name, + path: wtPath, + branch, + baseBranch, + status: 'active', + tmuxWindow: windowTarget, + agents: {}, + createdAt: new Date().toISOString(), + }; + + await updateManifest(projectRoot, (m) => { + m.worktrees[wtId] = worktreeEntry; + return m; + }); + + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText, + userVars, + count, + split: options.split === true, + worktreePath: wtPath, + branch, + taskName: name, + sessionName, + windowTarget, + windowNamePrefix: name, + reuseWindowForFirstAgent: true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wtId]) { + m.worktrees[wtId].agents[agentEntry.id] = agentEntry; + } + return m; + }); + }, + }); + + if (options.open === true) { + openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); + } + + return toSpawnResult( + { id: wtId, name, branch, path: wtPath, tmuxWindow: windowTarget }, + agents, + ); +} + +async function spawnIntoExistingWorktree( + projectRoot: string, + agentConfig: AgentConfig, + worktreeRef: string, + promptText: string, + count: number, + options: PerformSpawnOptions, + userVars: Record, +): Promise { + const manifest = await readManifest(projectRoot); + const wt = resolveWorktree(manifest, worktreeRef); + + if (!wt) throw new WorktreeNotFoundError(worktreeRef); + + // Lazily create tmux window if worktree has none (standalone worktree) + let windowTarget = wt.tmuxWindow; + if (!windowTarget) { + await tmux.ensureSession(manifest.sessionName); + windowTarget = await tmux.createWindow(manifest.sessionName, wt.name, wt.path); + + // Persist tmux window before spawning agents so partial failures are tracked. + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (!mWt) return m; + mWt.tmuxWindow = windowTarget; + return m; + }); + } + + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText, + userVars, + count, + split: options.split === true, + worktreePath: wt.path, + branch: wt.branch, + taskName: wt.name, + sessionName: manifest.sessionName, + windowTarget, + windowNamePrefix: `${wt.name}-agent`, + // For existing worktrees, only reuse the primary pane when explicitly splitting. + reuseWindowForFirstAgent: options.split === true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (!mWt) return m; + mWt.agents[agentEntry.id] = agentEntry; + return m; + }); + }, + }); + + // Only open Terminal window when explicitly requested via --open (fire-and-forget) + if (options.open === true) { + openTerminalWindow(manifest.sessionName, windowTarget, wt.name).catch(() => {}); + } + + return toSpawnResult( + { id: wt.id, name: wt.name, branch: wt.branch, path: wt.path, tmuxWindow: windowTarget }, + agents, + ); +} From 772f4b35d48ef1c586ee93538e74d4bf5cf4a31f Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:56:40 -0600 Subject: [PATCH 2/3] fix: address code review findings for spawn extraction - P2: Replace inline import('...').Config with top-level type import - P3: Restore context-specific success messages (new vs branch vs existing) - P4: Only show attach hint for newly created worktrees - P5: Remove redundant result-shape test (TypeScript already guarantees) - P6: Add --template prompt resolution test - P7: Add manifest updater ordering tests (skeleton before agents, tmux window persisted before spawn, partial failure scenario) - P8: Remove unused mock variable declarations --- src/commands/spawn.ts | 22 +++- src/core/operations/spawn.test.ts | 162 ++++++++++++++++++++---------- src/core/operations/spawn.ts | 6 +- 3 files changed, 130 insertions(+), 60 deletions(-) diff --git a/src/commands/spawn.ts b/src/commands/spawn.ts index d1d3120..ec1453d 100644 --- a/src/commands/spawn.ts +++ b/src/commands/spawn.ts @@ -10,11 +10,11 @@ export async function spawnCommand(options: SpawnOptions): Promise { const result = await performSpawn(spawnOpts); - emitSpawnResult(result, json); + emitSpawnResult(result, options); } -function emitSpawnResult(result: SpawnResult, json: boolean | undefined): void { - if (json) { +function emitSpawnResult(result: SpawnResult, options: SpawnOptions): void { + if (options.json) { output({ success: true, worktree: result.worktree, @@ -24,9 +24,21 @@ function emitSpawnResult(result: SpawnResult, json: boolean | undefined): void { } const agentCount = result.agents.length; - success(`Spawned worktree ${result.worktree.id} with ${agentCount} agent(s)`); + + if (options.worktree) { + success(`Added ${agentCount} agent(s) to worktree ${result.worktree.id}`); + } else if (options.branch) { + success(`Spawned worktree ${result.worktree.id} from branch ${options.branch} with ${agentCount} agent(s)`); + } else { + success(`Spawned worktree ${result.worktree.id} with ${agentCount} agent(s)`); + } + for (const a of result.agents) { info(` Agent ${a.id} → ${a.tmuxTarget}`); } - info(`Attach: ppg attach ${result.worktree.id}`); + + // Only show attach hint for newly created worktrees, not when adding to existing + if (!options.worktree) { + info(`Attach: ppg attach ${result.worktree.id}`); + } } diff --git a/src/core/operations/spawn.test.ts b/src/core/operations/spawn.test.ts index 61a29bf..1c81e33 100644 --- a/src/core/operations/spawn.test.ts +++ b/src/core/operations/spawn.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; -import type { Manifest } from '../../types/manifest.js'; +import type { Manifest, WorktreeEntry } from '../../types/manifest.js'; import type { Config } from '../../types/config.js'; // --- Mocks --- @@ -80,6 +80,7 @@ import { loadConfig, resolveAgentConfig } from '../config.js'; import { readManifest, updateManifest, resolveWorktree } from '../manifest.js'; import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../worktree.js'; import { setupWorktreeEnv } from '../env.js'; +import { loadTemplate } from '../template.js'; import { spawnAgent } from '../agent.js'; import * as tmux from '../tmux.js'; import { openTerminalWindow } from '../terminal.js'; @@ -87,24 +88,17 @@ import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSes import { performSpawn } from './spawn.js'; const mockedFs = vi.mocked(fs); -const mockedGetRepoRoot = vi.mocked(getRepoRoot); const mockedLoadConfig = vi.mocked(loadConfig); const mockedResolveAgentConfig = vi.mocked(resolveAgentConfig); const mockedReadManifest = vi.mocked(readManifest); const mockedUpdateManifest = vi.mocked(updateManifest); const mockedResolveWorktree = vi.mocked(resolveWorktree); -const mockedGetCurrentBranch = vi.mocked(getCurrentBranch); const mockedCreateWorktree = vi.mocked(createWorktree); -const mockedAdoptWorktree = vi.mocked(adoptWorktree); -const mockedSetupWorktreeEnv = vi.mocked(setupWorktreeEnv); const mockedSpawnAgent = vi.mocked(spawnAgent); const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -const mockedOpenTerminalWindow = vi.mocked(openTerminalWindow); -const mockedGenWorktreeId = vi.mocked(genWorktreeId); -const mockedGenAgentId = vi.mocked(genAgentId); -const mockedGenSessionId = vi.mocked(genSessionId); +const mockedLoadTemplate = vi.mocked(loadTemplate); const PROJECT_ROOT = '/tmp/project'; const SESSION_NAME = 'ppg-test'; @@ -130,25 +124,28 @@ const DEFAULT_MANIFEST: Manifest = { updatedAt: '2026-01-01T00:00:00.000Z', }; +function makeManifestState(): Manifest { + return structuredClone(DEFAULT_MANIFEST); +} + function setupDefaultMocks() { - mockedGetRepoRoot.mockResolvedValue(PROJECT_ROOT); + vi.mocked(getRepoRoot).mockResolvedValue(PROJECT_ROOT); mockedLoadConfig.mockResolvedValue(DEFAULT_CONFIG); mockedResolveAgentConfig.mockReturnValue(AGENT_CONFIG); mockedFs.access.mockResolvedValue(undefined); - mockedReadManifest.mockResolvedValue({ ...DEFAULT_MANIFEST }); + mockedReadManifest.mockResolvedValue(makeManifestState()); mockedUpdateManifest.mockImplementation(async (_root, updater) => { - const m = { ...DEFAULT_MANIFEST, worktrees: { ...DEFAULT_MANIFEST.worktrees } }; - return updater(m); + return updater(makeManifestState()); }); - mockedGetCurrentBranch.mockResolvedValue('main'); - mockedGenWorktreeId.mockReturnValue('wt-abc123'); - mockedGenAgentId.mockReturnValue('ag-test0001'); - mockedGenSessionId.mockReturnValue('session-uuid-1'); + vi.mocked(getCurrentBranch).mockResolvedValue('main'); + vi.mocked(genWorktreeId).mockReturnValue('wt-abc123'); + vi.mocked(genAgentId).mockReturnValue('ag-test0001'); + vi.mocked(genSessionId).mockReturnValue('session-uuid-1'); mockedCreateWorktree.mockResolvedValue(`${PROJECT_ROOT}/.worktrees/wt-abc123`); - mockedAdoptWorktree.mockResolvedValue(`${PROJECT_ROOT}/.worktrees/wt-abc123`); + vi.mocked(adoptWorktree).mockResolvedValue(`${PROJECT_ROOT}/.worktrees/wt-abc123`); mockedEnsureSession.mockResolvedValue(undefined); mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:1`); - mockedSetupWorktreeEnv.mockResolvedValue(undefined); + vi.mocked(setupWorktreeEnv).mockResolvedValue(undefined); mockedSpawnAgent.mockResolvedValue({ id: 'ag-test0001', name: 'claude', @@ -171,13 +168,11 @@ describe('performSpawn', () => { test('given prompt option, should create worktree, setup env, create tmux, spawn agent, return result', async () => { const result = await performSpawn({ prompt: 'Do the task', name: 'feature-x' }); - expect(mockedGetRepoRoot).toHaveBeenCalled(); - expect(mockedLoadConfig).toHaveBeenCalledWith(PROJECT_ROOT); expect(mockedCreateWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', { branch: 'ppg/feature-x', base: 'main', }); - expect(mockedSetupWorktreeEnv).toHaveBeenCalledWith( + expect(vi.mocked(setupWorktreeEnv)).toHaveBeenCalledWith( PROJECT_ROOT, `${PROJECT_ROOT}/.worktrees/wt-abc123`, DEFAULT_CONFIG, @@ -193,7 +188,6 @@ describe('performSpawn', () => { agentConfig: AGENT_CONFIG, projectRoot: PROJECT_ROOT, })); - expect(mockedUpdateManifest).toHaveBeenCalled(); expect(result).toEqual({ worktree: { @@ -227,15 +221,15 @@ describe('performSpawn', () => { branch: 'ppg/wt-abc123', base: 'develop', }); - expect(mockedGetCurrentBranch).not.toHaveBeenCalled(); + expect(vi.mocked(getCurrentBranch)).not.toHaveBeenCalled(); }); test('given --open, should call openTerminalWindow', async () => { - mockedOpenTerminalWindow.mockResolvedValue(undefined); + vi.mocked(openTerminalWindow).mockResolvedValue(undefined); await performSpawn({ prompt: 'Do the task', open: true }); - expect(mockedOpenTerminalWindow).toHaveBeenCalledWith( + expect(vi.mocked(openTerminalWindow)).toHaveBeenCalledWith( SESSION_NAME, `${SESSION_NAME}:1`, 'wt-abc123', @@ -244,7 +238,7 @@ describe('performSpawn', () => { test('given count=2 with --split, should split pane for second agent', async () => { let agentCallCount = 0; - mockedGenAgentId.mockImplementation(() => { + vi.mocked(genAgentId).mockImplementation(() => { agentCallCount++; return `ag-test000${agentCallCount}`; }); @@ -266,13 +260,36 @@ describe('performSpawn', () => { expect(mockedSplitPane).toHaveBeenCalledWith(`${SESSION_NAME}:1`, 'horizontal', expect.any(String)); expect(result.agents).toHaveLength(2); }); + + test('given new worktree, should register skeleton in manifest before spawning agents', async () => { + // Capture the updater functions to inspect what each one does in isolation + const updaters: Array<(m: Manifest) => Manifest | Promise> = []; + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + updaters.push(updater); + const m = makeManifestState(); + return updater(m); + }); + + await performSpawn({ prompt: 'Do the task', name: 'feature-x' }); + + // First updater should register the skeleton worktree (no agents yet) + const skeletonResult = await updaters[0](makeManifestState()); + expect(skeletonResult.worktrees['wt-abc123']).toBeDefined(); + expect(Object.keys(skeletonResult.worktrees['wt-abc123'].agents)).toHaveLength(0); + + // Second updater should add agent to an existing worktree entry + const withWorktree = makeManifestState(); + withWorktree.worktrees['wt-abc123'] = structuredClone(skeletonResult.worktrees['wt-abc123']); + const agentResult = await updaters[1](withWorktree); + expect(agentResult.worktrees['wt-abc123'].agents['ag-test0001']).toBeDefined(); + }); }); describe('existing branch (--branch)', () => { test('given --branch, should adopt worktree from existing branch', async () => { const result = await performSpawn({ prompt: 'Do the task', branch: 'ppg/fix-bug' }); - expect(mockedAdoptWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', 'ppg/fix-bug'); + expect(vi.mocked(adoptWorktree)).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', 'ppg/fix-bug'); expect(mockedCreateWorktree).not.toHaveBeenCalled(); expect(result.worktree.branch).toBe('ppg/fix-bug'); }); @@ -280,20 +297,19 @@ describe('performSpawn', () => { describe('existing worktree (--worktree)', () => { test('given --worktree, should add agent to existing worktree', async () => { - const existingWt = { + const existingWt: WorktreeEntry = { id: 'wt-exist1', name: 'existing', path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, branch: 'ppg/existing', baseBranch: 'main', - status: 'active' as const, + status: 'active', tmuxWindow: `${SESSION_NAME}:2`, agents: {}, createdAt: '2026-01-01T00:00:00.000Z', }; mockedResolveWorktree.mockReturnValue(existingWt); - // For existing worktree, the new agent window is created (not reused) mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:3`); mockedSpawnAgent.mockResolvedValue({ id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', @@ -304,25 +320,35 @@ describe('performSpawn', () => { const result = await performSpawn({ prompt: 'Do the task', worktree: 'wt-exist1' }); expect(mockedCreateWorktree).not.toHaveBeenCalled(); - expect(mockedAdoptWorktree).not.toHaveBeenCalled(); + expect(vi.mocked(adoptWorktree)).not.toHaveBeenCalled(); expect(result.worktree.id).toBe('wt-exist1'); expect(result.agents).toHaveLength(1); }); - test('given --worktree with no tmux window, should lazily create one', async () => { - const existingWt = { + test('given --worktree with no tmux window, should lazily create one and persist before spawning', async () => { + const existingWt: WorktreeEntry = { id: 'wt-exist1', name: 'existing', path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, branch: 'ppg/existing', baseBranch: 'main', - status: 'active' as const, - tmuxWindow: '', // no window + status: 'active', + tmuxWindow: '', agents: {}, createdAt: '2026-01-01T00:00:00.000Z', }; mockedResolveWorktree.mockReturnValue(existingWt); mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:5`); + + // Capture updater functions to verify ordering + const updaters: Array<(m: Manifest) => Manifest | Promise> = []; + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + updaters.push(updater); + const m = makeManifestState(); + m.worktrees['wt-exist1'] = structuredClone(existingWt); + return updater(m); + }); + mockedSpawnAgent.mockResolvedValue({ id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', tmuxTarget: `${SESSION_NAME}:5`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', @@ -334,6 +360,47 @@ describe('performSpawn', () => { expect(mockedEnsureSession).toHaveBeenCalledWith(SESSION_NAME); expect(mockedCreateWindow).toHaveBeenCalledWith(SESSION_NAME, 'existing', existingWt.path); expect(result.worktree.tmuxWindow).toBe(`${SESSION_NAME}:5`); + + // First updater should persist the tmux window (before agent spawn) + const windowInput = makeManifestState(); + windowInput.worktrees['wt-exist1'] = structuredClone(existingWt); + const windowResult = await updaters[0](windowInput); + expect(windowResult.worktrees['wt-exist1'].tmuxWindow).toBe(`${SESSION_NAME}:5`); + expect(Object.keys(windowResult.worktrees['wt-exist1'].agents)).toHaveLength(0); + }); + + test('given spawn failure on existing worktree with lazy window, should persist tmux window but no agents', async () => { + const existingWt: WorktreeEntry = { + id: 'wt-exist1', + name: 'existing', + path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, + branch: 'ppg/existing', + baseBranch: 'main', + status: 'active', + tmuxWindow: '', + agents: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }; + mockedResolveWorktree.mockReturnValue(existingWt); + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:7`); + + let persistedTmuxWindow = ''; + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + const m = makeManifestState(); + m.worktrees['wt-exist1'] = structuredClone(existingWt); + const result = await updater(m); + persistedTmuxWindow = result.worktrees['wt-exist1']?.tmuxWindow ?? ''; + return result; + }); + + mockedSpawnAgent.mockRejectedValueOnce(new Error('spawn failed')); + + await expect(performSpawn({ prompt: 'Do work', worktree: 'wt-exist1' })) + .rejects.toThrow('spawn failed'); + + // tmux window should have been persisted before the spawn failure + expect(persistedTmuxWindow).toBe(`${SESSION_NAME}:7`); + expect(mockedUpdateManifest).toHaveBeenCalledTimes(1); }); test('given unknown worktree ref, should throw WorktreeNotFoundError', async () => { @@ -344,7 +411,7 @@ describe('performSpawn', () => { }); }); - describe('validation', () => { + describe('prompt resolution', () => { test('given --branch and --worktree, should throw INVALID_ARGS', async () => { await expect(performSpawn({ prompt: 'Do the task', branch: 'foo', worktree: 'bar' })) .rejects.toThrow('--branch and --worktree are mutually exclusive'); @@ -367,22 +434,13 @@ describe('performSpawn', () => { expect(mockedFs.readFile).toHaveBeenCalledWith('/tmp/prompt.md', 'utf-8'); }); - }); - describe('result shape', () => { - test('should return SpawnResult with worktree and agents', async () => { - const result = await performSpawn({ prompt: 'Task' }); - - expect(result).toHaveProperty('worktree'); - expect(result).toHaveProperty('agents'); - expect(result.worktree).toHaveProperty('id'); - expect(result.worktree).toHaveProperty('name'); - expect(result.worktree).toHaveProperty('branch'); - expect(result.worktree).toHaveProperty('path'); - expect(result.worktree).toHaveProperty('tmuxWindow'); - expect(result.agents[0]).toHaveProperty('id'); - expect(result.agents[0]).toHaveProperty('tmuxTarget'); - expect(result.agents[0]).toHaveProperty('sessionId'); + test('given --template, should load template by name', async () => { + mockedLoadTemplate.mockResolvedValue('Template content with {{BRANCH}}'); + + await performSpawn({ template: 'my-template' }); + + expect(mockedLoadTemplate).toHaveBeenCalledWith(PROJECT_ROOT, 'my-template'); }); }); }); diff --git a/src/core/operations/spawn.ts b/src/core/operations/spawn.ts index d2713ed..c4a3225 100644 --- a/src/core/operations/spawn.ts +++ b/src/core/operations/spawn.ts @@ -13,7 +13,7 @@ import { PpgError, NotInitializedError, WorktreeNotFoundError } from '../../lib/ import { normalizeName } from '../../lib/name.js'; import { parseVars } from '../../lib/vars.js'; import type { WorktreeEntry, AgentEntry } from '../../types/manifest.js'; -import type { AgentConfig } from '../../types/config.js'; +import type { Config, AgentConfig } from '../../types/config.js'; export interface PerformSpawnOptions { name?: string; @@ -220,7 +220,7 @@ function toSpawnResult( async function spawnNewWorktree( projectRoot: string, - config: import('../../types/config.js').Config, + config: Config, agentConfig: AgentConfig, promptText: string, count: number, @@ -306,7 +306,7 @@ async function spawnNewWorktree( async function spawnOnExistingBranch( projectRoot: string, - config: import('../../types/config.js').Config, + config: Config, agentConfig: AgentConfig, branch: string, promptText: string, From fbce7a3557f5fa93391cd48ff6548dcaf702debf Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:26:30 -0600 Subject: [PATCH 3/3] test: fix manifest typing in spawn command test --- src/commands/spawn.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..3b1b54f 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo',