diff --git a/src/commands/restart.ts b/src/commands/restart.ts index c2627e5..4411e3e 100644 --- a/src/commands/restart.ts +++ b/src/commands/restart.ts @@ -1,15 +1,6 @@ -import fs from 'node:fs/promises'; -import { requireManifest, updateManifest, findAgent } from '../core/manifest.js'; -import { loadConfig, resolveAgentConfig } from '../core/config.js'; -import { spawnAgent, killAgent } from '../core/agent.js'; -import { getRepoRoot } from '../core/worktree.js'; -import * as tmux from '../core/tmux.js'; +import { performRestart } from '../core/operations/restart.js'; import { openTerminalWindow } from '../core/terminal.js'; -import { agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; -import { agentPromptFile } from '../lib/paths.js'; -import { PpgError, AgentNotFoundError } from '../lib/errors.js'; import { output, success, info } from '../lib/output.js'; -import { renderTemplate, type TemplateContext } from '../core/template.js'; export interface RestartOptions { prompt?: string; @@ -19,105 +10,28 @@ export interface RestartOptions { } export async function restartCommand(agentRef: string, options: RestartOptions): Promise { - const projectRoot = await getRepoRoot(); - const config = await loadConfig(projectRoot); - - const manifest = await requireManifest(projectRoot); - - const found = findAgent(manifest, agentRef); - if (!found) throw new AgentNotFoundError(agentRef); - - const { worktree: wt, agent: oldAgent } = found; - - // Kill old agent if still running - if (oldAgent.status === 'running') { - info(`Killing existing agent ${oldAgent.id}`); - await killAgent(oldAgent); - } - - // Read original prompt from prompt file, or use override - let promptText: string; - if (options.prompt) { - promptText = options.prompt; - } else { - const pFile = agentPromptFile(projectRoot, oldAgent.id); - try { - promptText = await fs.readFile(pFile, 'utf-8'); - } catch { - throw new PpgError( - `Could not read original prompt for agent ${oldAgent.id}. Use --prompt to provide one.`, - 'PROMPT_NOT_FOUND', - ); - } - } - - // Resolve agent config - const agentConfig = resolveAgentConfig(config, options.agent ?? oldAgent.agentType); - - // Ensure tmux session - await tmux.ensureSession(manifest.sessionName); - - // Create new tmux window in same worktree - const newAgentId = genAgentId(); - const windowTarget = await tmux.createWindow(manifest.sessionName, `${wt.name}-restart`, wt.path); - - // Render template vars - const ctx: TemplateContext = { - WORKTREE_PATH: wt.path, - BRANCH: wt.branch, - AGENT_ID: newAgentId, - PROJECT_ROOT: projectRoot, - TASK_NAME: wt.name, - PROMPT: promptText, - }; - const renderedPrompt = renderTemplate(promptText, ctx); - - const newSessionId = genSessionId(); - const agentEntry = await spawnAgent({ - agentId: newAgentId, - agentConfig, - prompt: renderedPrompt, - worktreePath: wt.path, - tmuxTarget: windowTarget, - projectRoot, - branch: wt.branch, - sessionId: newSessionId, - }); - - // Update manifest: mark old agent as gone, add new agent - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (mWt) { - const mOldAgent = mWt.agents[oldAgent.id]; - if (mOldAgent && mOldAgent.status === 'running') { - mOldAgent.status = 'gone'; - } - mWt.agents[newAgentId] = agentEntry; - } - return m; + const result = await performRestart({ + agentRef, + prompt: options.prompt, + agentType: options.agent, }); // Only open Terminal window when explicitly requested via --open (fire-and-forget) if (options.open === true) { - openTerminalWindow(manifest.sessionName, windowTarget, `${wt.name}-restart`).catch(() => {}); + openTerminalWindow(result.sessionName, result.newAgent.tmuxTarget, `${result.newAgent.worktreeName}-restart`).catch(() => {}); } if (options.json) { output({ success: true, - oldAgentId: oldAgent.id, - newAgent: { - id: newAgentId, - tmuxTarget: windowTarget, - sessionId: newSessionId, - worktreeId: wt.id, - worktreeName: wt.name, - branch: wt.branch, - path: wt.path, - }, + oldAgentId: result.oldAgentId, + newAgent: result.newAgent, }, true); } else { - success(`Restarted agent ${oldAgent.id} → ${newAgentId} in worktree ${wt.name}`); - info(` New agent ${newAgentId} → ${windowTarget}`); + if (result.killedOldAgent) { + info(`Killed existing agent ${result.oldAgentId}`); + } + success(`Restarted agent ${result.oldAgentId} → ${result.newAgent.id} in worktree ${result.newAgent.worktreeName}`); + info(` New agent ${result.newAgent.id} → ${result.newAgent.tmuxTarget}`); } } diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..370fef0 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', @@ -93,7 +94,7 @@ function createManifest(tmuxWindow = '') { baseBranch: 'main', status: 'active' as const, tmuxWindow, - agents: {} as Record, + agents: {}, createdAt: '2026-02-27T00:00:00.000Z', }, }, @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1; diff --git a/src/core/operations/restart.test.ts b/src/core/operations/restart.test.ts new file mode 100644 index 0000000..43944a1 --- /dev/null +++ b/src/core/operations/restart.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeAgent, makeWorktree } from '../../test-fixtures.js'; +import type { Manifest } from '../../types/manifest.js'; +import type { AgentStatus } from '../../types/manifest.js'; + +// Mock node:fs/promises +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + }, +})); + +// Mock core modules +vi.mock('../worktree.js', () => ({ + getRepoRoot: vi.fn().mockResolvedValue('/tmp/project'), +})); + +vi.mock('../config.js', () => ({ + loadConfig: vi.fn().mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { + claude: { name: 'claude', command: 'claude --dangerously-skip-permissions', interactive: true }, + }, + }), + resolveAgentConfig: vi.fn().mockReturnValue({ + name: 'claude', + command: 'claude --dangerously-skip-permissions', + interactive: true, + }), +})); + +vi.mock('../manifest.js', () => ({ + requireManifest: vi.fn(), + updateManifest: vi.fn(), + findAgent: vi.fn(), +})); + +vi.mock('../agent.js', () => ({ + spawnAgent: vi.fn(), + killAgent: vi.fn(), +})); + +vi.mock('../tmux.js', () => ({ + ensureSession: vi.fn(), + createWindow: vi.fn(), +})); + +vi.mock('../template.js', () => ({ + renderTemplate: vi.fn((content: string) => content), +})); + +vi.mock('../../lib/id.js', () => ({ + agentId: vi.fn().mockReturnValue('ag-newagent'), + sessionId: vi.fn().mockReturnValue('sess-new123'), +})); + +vi.mock('../../lib/paths.js', () => ({ + agentPromptFile: vi.fn().mockReturnValue('/tmp/project/.ppg/agent-prompts/ag-test1234.md'), +})); + +vi.mock('../../lib/errors.js', async () => { + const actual = await vi.importActual('../../lib/errors.js'); + return actual; +}); + +import fs from 'node:fs/promises'; +import { requireManifest, updateManifest, findAgent } from '../manifest.js'; +import { spawnAgent, killAgent } from '../agent.js'; +import * as tmux from '../tmux.js'; +import { performRestart } from './restart.js'; + +const mockedFindAgent = vi.mocked(findAgent); +const mockedRequireManifest = vi.mocked(requireManifest); +const mockedUpdateManifest = vi.mocked(updateManifest); +const mockedSpawnAgent = vi.mocked(spawnAgent); +const mockedKillAgent = vi.mocked(killAgent); +const mockedEnsureSession = vi.mocked(tmux.ensureSession); +const mockedCreateWindow = vi.mocked(tmux.createWindow); +const mockedReadFile = vi.mocked(fs.readFile); + +const PROJECT_ROOT = '/tmp/project'; + +function makeManifest(overrides?: Partial): Manifest { + return { + version: 1, + projectRoot: PROJECT_ROOT, + sessionName: 'ppg', + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('performRestart', () => { + function setupDefaults(agentOverrides?: { status?: AgentStatus }) { + const status = agentOverrides?.status ?? 'running'; + const agent = makeAgent({ id: 'ag-oldagent', status }); + const wt = makeWorktree({ + id: 'wt-abc123', + name: 'feature-auth', + agents: { 'ag-oldagent': agent }, + }); + const manifest = makeManifest({ worktrees: { [wt.id]: wt } }); + mockedRequireManifest.mockResolvedValue(manifest); + mockedFindAgent.mockReturnValue({ worktree: wt, agent }); + mockedCreateWindow.mockResolvedValue('ppg:2'); + mockedReadFile.mockResolvedValue('original prompt' as unknown as never); + mockedSpawnAgent.mockResolvedValue(makeAgent({ + id: 'ag-newagent', + tmuxTarget: 'ppg:2', + sessionId: 'sess-new123', + })); + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + const m = JSON.parse(JSON.stringify(manifest)) as Manifest; + return updater(m); + }); + return { agent, wt, manifest }; + } + + test('given running agent, should kill old agent before restarting', async () => { + const { agent } = setupDefaults({ status: 'running' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).toHaveBeenCalledWith(agent); + }); + + test('given running agent, should return killedOldAgent true', async () => { + setupDefaults({ status: 'running' }); + + const result = await performRestart({ agentRef: 'ag-oldagent' }); + + expect(result.killedOldAgent).toBe(true); + }); + + test('given idle agent, should not kill old agent', async () => { + setupDefaults({ status: 'idle' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).not.toHaveBeenCalled(); + }); + + test('given exited agent, should not kill old agent', async () => { + setupDefaults({ status: 'exited' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).not.toHaveBeenCalled(); + }); + + test('given gone agent, should not kill old agent', async () => { + setupDefaults({ status: 'gone' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).not.toHaveBeenCalled(); + }); + + test('given non-running agent, should return killedOldAgent false', async () => { + setupDefaults({ status: 'idle' }); + + const result = await performRestart({ agentRef: 'ag-oldagent' }); + + expect(result.killedOldAgent).toBe(false); + }); + + test('should create tmux window in same worktree', async () => { + const { wt } = setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedEnsureSession).toHaveBeenCalledWith('ppg'); + expect(mockedCreateWindow).toHaveBeenCalledWith('ppg', 'feature-auth-restart', wt.path); + }); + + test('should spawn agent with correct options', async () => { + const { wt } = setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedSpawnAgent).toHaveBeenCalledWith({ + agentId: 'ag-newagent', + agentConfig: { + name: 'claude', + command: 'claude --dangerously-skip-permissions', + interactive: true, + }, + prompt: 'original prompt', + worktreePath: wt.path, + tmuxTarget: 'ppg:2', + projectRoot: PROJECT_ROOT, + branch: wt.branch, + sessionId: 'sess-new123', + }); + }); + + test('should update manifest with new agent and mark old as gone', async () => { + const { wt } = setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedUpdateManifest).toHaveBeenCalledWith(PROJECT_ROOT, expect.any(Function)); + + // Verify the updater function marks old agent gone and adds new agent + const updater = mockedUpdateManifest.mock.calls[0][1]; + const testManifest = makeManifest({ + worktrees: { + [wt.id]: { + ...wt, + agents: { + 'ag-oldagent': makeAgent({ id: 'ag-oldagent', status: 'running' }), + }, + }, + }, + }); + const updated = await updater(testManifest); + const updatedWt = updated.worktrees[wt.id]; + + expect(updatedWt.agents['ag-oldagent'].status).toBe('gone'); + expect(updatedWt.agents['ag-newagent']).toBeDefined(); + }); + + test('should return old and new agent info', async () => { + setupDefaults(); + + const result = await performRestart({ agentRef: 'ag-oldagent' }); + + expect(result.oldAgentId).toBe('ag-oldagent'); + expect(result.newAgent.id).toBe('ag-newagent'); + expect(result.newAgent.tmuxTarget).toBe('ppg:2'); + expect(result.newAgent.sessionId).toBe('sess-new123'); + expect(result.newAgent.worktreeId).toBe('wt-abc123'); + expect(result.newAgent.worktreeName).toBe('feature-auth'); + }); + + test('given prompt override, should use it instead of reading file', async () => { + setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent', prompt: 'custom prompt' }); + + expect(mockedReadFile).not.toHaveBeenCalled(); + }); + + test('given no prompt and missing prompt file, should throw PromptNotFoundError', async () => { + setupDefaults(); + mockedReadFile.mockRejectedValue(new Error('ENOENT')); + + await expect(performRestart({ agentRef: 'ag-oldagent' })).rejects.toThrow('Could not read original prompt'); + }); + + test('given unknown agent ref, should throw AgentNotFoundError', async () => { + const manifest = makeManifest(); + mockedRequireManifest.mockResolvedValue(manifest); + mockedFindAgent.mockReturnValue(undefined); + + await expect(performRestart({ agentRef: 'ag-nonexist' })).rejects.toThrow('Agent not found'); + }); + + test('given explicit projectRoot, should use it instead of getRepoRoot', async () => { + setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent', projectRoot: PROJECT_ROOT }); + + // getRepoRoot is mocked — if projectRoot is passed, the operation still works + // (verifiable because requireManifest receives the correct root) + expect(mockedUpdateManifest).toHaveBeenCalledWith(PROJECT_ROOT, expect.any(Function)); + }); +}); diff --git a/src/core/operations/restart.ts b/src/core/operations/restart.ts new file mode 100644 index 0000000..50ebcc8 --- /dev/null +++ b/src/core/operations/restart.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs/promises'; +import { requireManifest, updateManifest, findAgent } from '../manifest.js'; +import { loadConfig, resolveAgentConfig } from '../config.js'; +import { spawnAgent, killAgent } from '../agent.js'; +import { getRepoRoot } from '../worktree.js'; +import * as tmux from '../tmux.js'; +import { agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { agentPromptFile } from '../../lib/paths.js'; +import { AgentNotFoundError, PromptNotFoundError } from '../../lib/errors.js'; +import { renderTemplate, type TemplateContext } from '../template.js'; + +export interface RestartParams { + agentRef: string; + prompt?: string; + agentType?: string; + projectRoot?: string; +} + +export interface RestartResult { + oldAgentId: string; + killedOldAgent: boolean; + newAgent: { + id: string; + tmuxTarget: string; + sessionId: string; + worktreeId: string; + worktreeName: string; + branch: string; + path: string; + }; + sessionName: string; +} + +export async function performRestart(params: RestartParams): Promise { + const { agentRef, prompt: promptOverride, agentType } = params; + + const projectRoot = params.projectRoot ?? await getRepoRoot(); + const config = await loadConfig(projectRoot); + const manifest = await requireManifest(projectRoot); + + const found = findAgent(manifest, agentRef); + if (!found) throw new AgentNotFoundError(agentRef); + + const { worktree: wt, agent: oldAgent } = found; + + // Kill old agent if still running + let killedOldAgent = false; + if (oldAgent.status === 'running') { + await killAgent(oldAgent); + killedOldAgent = true; + } + + // Read original prompt from prompt file, or use override + let promptText: string; + if (promptOverride) { + promptText = promptOverride; + } else { + const pFile = agentPromptFile(projectRoot, oldAgent.id); + try { + promptText = await fs.readFile(pFile, 'utf-8'); + } catch { + throw new PromptNotFoundError(oldAgent.id); + } + } + + // Resolve agent config + const agentConfig = resolveAgentConfig(config, agentType ?? oldAgent.agentType); + + // Ensure tmux session + await tmux.ensureSession(manifest.sessionName); + + // Create new tmux window in same worktree + const newAgentId = genAgentId(); + const windowTarget = await tmux.createWindow(manifest.sessionName, `${wt.name}-restart`, wt.path); + + // Render template vars + const ctx: TemplateContext = { + WORKTREE_PATH: wt.path, + BRANCH: wt.branch, + AGENT_ID: newAgentId, + PROJECT_ROOT: projectRoot, + TASK_NAME: wt.name, + PROMPT: promptText, + }; + const renderedPrompt = renderTemplate(promptText, ctx); + + const newSessionId = genSessionId(); + const agentEntry = await spawnAgent({ + agentId: newAgentId, + agentConfig, + prompt: renderedPrompt, + worktreePath: wt.path, + tmuxTarget: windowTarget, + projectRoot, + branch: wt.branch, + sessionId: newSessionId, + }); + + // Update manifest: mark old agent as gone, add new agent + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + const mOldAgent = mWt.agents[oldAgent.id]; + if (mOldAgent && mOldAgent.status === 'running') { + mOldAgent.status = 'gone'; + } + mWt.agents[newAgentId] = agentEntry; + } + return m; + }); + + return { + oldAgentId: oldAgent.id, + killedOldAgent, + newAgent: { + id: newAgentId, + tmuxTarget: windowTarget, + sessionId: newSessionId, + worktreeId: wt.id, + worktreeName: wt.name, + branch: wt.branch, + path: wt.path, + }, + sessionName: manifest.sessionName, + }; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 0af4143..9e694be 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -86,6 +86,16 @@ export class GhNotFoundError extends PpgError { } } +export class PromptNotFoundError extends PpgError { + constructor(agentId: string) { + super( + `Could not read original prompt for agent ${agentId}. Use --prompt to provide one.`, + 'PROMPT_NOT_FOUND', + ); + this.name = 'PromptNotFoundError'; + } +} + export class UnmergedWorkError extends PpgError { constructor(names: string[]) { const list = names.map((n) => ` ${n}`).join('\n');