docs(04): create agent lifecycle phase plan

Phase 04: Agent Lifecycle
- 4 plans in 3 waves
- Wave 1 (parallel): 04-01 schema/repository, 04-02 port/events
- Wave 2: 04-03 ClaudeAgentManager adapter (Claude Agent SDK)
- Wave 3: 04-04 tRPC + CLI integration

Requirements covered:
- AGENT-01: Spawn new agent with task assignment
- AGENT-02: Stop running agent
- AGENT-03: List all agents with status
- AGENT-04: Session persistence via SDK session IDs
- AGENT-05: Background mode via Node.js event loop
- AGENT-07: JSON output via Claude Agent SDK
This commit is contained in:
Lukas May
2026-01-30 19:43:28 +01:00
parent e19b14944c
commit 781fbd0b23
5 changed files with 1614 additions and 0 deletions

View File

@@ -0,0 +1,652 @@
---
phase: 04-agent-lifecycle
plan: 03
type: execute
wave: 2
depends_on: ["04-01", "04-02"]
files_modified: [package.json, src/agent/manager.ts, src/agent/manager.test.ts, src/agent/index.ts]
autonomous: true
---
<objective>
Implement ClaudeAgentManager adapter using the Claude Agent SDK.
Purpose: Provide concrete implementation of AgentManager that spawns real Claude agents.
Output: ClaudeAgentManager adapter with comprehensive tests.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-agent-lifecycle/DISCOVERY.md
@.planning/phases/04-agent-lifecycle/04-01-SUMMARY.md
@.planning/phases/04-agent-lifecycle/04-02-SUMMARY.md
@src/agent/types.ts
@src/git/types.ts
@src/git/manager.ts
@src/db/repositories/agent-repository.ts
@src/events/types.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Install Claude Agent SDK</name>
<files>package.json</files>
<action>
Install the Claude Agent SDK:
```bash
npm install @anthropic-ai/claude-agent-sdk
```
Verify installation by checking package.json includes the dependency.
Note: SDK requires Node.js 18+ (already satisfied by project).
</action>
<verify>npm ls @anthropic-ai/claude-agent-sdk shows package installed</verify>
<done>@anthropic-ai/claude-agent-sdk added to dependencies</done>
</task>
<task type="auto">
<name>Task 2: Implement ClaudeAgentManager adapter</name>
<files>src/agent/manager.ts, src/agent/index.ts</files>
<action>
Create ClaudeAgentManager implementing AgentManager port:
```typescript
// src/agent/manager.ts
import { query } from '@anthropic-ai/claude-agent-sdk';
import { randomUUID } from 'crypto';
import type { AgentManager, AgentInfo, SpawnAgentOptions, AgentResult, AgentStatus } from './types.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { WorktreeManager } from '../git/types.js';
import type { EventBus, AgentSpawnedEvent, AgentStoppedEvent, AgentCrashedEvent, AgentResumedEvent } from '../events/index.js';
interface ActiveAgent {
abortController: AbortController;
result?: AgentResult;
}
export class ClaudeAgentManager implements AgentManager {
private activeAgents: Map<string, ActiveAgent> = new Map();
constructor(
private repository: AgentRepository,
private worktreeManager: WorktreeManager,
private eventBus?: EventBus
) {}
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
const { taskId, prompt, cwd } = options;
const agentId = randomUUID();
const branchName = `agent/${agentId}`;
// 1. Create isolated worktree
const worktree = await this.worktreeManager.create(agentId, branchName);
// 2. Create agent record (session ID set after SDK init)
const agent = await this.repository.create({
id: agentId,
taskId,
sessionId: '', // Updated after SDK init
worktreeId: worktree.id,
status: 'running',
});
// 3. Start agent execution
const abortController = new AbortController();
this.activeAgents.set(agentId, { abortController });
// Run agent in background (non-blocking)
this.runAgent(agentId, prompt, cwd ?? worktree.path, abortController.signal)
.catch(error => this.handleAgentError(agentId, error));
return this.toAgentInfo(agent);
}
private async runAgent(
agentId: string,
prompt: string,
cwd: string,
signal: AbortSignal
): Promise<void> {
try {
let sessionId: string | undefined;
for await (const message of query({
prompt,
options: {
allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
permissionMode: 'bypassPermissions',
cwd,
}
})) {
// Check for abort
if (signal.aborted) {
throw new Error('Agent stopped by user');
}
// Capture session ID from init message
if (message.type === 'system' && message.subtype === 'init') {
sessionId = message.session_id;
await this.repository.updateSessionId(agentId, sessionId);
// Emit spawned event now that we have session ID
if (this.eventBus) {
const agent = await this.repository.findById(agentId);
if (agent) {
const event: AgentSpawnedEvent = {
type: 'agent:spawned',
timestamp: new Date(),
payload: {
agentId,
taskId: agent.taskId ?? '',
sessionId,
worktreeId: agent.worktreeId,
},
};
this.eventBus.emit(event);
}
}
}
// Handle result
if (message.type === 'result') {
const active = this.activeAgents.get(agentId);
if (active) {
active.result = {
success: message.subtype === 'success',
message: message.subtype === 'success'
? 'Task completed successfully'
: 'Task failed',
};
}
}
}
// Agent completed successfully
await this.repository.updateStatus(agentId, 'idle');
if (this.eventBus) {
const agent = await this.repository.findById(agentId);
if (agent) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
taskId: agent.taskId ?? '',
reason: 'task_complete',
},
};
this.eventBus.emit(event);
}
}
} catch (error) {
throw error;
}
}
private async handleAgentError(agentId: string, error: unknown): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if this was a user-requested stop
if (errorMessage === 'Agent stopped by user') {
await this.repository.updateStatus(agentId, 'stopped');
return;
}
// Crashed
await this.repository.updateStatus(agentId, 'crashed');
if (this.eventBus) {
const agent = await this.repository.findById(agentId);
if (agent) {
const event: AgentCrashedEvent = {
type: 'agent:crashed',
timestamp: new Date(),
payload: {
agentId,
taskId: agent.taskId ?? '',
error: errorMessage,
},
};
this.eventBus.emit(event);
}
}
// Store error result
const active = this.activeAgents.get(agentId);
if (active) {
active.result = {
success: false,
message: errorMessage,
};
}
}
async stop(agentId: string): Promise<void> {
const agent = await this.repository.findById(agentId);
if (!agent) {
throw new Error(`Agent '${agentId}' not found`);
}
const active = this.activeAgents.get(agentId);
if (active) {
active.abortController.abort();
this.activeAgents.delete(agentId);
}
await this.repository.updateStatus(agentId, 'stopped');
if (this.eventBus) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
taskId: agent.taskId ?? '',
reason: 'user_requested',
},
};
this.eventBus.emit(event);
}
}
async list(): Promise<AgentInfo[]> {
const agents = await this.repository.findAll();
return agents.map(a => this.toAgentInfo(a));
}
async get(agentId: string): Promise<AgentInfo | null> {
const agent = await this.repository.findById(agentId);
return agent ? this.toAgentInfo(agent) : null;
}
async resume(agentId: string, prompt: string): Promise<void> {
const agent = await this.repository.findById(agentId);
if (!agent) {
throw new Error(`Agent '${agentId}' not found`);
}
if (agent.status !== 'idle') {
throw new Error(`Agent '${agentId}' is not idle (status: ${agent.status})`);
}
if (!agent.sessionId) {
throw new Error(`Agent '${agentId}' has no session to resume`);
}
// Get worktree path
const worktree = await this.worktreeManager.get(agent.worktreeId);
if (!worktree) {
throw new Error(`Worktree '${agent.worktreeId}' not found`);
}
// Update status to running
await this.repository.updateStatus(agentId, 'running');
// Create new abort controller
const abortController = new AbortController();
this.activeAgents.set(agentId, { abortController });
// Emit resumed event
if (this.eventBus) {
const event: AgentResumedEvent = {
type: 'agent:resumed',
timestamp: new Date(),
payload: {
agentId,
taskId: agent.taskId ?? '',
sessionId: agent.sessionId,
},
};
this.eventBus.emit(event);
}
// Run with resume option
this.runAgentResume(agentId, prompt, worktree.path, agent.sessionId, abortController.signal)
.catch(error => this.handleAgentError(agentId, error));
}
private async runAgentResume(
agentId: string,
prompt: string,
cwd: string,
sessionId: string,
signal: AbortSignal
): Promise<void> {
try {
for await (const message of query({
prompt,
options: {
allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
permissionMode: 'bypassPermissions',
cwd,
resume: sessionId,
}
})) {
if (signal.aborted) {
throw new Error('Agent stopped by user');
}
if (message.type === 'result') {
const active = this.activeAgents.get(agentId);
if (active) {
active.result = {
success: message.subtype === 'success',
message: message.subtype === 'success'
? 'Task completed successfully'
: 'Task failed',
};
}
}
}
await this.repository.updateStatus(agentId, 'idle');
if (this.eventBus) {
const agent = await this.repository.findById(agentId);
if (agent) {
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
taskId: agent.taskId ?? '',
reason: 'task_complete',
},
};
this.eventBus.emit(event);
}
}
} catch (error) {
throw error;
}
}
async getResult(agentId: string): Promise<AgentResult | null> {
const active = this.activeAgents.get(agentId);
return active?.result ?? null;
}
private toAgentInfo(agent: { id: string; taskId: string | null; sessionId: string; worktreeId: string; status: string; createdAt: Date; updatedAt: Date }): AgentInfo {
return {
id: agent.id,
taskId: agent.taskId ?? '',
sessionId: agent.sessionId,
worktreeId: agent.worktreeId,
status: agent.status as AgentStatus,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
};
}
}
```
Export from index.ts:
```typescript
export * from './types.js';
export { ClaudeAgentManager } from './manager.js';
```
</action>
<verify>npm run build passes with no TypeScript errors</verify>
<done>ClaudeAgentManager adapter implemented and exported</done>
</task>
<task type="auto">
<name>Task 3: Write tests for AgentManager</name>
<files>src/agent/manager.test.ts</files>
<action>
Create unit tests for ClaudeAgentManager. Since we can't actually spawn Claude agents in tests, mock the SDK:
```typescript
// src/agent/manager.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ClaudeAgentManager } from './manager.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { WorktreeManager, Worktree } from '../git/types.js';
import { EventEmitterBus } from '../events/index.js';
// Mock the Claude Agent SDK
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
query: vi.fn(),
}));
import { query } from '@anthropic-ai/claude-agent-sdk';
const mockQuery = vi.mocked(query);
describe('ClaudeAgentManager', () => {
let manager: ClaudeAgentManager;
let mockRepository: AgentRepository;
let mockWorktreeManager: WorktreeManager;
let eventBus: EventEmitterBus;
const mockWorktree: Worktree = {
id: 'worktree-123',
branch: 'agent/test',
path: '/tmp/worktree',
isMainWorktree: false,
};
const mockAgent = {
id: 'agent-123',
taskId: 'task-456',
sessionId: 'session-789',
worktreeId: 'worktree-123',
status: 'idle' as const,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
mockRepository = {
create: vi.fn().mockResolvedValue(mockAgent),
findById: vi.fn().mockResolvedValue(mockAgent),
findByTaskId: vi.fn().mockResolvedValue(mockAgent),
findBySessionId: vi.fn().mockResolvedValue(mockAgent),
findAll: vi.fn().mockResolvedValue([mockAgent]),
findByStatus: vi.fn().mockResolvedValue([mockAgent]),
updateStatus: vi.fn().mockResolvedValue({ ...mockAgent, status: 'running' }),
updateSessionId: vi.fn().mockResolvedValue({ ...mockAgent, sessionId: 'new-session' }),
delete: vi.fn().mockResolvedValue(undefined),
};
mockWorktreeManager = {
create: vi.fn().mockResolvedValue(mockWorktree),
remove: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([mockWorktree]),
get: vi.fn().mockResolvedValue(mockWorktree),
diff: vi.fn().mockResolvedValue({ files: [], summary: '' }),
merge: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
};
eventBus = new EventEmitterBus();
manager = new ClaudeAgentManager(mockRepository, mockWorktreeManager, eventBus);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('spawn', () => {
it('creates worktree and agent record', async () => {
// Mock query to complete immediately
mockQuery.mockImplementation(async function* () {
yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
yield { type: 'result', subtype: 'success' };
});
const result = await manager.spawn({
taskId: 'task-456',
prompt: 'Test task',
});
expect(mockWorktreeManager.create).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('agent/')
);
expect(mockRepository.create).toHaveBeenCalled();
expect(result.taskId).toBe('task-456');
});
it('emits AgentSpawned event', async () => {
const events: any[] = [];
eventBus.subscribe((event) => events.push(event));
mockQuery.mockImplementation(async function* () {
yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
yield { type: 'result', subtype: 'success' };
});
await manager.spawn({ taskId: 'task-456', prompt: 'Test' });
// Wait for async event
await new Promise(resolve => setTimeout(resolve, 10));
expect(events.some(e => e.type === 'agent:spawned')).toBe(true);
});
});
describe('stop', () => {
it('stops running agent', async () => {
// First spawn an agent
mockQuery.mockImplementation(async function* () {
yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
// Hang here - never yield result
await new Promise(() => {});
});
const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' });
await manager.stop(agent.id);
expect(mockRepository.updateStatus).toHaveBeenCalledWith(agent.id, 'stopped');
});
it('throws for non-existent agent', async () => {
mockRepository.findById = vi.fn().mockResolvedValue(null);
await expect(manager.stop('not-found')).rejects.toThrow("Agent 'not-found' not found");
});
});
describe('list', () => {
it('returns all agents', async () => {
const agents = await manager.list();
expect(mockRepository.findAll).toHaveBeenCalled();
expect(agents).toHaveLength(1);
expect(agents[0].id).toBe('agent-123');
});
});
describe('get', () => {
it('returns agent by id', async () => {
const agent = await manager.get('agent-123');
expect(mockRepository.findById).toHaveBeenCalledWith('agent-123');
expect(agent?.id).toBe('agent-123');
});
it('returns null for non-existent agent', async () => {
mockRepository.findById = vi.fn().mockResolvedValue(null);
const agent = await manager.get('not-found');
expect(agent).toBeNull();
});
});
describe('resume', () => {
it('resumes idle agent with existing session', async () => {
mockQuery.mockImplementation(async function* () {
yield { type: 'result', subtype: 'success' };
});
await manager.resume('agent-123', 'Continue work');
expect(mockRepository.updateStatus).toHaveBeenCalledWith('agent-123', 'running');
});
it('throws if agent not idle', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'running',
});
await expect(manager.resume('agent-123', 'Continue')).rejects.toThrow('is not idle');
});
it('throws if no session to resume', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
sessionId: '',
});
await expect(manager.resume('agent-123', 'Continue')).rejects.toThrow('no session to resume');
});
});
describe('getResult', () => {
it('returns result after completion', async () => {
mockQuery.mockImplementation(async function* () {
yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
yield { type: 'result', subtype: 'success' };
});
const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' });
// Wait for completion
await new Promise(resolve => setTimeout(resolve, 10));
const result = await manager.getResult(agent.id);
expect(result?.success).toBe(true);
});
it('returns null for unknown agent', async () => {
const result = await manager.getResult('unknown');
expect(result).toBeNull();
});
});
});
```
Tests mock the Claude Agent SDK since we can't spawn real agents in tests.
</action>
<verify>npm test -- src/agent/manager.test.ts passes all tests</verify>
<done>ClaudeAgentManager tests pass, verifying spawn, stop, list, get, resume, and getResult</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] npm run build succeeds without errors
- [ ] npm test passes all agent manager tests
- [ ] @anthropic-ai/claude-agent-sdk installed
- [ ] ClaudeAgentManager implements all AgentManager methods
- [ ] Events emitted on spawn, stop, crash, resume
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- AgentManager ready for tRPC integration
</success_criteria>
<output>
After completion, create `.planning/phases/04-agent-lifecycle/04-03-SUMMARY.md`
</output>