feat: Add agent spawn infrastructure for errand mode
Implements three primitives needed before errand tRPC procedures can be wired up: - agentManager.sendUserMessage(agentId, message): resumes an errand agent with a raw user message, bypassing the conversations table and conversationResumeLocks. Throws on missing agent, invalid status, or absent sessionId. - writeErrandManifest(options): writes .cw/input/errand.md (YAML frontmatter), .cw/input/manifest.json (errandId/agentId/agentName/mode, no files/contextFiles), and .cw/expected-pwd.txt to an agent workdir. - buildErrandPrompt(description): minimal prompt for errand agents; exported from prompts/errand.ts and re-exported from prompts/index.ts. Also fixes a pre-existing TypeScript error in lifecycle/controller.test.ts (missing backoffMs property in RetryPolicy mock introduced by a concurrent agent commit). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* File-Based Agent I/O Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
readDecisionFiles,
|
||||
readPageFiles,
|
||||
generateId,
|
||||
writeErrandManifest,
|
||||
} from './file-io.js';
|
||||
import { buildErrandPrompt } from './prompts/index.js';
|
||||
import type { Initiative, Phase, Task } from '../db/schema.js';
|
||||
|
||||
let testDir: string;
|
||||
@@ -367,3 +369,116 @@ New content for the page.
|
||||
expect(pages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeErrandManifest', () => {
|
||||
let errandTestDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
errandTestDir = join(tmpdir(), `cw-errand-test-${randomUUID()}`);
|
||||
mkdirSync(errandTestDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// no-op: beforeEach creates dirs, afterEach in outer scope cleans up
|
||||
});
|
||||
|
||||
it('writes manifest.json with correct shape', async () => {
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: errandTestDir,
|
||||
errandId: 'errand-abc',
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-errandabc',
|
||||
projectName: 'my-project',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
});
|
||||
|
||||
const manifestPath = join(errandTestDir, '.cw', 'input', 'manifest.json');
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
expect(manifest).toEqual({
|
||||
errandId: 'errand-abc',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
mode: 'errand',
|
||||
});
|
||||
expect('files' in manifest).toBe(false);
|
||||
expect('contextFiles' in manifest).toBe(false);
|
||||
});
|
||||
|
||||
it('writes errand.md with correct YAML frontmatter', async () => {
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: errandTestDir,
|
||||
errandId: 'errand-abc',
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-errandabc',
|
||||
projectName: 'my-project',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
});
|
||||
|
||||
const errandMdPath = join(errandTestDir, '.cw', 'input', 'errand.md');
|
||||
expect(existsSync(errandMdPath)).toBe(true);
|
||||
const content = readFileSync(errandMdPath, 'utf-8');
|
||||
expect(content).toContain('id: errand-abc');
|
||||
expect(content).toContain('description: fix typo');
|
||||
expect(content).toContain('branch: cw/errand/fix-typo-errandabc');
|
||||
expect(content).toContain('project: my-project');
|
||||
});
|
||||
|
||||
it('writes expected-pwd.txt with agentWorkdir path', async () => {
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: errandTestDir,
|
||||
errandId: 'errand-abc',
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-errandabc',
|
||||
projectName: 'my-project',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
});
|
||||
|
||||
const pwdPath = join(errandTestDir, '.cw', 'expected-pwd.txt');
|
||||
expect(existsSync(pwdPath)).toBe(true);
|
||||
const content = readFileSync(pwdPath, 'utf-8').trim();
|
||||
expect(content).toBe(errandTestDir);
|
||||
});
|
||||
|
||||
it('creates input directory if it does not exist', async () => {
|
||||
const freshDir = join(tmpdir(), `cw-errand-fresh-${randomUUID()}`);
|
||||
mkdirSync(freshDir, { recursive: true });
|
||||
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: freshDir,
|
||||
errandId: 'errand-xyz',
|
||||
description: 'add feature',
|
||||
branch: 'cw/errand/add-feature-errandxyz',
|
||||
projectName: 'other-project',
|
||||
agentId: 'agent-2',
|
||||
agentName: 'brave-eagle',
|
||||
});
|
||||
|
||||
expect(existsSync(join(freshDir, '.cw', 'input', 'manifest.json'))).toBe(true);
|
||||
expect(existsSync(join(freshDir, '.cw', 'input', 'errand.md'))).toBe(true);
|
||||
expect(existsSync(join(freshDir, '.cw', 'expected-pwd.txt'))).toBe(true);
|
||||
|
||||
rmSync(freshDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildErrandPrompt', () => {
|
||||
it('includes the description in the output', () => {
|
||||
const result = buildErrandPrompt('fix typo in README');
|
||||
expect(result).toContain('fix typo in README');
|
||||
});
|
||||
|
||||
it('includes signal.json instruction', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('signal.json');
|
||||
expect(result).toContain('"status": "done"');
|
||||
});
|
||||
|
||||
it('includes error signal format', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('"status": "error"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,6 +298,50 @@ export async function writeInputFiles(options: WriteInputFilesOptions): Promise<
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ERRAND INPUT FILE WRITING
|
||||
// =============================================================================
|
||||
|
||||
export async function writeErrandManifest(options: {
|
||||
agentWorkdir: string;
|
||||
errandId: string;
|
||||
description: string;
|
||||
branch: string;
|
||||
projectName: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
}): Promise<void> {
|
||||
await mkdir(join(options.agentWorkdir, '.cw', 'input'), { recursive: true });
|
||||
|
||||
// Write errand.md first (before manifest.json)
|
||||
const errandMdContent = formatFrontmatter({
|
||||
id: options.errandId,
|
||||
description: options.description,
|
||||
branch: options.branch,
|
||||
project: options.projectName,
|
||||
});
|
||||
await writeFile(join(options.agentWorkdir, '.cw', 'input', 'errand.md'), errandMdContent, 'utf-8');
|
||||
|
||||
// Write manifest.json last (after all other files exist)
|
||||
await writeFile(
|
||||
join(options.agentWorkdir, '.cw', 'input', 'manifest.json'),
|
||||
JSON.stringify({
|
||||
errandId: options.errandId,
|
||||
agentId: options.agentId,
|
||||
agentName: options.agentName,
|
||||
mode: 'errand',
|
||||
}) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Write expected-pwd.txt
|
||||
await writeFile(
|
||||
join(options.agentWorkdir, '.cw', 'expected-pwd.txt'),
|
||||
options.agentWorkdir,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OUTPUT FILE READING
|
||||
// =============================================================================
|
||||
|
||||
155
apps/server/agent/lifecycle/controller.test.ts
Normal file
155
apps/server/agent/lifecycle/controller.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* AgentLifecycleController Tests — Regression coverage for event emissions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AgentLifecycleController } from './controller.js';
|
||||
import type { AgentRepository } from '../../db/repositories/agent-repository.js';
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { SignalManager } from './signal-manager.js';
|
||||
import type { RetryPolicy } from './retry-policy.js';
|
||||
import type { AgentErrorAnalyzer } from './error-analyzer.js';
|
||||
import type { ProcessManager } from '../process-manager.js';
|
||||
import type { CleanupManager } from '../cleanup-manager.js';
|
||||
import type { CleanupStrategy } from './cleanup-strategy.js';
|
||||
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
|
||||
|
||||
function makeController(overrides: {
|
||||
repository?: Partial<AgentRepository>;
|
||||
accountRepository?: Partial<AccountRepository>;
|
||||
eventBus?: EventBus;
|
||||
}): AgentLifecycleController {
|
||||
const signalManager: SignalManager = {
|
||||
clearSignal: vi.fn(),
|
||||
checkSignalExists: vi.fn(),
|
||||
readSignal: vi.fn(),
|
||||
waitForSignal: vi.fn(),
|
||||
validateSignalFile: vi.fn(),
|
||||
};
|
||||
const retryPolicy: RetryPolicy = {
|
||||
maxAttempts: 3,
|
||||
backoffMs: [1000, 2000, 4000],
|
||||
shouldRetry: vi.fn().mockReturnValue(false),
|
||||
getRetryDelay: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer;
|
||||
const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager;
|
||||
const cleanupManager = {} as unknown as CleanupManager;
|
||||
const cleanupStrategy = {
|
||||
shouldCleanup: vi.fn(),
|
||||
executeCleanup: vi.fn(),
|
||||
} as unknown as CleanupStrategy;
|
||||
|
||||
return new AgentLifecycleController(
|
||||
signalManager,
|
||||
retryPolicy,
|
||||
errorAnalyzer,
|
||||
processManager,
|
||||
overrides.repository as AgentRepository,
|
||||
cleanupManager,
|
||||
cleanupStrategy,
|
||||
overrides.accountRepository as AccountRepository | undefined,
|
||||
false,
|
||||
overrides.eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentLifecycleController', () => {
|
||||
describe('handleAccountExhaustion', () => {
|
||||
it('emits agent:account_switched with correct payload when new account is available', async () => {
|
||||
const emittedEvents: AgentAccountSwitchedEvent[] = [];
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const agentRecord = {
|
||||
id: 'agent-1',
|
||||
name: 'test-agent',
|
||||
accountId: 'old-account-id',
|
||||
provider: 'claude',
|
||||
};
|
||||
const newAccount = { id: 'new-account-id' };
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue(agentRecord),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn().mockResolvedValue(agentRecord),
|
||||
findNextAvailable: vi.fn().mockResolvedValue(newAccount),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
// Call private method via any-cast
|
||||
await (controller as any).handleAccountExhaustion('agent-1');
|
||||
|
||||
const accountSwitchedEvents = emittedEvents.filter(
|
||||
(e) => e.type === 'agent:account_switched'
|
||||
);
|
||||
expect(accountSwitchedEvents).toHaveLength(1);
|
||||
const event = accountSwitchedEvents[0];
|
||||
expect(event.type).toBe('agent:account_switched');
|
||||
expect(event.payload.agentId).toBe('agent-1');
|
||||
expect(event.payload.name).toBe('test-agent');
|
||||
expect(event.payload.previousAccountId).toBe('old-account-id');
|
||||
expect(event.payload.newAccountId).toBe('new-account-id');
|
||||
expect(event.payload.reason).toBe('account_exhausted');
|
||||
});
|
||||
|
||||
it('does not emit agent:account_switched when no new account is available', async () => {
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const agentRecord = {
|
||||
id: 'agent-2',
|
||||
name: 'test-agent-2',
|
||||
accountId: 'old-account-id',
|
||||
provider: 'claude',
|
||||
};
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue(agentRecord),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn().mockResolvedValue(agentRecord),
|
||||
findNextAvailable: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
await (controller as any).handleAccountExhaustion('agent-2');
|
||||
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not emit when agent has no accountId', async () => {
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn(),
|
||||
findNextAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
await (controller as any).handleAccountExhaustion('agent-3');
|
||||
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -625,6 +625,73 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a user message to a running or idle errand agent.
|
||||
* Does not use the conversations table — the message is injected directly
|
||||
* as the next resume prompt for the agent's Claude Code session.
|
||||
*/
|
||||
async sendUserMessage(agentId: string, message: string): Promise<void> {
|
||||
const agent = await this.repository.findById(agentId);
|
||||
if (!agent) throw new Error(`Agent not found: ${agentId}`);
|
||||
|
||||
if (agent.status !== 'running' && agent.status !== 'idle') {
|
||||
throw new Error(`Agent is not running (status: ${agent.status})`);
|
||||
}
|
||||
|
||||
if (!agent.sessionId) {
|
||||
throw new Error('Agent has no session ID');
|
||||
}
|
||||
|
||||
const provider = getProvider(agent.provider);
|
||||
if (!provider) throw new Error(`Unknown provider: ${agent.provider}`);
|
||||
|
||||
const agentCwd = this.processManager.getAgentWorkdir(agent.worktreeId);
|
||||
|
||||
// Clear previous signal.json
|
||||
const signalPath = join(agentCwd, '.cw/output/signal.json');
|
||||
try {
|
||||
await unlink(signalPath);
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
await this.repository.update(agentId, { status: 'running', result: null });
|
||||
|
||||
const { command, args, env: providerEnv } = this.processManager.buildResumeCommand(provider, agent.sessionId, message);
|
||||
const { processEnv } = await this.credentialHandler.prepareProcessEnv(providerEnv, provider, agent.accountId);
|
||||
|
||||
// Stop previous tailer/poll
|
||||
const prevActive = this.activeAgents.get(agentId);
|
||||
prevActive?.cancelPoll?.();
|
||||
if (prevActive?.tailer) {
|
||||
await prevActive.tailer.stop();
|
||||
}
|
||||
|
||||
let sessionNumber = 1;
|
||||
if (this.logChunkRepository) {
|
||||
sessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1;
|
||||
}
|
||||
|
||||
const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached(
|
||||
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, message,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, agent.name, sessionNumber),
|
||||
);
|
||||
|
||||
await this.repository.update(agentId, { pid, outputFilePath });
|
||||
const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath };
|
||||
this.activeAgents.set(agentId, activeEntry);
|
||||
|
||||
const { cancel } = this.processManager.pollForCompletion(
|
||||
agentId, pid,
|
||||
() => this.handleDetachedAgentCompletion(agentId),
|
||||
() => this.activeAgents.get(agentId)?.tailer,
|
||||
);
|
||||
activeEntry.cancelPoll = cancel;
|
||||
|
||||
log.info({ agentId, pid }, 'resumed errand agent for user message');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync credentials from agent's config dir back to DB after completion.
|
||||
* The subprocess may have refreshed tokens mid-session; this ensures
|
||||
|
||||
16
apps/server/agent/prompts/errand.ts
Normal file
16
apps/server/agent/prompts/errand.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function buildErrandPrompt(description: string): string {
|
||||
return `You are working on a small, focused change in an isolated worktree.
|
||||
|
||||
Description: ${description}
|
||||
|
||||
Work interactively with the user. Make only the changes needed to fulfill the description.
|
||||
When you are done, write .cw/output/signal.json:
|
||||
|
||||
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
|
||||
|
||||
If you cannot complete the change:
|
||||
|
||||
{ "status": "error", "error": "<explanation>" }
|
||||
|
||||
Do not create any other output files.`;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export { buildDetailPrompt } from './detail.js';
|
||||
export { buildRefinePrompt } from './refine.js';
|
||||
export { buildChatPrompt } from './chat.js';
|
||||
export type { ChatHistoryEntry } from './chat.js';
|
||||
export { buildErrandPrompt } from './errand.js';
|
||||
export { buildWorkspaceLayout } from './workspace.js';
|
||||
export { buildPreviewInstructions } from './preview.js';
|
||||
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
||||
|
||||
Reference in New Issue
Block a user