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,161 @@
---
phase: 04-agent-lifecycle
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/db/schema.ts, src/db/repositories/agent-repository.ts, src/db/repositories/drizzle/agent.ts, src/db/repositories/drizzle/agent.test.ts, src/db/repositories/drizzle/index.ts, src/db/repositories/index.ts]
autonomous: true
---
<objective>
Add agents table to database schema and create AgentRepository for persistence.
Purpose: Enable agent state persistence for session resumption (AGENT-04) and listing (AGENT-03).
Output: Database schema with agents table, AgentRepository port and Drizzle adapter with 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
@src/db/schema.ts
@src/db/repositories/task-repository.ts
@src/db/repositories/drizzle/task.ts
@src/db/repositories/drizzle/test-helpers.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Add agents table to database schema</name>
<files>src/db/schema.ts</files>
<action>
Add agents table to schema.ts following existing patterns:
```typescript
export const agents = sqliteTable('agents', {
id: text('id').primaryKey(),
taskId: text('task_id')
.references(() => tasks.id, { onDelete: 'set null' }), // Task may be deleted
sessionId: text('session_id').notNull(), // Claude SDK session ID for resumption
worktreeId: text('worktree_id').notNull(), // WorktreeManager worktree ID
status: text('status', { enum: ['idle', 'running', 'stopped', 'crashed'] })
.notNull()
.default('idle'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
```
Add relations:
```typescript
export const agentsRelations = relations(agents, ({ one }) => ({
task: one(tasks, {
fields: [agents.taskId],
references: [tasks.id],
}),
}));
```
Export types:
```typescript
export type Agent = InferSelectModel<typeof agents>;
export type NewAgent = InferInsertModel<typeof agents>;
```
Note: taskId is nullable with onDelete: 'set null' because agent may outlive task.
</action>
<verify>npm run build passes with no TypeScript errors</verify>
<done>agents table defined in schema with relations and exported types</done>
</task>
<task type="auto">
<name>Task 2: Create AgentRepository port interface</name>
<files>src/db/repositories/agent-repository.ts, src/db/repositories/index.ts</files>
<action>
Create AgentRepository port interface following same pattern as TaskRepository:
```typescript
// src/db/repositories/agent-repository.ts
import type { Agent, NewAgent } from '../schema.js';
export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed';
export interface AgentRepository {
create(agent: Omit<NewAgent, 'createdAt' | 'updatedAt'>): Promise<Agent>;
findById(id: string): Promise<Agent | null>;
findByTaskId(taskId: string): Promise<Agent | null>;
findBySessionId(sessionId: string): Promise<Agent | null>;
findAll(): Promise<Agent[]>;
findByStatus(status: AgentStatus): Promise<Agent[]>;
updateStatus(id: string, status: AgentStatus): Promise<Agent>;
updateSessionId(id: string, sessionId: string): Promise<Agent>;
delete(id: string): Promise<void>;
}
```
Export from index.ts barrel file.
</action>
<verify>npm run build passes</verify>
<done>AgentRepository interface exported from src/db/repositories/</done>
</task>
<task type="auto">
<name>Task 3: Create DrizzleAgentRepository adapter with tests</name>
<files>src/db/repositories/drizzle/agent.ts, src/db/repositories/drizzle/agent.test.ts, src/db/repositories/drizzle/index.ts</files>
<action>
Create DrizzleAgentRepository following existing patterns (see drizzle/task.ts):
1. Implement all AgentRepository methods using Drizzle ORM
2. Use eq() for queries, set() for updates
3. Fetch after insert to ensure schema defaults applied
4. Throw on not found for update/delete operations
Tests should cover:
- create() returns agent with timestamps
- findById() returns null for non-existent
- findByTaskId() finds agent by task
- findBySessionId() finds agent by session
- findAll() returns all agents
- findByStatus() filters correctly
- updateStatus() changes status and updatedAt
- updateSessionId() changes sessionId
- delete() removes agent
Use createTestDatabase() helper from test-helpers.ts for isolated test databases.
Export DrizzleAgentRepository from drizzle/index.ts.
</action>
<verify>npm test -- src/db/repositories/drizzle/agent.test.ts passes all tests</verify>
<done>DrizzleAgentRepository implemented with passing tests, exported from barrel file</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] npm run build succeeds without errors
- [ ] npm test passes all agent repository tests
- [ ] agents table has correct columns and relations
- [ ] AgentRepository interface is exported
- [ ] DrizzleAgentRepository is exported from drizzle/index.ts
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- Agent persistence layer ready for AgentManager adapter
</success_criteria>
<output>
After completion, create `.planning/phases/04-agent-lifecycle/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,255 @@
---
phase: 04-agent-lifecycle
plan: 02
type: execute
wave: 1
depends_on: []
files_modified: [src/agent/types.ts, src/agent/index.ts, src/events/types.ts, src/events/index.ts]
autonomous: true
---
<objective>
Define AgentManager port interface and agent lifecycle domain events.
Purpose: Establish the contract for agent operations following hexagonal architecture.
Output: AgentManager port interface and AgentSpawned/AgentStopped/AgentCrashed events.
</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
@src/events/types.ts
@src/events/index.ts
@src/git/types.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Define AgentManager port interface and domain types</name>
<files>src/agent/types.ts, src/agent/index.ts</files>
<action>
Create new agent module with port interface following WorktreeManager pattern:
```typescript
// src/agent/types.ts
/**
* Agent Module Types
*
* Port interface for agent lifecycle management.
* AgentManager is the PORT. Implementations are ADAPTERS.
*/
export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed';
/**
* Options for spawning a new agent
*/
export interface SpawnAgentOptions {
/** Task ID to assign to agent */
taskId: string;
/** Initial prompt/instruction for the agent */
prompt: string;
/** Optional working directory (defaults to worktree path) */
cwd?: string;
}
/**
* Represents a Claude agent instance
*/
export interface AgentInfo {
/** Unique identifier for this agent */
id: string;
/** Task this agent is working on */
taskId: string;
/** Claude SDK session ID for resumption */
sessionId: string;
/** WorktreeManager worktree ID */
worktreeId: string;
/** Current status */
status: AgentStatus;
/** When the agent was created */
createdAt: Date;
/** Last activity timestamp */
updatedAt: Date;
}
/**
* Result from agent execution
*/
export interface AgentResult {
/** Whether the task completed successfully */
success: boolean;
/** Result message or error description */
message: string;
/** Files modified during execution */
filesModified?: string[];
}
/**
* AgentManager Port Interface
*
* Manages Claude agent lifecycle - spawn, stop, list, resume.
*
* Covers requirements:
* - AGENT-01: Spawn new agent with task assignment
* - AGENT-02: Stop running agent
* - AGENT-03: List all agents with status
* - AGENT-04: Resume agent session
* - AGENT-05: Background mode (implementation detail)
*/
export interface AgentManager {
/**
* Spawn a new agent to work on a task.
*
* Creates isolated worktree, starts Claude SDK session,
* and begins executing the prompt.
*
* @param options - Spawn configuration
* @returns Agent info with session ID for later resumption
*/
spawn(options: SpawnAgentOptions): Promise<AgentInfo>;
/**
* Stop a running agent.
*
* Gracefully stops the agent's work. Worktree is preserved
* for potential resumption.
*
* @param agentId - Agent to stop
*/
stop(agentId: string): Promise<void>;
/**
* List all agents with their current status.
*
* @returns Array of all agents
*/
list(): Promise<AgentInfo[]>;
/**
* Get a specific agent by ID.
*
* @param agentId - Agent ID
* @returns Agent if found, null otherwise
*/
get(agentId: string): Promise<AgentInfo | null>;
/**
* Resume an idle agent with a new prompt.
*
* Uses stored session ID to continue with full context.
* Agent must be in 'idle' status.
*
* @param agentId - Agent to resume
* @param prompt - New instruction for the agent
*/
resume(agentId: string, prompt: string): Promise<void>;
/**
* Get the result of an agent's work.
*
* Only available after agent completes or stops.
*
* @param agentId - Agent ID
* @returns Result if available, null if agent still running
*/
getResult(agentId: string): Promise<AgentResult | null>;
}
```
Create barrel export:
```typescript
// src/agent/index.ts
export * from './types.js';
```
</action>
<verify>npm run build passes with no TypeScript errors</verify>
<done>AgentManager port interface and types exported from src/agent/</done>
</task>
<task type="auto">
<name>Task 2: Add agent lifecycle events to events module</name>
<files>src/events/types.ts, src/events/index.ts</files>
<action>
Add agent lifecycle events following existing patterns (ProcessSpawned, WorktreeCreated, etc.):
```typescript
// Add to src/events/types.ts
// Agent Events
export interface AgentSpawnedEvent extends DomainEvent {
type: 'agent:spawned';
payload: {
agentId: string;
taskId: string;
sessionId: string;
worktreeId: string;
};
}
export interface AgentStoppedEvent extends DomainEvent {
type: 'agent:stopped';
payload: {
agentId: string;
taskId: string;
reason: 'user_requested' | 'task_complete' | 'error';
};
}
export interface AgentCrashedEvent extends DomainEvent {
type: 'agent:crashed';
payload: {
agentId: string;
taskId: string;
error: string;
};
}
export interface AgentResumedEvent extends DomainEvent {
type: 'agent:resumed';
payload: {
agentId: string;
taskId: string;
sessionId: string;
};
}
```
Update the DomainEventType union to include new event types.
Export new event types from index.ts if not already using * export.
</action>
<verify>npm run build passes</verify>
<done>Agent lifecycle events defined and exported from events module</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] npm run build succeeds without errors
- [ ] AgentManager interface exported from src/agent/
- [ ] Agent lifecycle events (spawned, stopped, crashed, resumed) in events module
- [ ] All types properly exported from barrel files
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- Port interface ready for adapter implementation
</success_criteria>
<output>
After completion, create `.planning/phases/04-agent-lifecycle/04-02-SUMMARY.md`
</output>

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>

View File

@@ -0,0 +1,326 @@
---
phase: 04-agent-lifecycle
plan: 04
type: execute
wave: 3
depends_on: ["04-03"]
files_modified: [src/trpc/router.ts, src/trpc/context.ts, src/cli/index.ts, src/cli/trpc-client.ts]
autonomous: true
---
<objective>
Add agent procedures to tRPC router and CLI commands for agent management.
Purpose: Enable users to spawn, stop, list, and manage agents via CLI (AGENT-01, 02, 03).
Output: tRPC procedures and CLI commands for full agent lifecycle management.
</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-03-SUMMARY.md
@src/trpc/router.ts
@src/trpc/context.ts
@src/cli/index.ts
@src/cli/trpc-client.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Add AgentManager to tRPC context</name>
<files>src/trpc/context.ts</files>
<action>
Update tRPC context to include AgentManager:
1. Import AgentManager and ClaudeAgentManager
2. Add agentManager to context type
3. Create agentManager in context factory (requires repository and worktreeManager from context)
The context should wire up:
- AgentRepository (from database)
- WorktreeManager (from git module)
- EventBus (optional, for event emission)
Example pattern from existing context:
```typescript
export interface Context {
// ... existing
agentManager: AgentManager;
}
export function createContext(): Context {
// ... existing setup
const agentRepository = new DrizzleAgentRepository(db);
const agentManager = new ClaudeAgentManager(
agentRepository,
worktreeManager,
eventBus
);
return {
// ... existing
agentManager,
};
}
```
Note: Context may need to be async if database/worktree setup is async.
</action>
<verify>npm run build passes</verify>
<done>AgentManager available in tRPC context</done>
</task>
<task type="auto">
<name>Task 2: Add agent procedures to tRPC router</name>
<files>src/trpc/router.ts</files>
<action>
Add agent procedures following existing patterns:
```typescript
// Add to router.ts
import { z } from 'zod';
// Input schemas
const spawnAgentInput = z.object({
taskId: z.string(),
prompt: z.string(),
cwd: z.string().optional(),
});
const stopAgentInput = z.object({
agentId: z.string(),
});
const resumeAgentInput = z.object({
agentId: z.string(),
prompt: z.string(),
});
const getAgentInput = z.object({
agentId: z.string(),
});
// Add to router
export const appRouter = router({
// ... existing procedures
// Agent procedures
spawnAgent: procedure
.input(spawnAgentInput)
.mutation(async ({ ctx, input }) => {
const agent = await ctx.agentManager.spawn({
taskId: input.taskId,
prompt: input.prompt,
cwd: input.cwd,
});
return agent;
}),
stopAgent: procedure
.input(stopAgentInput)
.mutation(async ({ ctx, input }) => {
await ctx.agentManager.stop(input.agentId);
return { success: true };
}),
listAgents: procedure
.query(async ({ ctx }) => {
return ctx.agentManager.list();
}),
getAgent: procedure
.input(getAgentInput)
.query(async ({ ctx, input }) => {
return ctx.agentManager.get(input.agentId);
}),
resumeAgent: procedure
.input(resumeAgentInput)
.mutation(async ({ ctx, input }) => {
await ctx.agentManager.resume(input.agentId, input.prompt);
return { success: true };
}),
getAgentResult: procedure
.input(getAgentInput)
.query(async ({ ctx, input }) => {
return ctx.agentManager.getResult(input.agentId);
}),
});
```
Export updated AppRouter type for client.
</action>
<verify>npm run build passes</verify>
<done>Agent tRPC procedures added: spawn, stop, list, get, resume, getResult</done>
</task>
<task type="auto">
<name>Task 3: Add agent CLI commands</name>
<files>src/cli/index.ts</files>
<action>
Add CLI commands for agent management using existing tRPC client pattern:
```typescript
// Add commands to CLI
// cw agent spawn <taskId> <prompt>
// Spawns a new agent for the given task
program
.command('agent spawn <taskId> <prompt>')
.description('Spawn a new agent to work on a task')
.option('--cwd <path>', 'Working directory for agent')
.action(async (taskId: string, prompt: string, options: { cwd?: string }) => {
const client = await getTrpcClient();
try {
const agent = await client.spawnAgent.mutate({
taskId,
prompt,
cwd: options.cwd,
});
console.log(`Agent spawned: ${agent.id}`);
console.log(` Task: ${agent.taskId}`);
console.log(` Status: ${agent.status}`);
console.log(` Worktree: ${agent.worktreeId}`);
} catch (error) {
console.error('Failed to spawn agent:', error);
process.exit(1);
}
});
// cw agent stop <agentId>
program
.command('agent stop <agentId>')
.description('Stop a running agent')
.action(async (agentId: string) => {
const client = await getTrpcClient();
try {
await client.stopAgent.mutate({ agentId });
console.log(`Agent ${agentId} stopped`);
} catch (error) {
console.error('Failed to stop agent:', error);
process.exit(1);
}
});
// cw agent list
program
.command('agent list')
.description('List all agents')
.action(async () => {
const client = await getTrpcClient();
try {
const agents = await client.listAgents.query();
if (agents.length === 0) {
console.log('No agents found');
return;
}
console.log('Agents:');
for (const agent of agents) {
console.log(` ${agent.id} [${agent.status}] - Task: ${agent.taskId}`);
}
} catch (error) {
console.error('Failed to list agents:', error);
process.exit(1);
}
});
// cw agent get <agentId>
program
.command('agent get <agentId>')
.description('Get agent details')
.action(async (agentId: string) => {
const client = await getTrpcClient();
try {
const agent = await client.getAgent.query({ agentId });
if (!agent) {
console.log(`Agent ${agentId} not found`);
return;
}
console.log(`Agent: ${agent.id}`);
console.log(` Task: ${agent.taskId}`);
console.log(` Session: ${agent.sessionId}`);
console.log(` Worktree: ${agent.worktreeId}`);
console.log(` Status: ${agent.status}`);
console.log(` Created: ${agent.createdAt}`);
console.log(` Updated: ${agent.updatedAt}`);
} catch (error) {
console.error('Failed to get agent:', error);
process.exit(1);
}
});
// cw agent resume <agentId> <prompt>
program
.command('agent resume <agentId> <prompt>')
.description('Resume an idle agent with a new prompt')
.action(async (agentId: string, prompt: string) => {
const client = await getTrpcClient();
try {
await client.resumeAgent.mutate({ agentId, prompt });
console.log(`Agent ${agentId} resumed`);
} catch (error) {
console.error('Failed to resume agent:', error);
process.exit(1);
}
});
// cw agent result <agentId>
program
.command('agent result <agentId>')
.description('Get agent execution result')
.action(async (agentId: string) => {
const client = await getTrpcClient();
try {
const result = await client.getAgentResult.query({ agentId });
if (!result) {
console.log('No result available (agent may still be running)');
return;
}
console.log(`Result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
console.log(` Message: ${result.message}`);
if (result.filesModified?.length) {
console.log(` Files modified: ${result.filesModified.join(', ')}`);
}
} catch (error) {
console.error('Failed to get result:', error);
process.exit(1);
}
});
```
Commands use commander.js pattern from existing CLI.
</action>
<verify>npm run build passes, cw agent --help shows commands</verify>
<done>CLI commands added: agent spawn, stop, list, get, resume, result</done>
</task>
</tasks>
<verification>
Before declaring plan complete:
- [ ] npm run build succeeds without errors
- [ ] cw agent --help shows all agent commands
- [ ] Agent procedures accessible via tRPC client
- [ ] All 6 requirements satisfied (AGENT-01 through AGENT-07 except AGENT-06)
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- Users can manage agents via CLI
</success_criteria>
<output>
After completion, create `.planning/phases/04-agent-lifecycle/04-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,220 @@
# Phase 4: Agent Lifecycle - Discovery
**Research Level:** 2 (Standard Research)
**Completed:** 2026-01-30
## Research Questions
1. How do we spawn Claude Code agents programmatically?
2. What's the output format for background/non-interactive agents?
3. How do we persist agent sessions across terminal close/reopen?
4. How do we manage the process lifecycle?
## Key Finding: Claude Agent SDK
**The Claude Agent SDK** (formerly Claude Code SDK) is the correct approach. It provides:
- Programmatic agent spawning via `query()` function
- Built-in tool execution (no need to implement tool loop)
- Session persistence via session IDs
- Streaming message output
- Multi-turn conversations
### Installation
```bash
npm install @anthropic-ai/claude-agent-sdk
```
Requires Node.js 18+.
### API Patterns
**Single-turn / autonomous task (V1 - recommended for our use case):**
```typescript
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "Find and fix the bug in auth.py",
options: {
allowedTools: ["Read", "Edit", "Bash"],
permissionMode: "bypassPermissions" // For automation
}
})) {
if ("result" in message) console.log(message.result);
}
```
**Multi-turn sessions (V2 preview):**
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
await using session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
await session.send('Read the auth module')
for await (const msg of session.stream()) {
// Process messages
}
```
### Session Persistence (AGENT-04)
Sessions can be captured and resumed:
```typescript
let sessionId: string | undefined;
// First query: capture session ID
for await (const message of query({
prompt: "Read the authentication module",
options: { allowedTools: ["Read", "Glob"] }
})) {
if (message.type === "system" && message.subtype === "init") {
sessionId = message.session_id; // Store this!
}
}
// Later: resume with full context
for await (const message of query({
prompt: "Now find all places that call it",
options: { resume: sessionId }
})) {
// Full context preserved
}
```
**Decision:** Store `session_id` in database for persistence across terminal sessions.
### Key Options
| Option | Purpose |
|--------|---------|
| `allowedTools` | Array of tool names (Read, Edit, Bash, Glob, etc.) |
| `permissionMode` | "bypassPermissions" for automation, "acceptEdits" for safer mode |
| `resume` | Session ID to resume from |
| `maxTurns` | Maximum agentic turns before stopping |
| `cwd` | Working directory for agent operations |
### Message Types
| Type | Subtype | Contains |
|------|---------|----------|
| `system` | `init` | `session_id` - capture this for persistence |
| `assistant` | - | `message.content` with text blocks |
| `result` | `success`/`error` | Final result |
### Working Directory & Worktrees
The SDK accepts `cwd` option for working directory. We'll set this to the worktree path:
```typescript
for await (const message of query({
prompt: task.description,
options: {
allowedTools: ["Read", "Edit", "Bash", "Glob", "Grep"],
cwd: worktree.path, // Agent operates in isolated worktree
permissionMode: "bypassPermissions"
}
})) {
// ...
}
```
## Architecture Decision
### AgentManager Port Interface
Following hexagonal architecture (same as EventBus, WorktreeManager):
```typescript
interface AgentManager {
// Spawn new agent with task assignment (AGENT-01)
spawn(taskId: string, prompt: string): Promise<Agent>;
// Stop running agent (AGENT-02)
stop(agentId: string): Promise<void>;
// List all agents with status (AGENT-03)
list(): Promise<Agent[]>;
// Get single agent
get(agentId: string): Promise<Agent | null>;
// Resume agent session (AGENT-04)
resume(agentId: string, prompt: string): Promise<void>;
}
interface Agent {
id: string;
taskId: string;
sessionId: string; // Claude SDK session ID for persistence
worktreeId: string; // WorktreeManager worktree ID
status: 'running' | 'idle' | 'stopped' | 'crashed';
createdAt: Date;
lastActivityAt: Date;
}
```
### Database Schema Addition
Add `agents` table to persist agent state:
```sql
CREATE TABLE agents (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks(id),
session_id TEXT NOT NULL, -- Claude SDK session ID
worktree_id TEXT NOT NULL, -- WorktreeManager worktree ID
status TEXT NOT NULL DEFAULT 'idle',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
### Integration with Existing Infrastructure
1. **WorktreeManager** - Create isolated worktree per agent
2. **EventBus** - Emit agent lifecycle events (AgentSpawned, AgentStopped, etc.)
3. **Database** - Persist agent state for session resumption
4. **tRPC** - Expose agent operations to CLI
## Requirements Mapping
| Requirement | Solution |
|-------------|----------|
| AGENT-01: Spawn with task | `AgentManager.spawn(taskId, prompt)` creates worktree, starts SDK query |
| AGENT-02: Stop agent | `AgentManager.stop(id)` - abort async generator, emit stop event |
| AGENT-03: List agents | `AgentManager.list()` - query database, return with status |
| AGENT-04: Session persistence | Store `session_id` in DB, use `resume` option to continue |
| AGENT-05: Background mode | SDK runs in Node.js event loop, no terminal needed |
| AGENT-07: JSON output | SDK streams structured messages, not raw CLI output |
## Plan Structure
**4 plans in 3 waves:**
```
Wave 1 (parallel):
04-01: Agent schema + repository (database layer)
04-02: AgentManager port + events (interface + types)
Wave 2:
04-03: AgentManager adapter (SDK implementation)
Wave 3:
04-04: tRPC integration + CLI commands
```
## Sources
- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/agent-sdk/overview)
- [TypeScript SDK Reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
- [Claude Agent SDK TypeScript V2 Preview](https://platform.claude.com/docs/en/agent-sdk/typescript-v2-preview)
- [Claude Code CLI Reference](https://code.claude.com/docs/en/cli-reference)
---
*Discovery completed: 2026-01-30*