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:
161
.planning/phases/04-agent-lifecycle/04-01-PLAN.md
Normal file
161
.planning/phases/04-agent-lifecycle/04-01-PLAN.md
Normal 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>
|
||||
255
.planning/phases/04-agent-lifecycle/04-02-PLAN.md
Normal file
255
.planning/phases/04-agent-lifecycle/04-02-PLAN.md
Normal 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>
|
||||
652
.planning/phases/04-agent-lifecycle/04-03-PLAN.md
Normal file
652
.planning/phases/04-agent-lifecycle/04-03-PLAN.md
Normal 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>
|
||||
326
.planning/phases/04-agent-lifecycle/04-04-PLAN.md
Normal file
326
.planning/phases/04-agent-lifecycle/04-04-PLAN.md
Normal 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>
|
||||
220
.planning/phases/04-agent-lifecycle/DISCOVERY.md
Normal file
220
.planning/phases/04-agent-lifecycle/DISCOVERY.md
Normal 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*
|
||||
Reference in New Issue
Block a user