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:
Lukas May
2026-03-06 13:22:15 +01:00
parent d52317ac5d
commit 52e238924c
7 changed files with 271 additions and 3 deletions

View File

@@ -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"');
});
});

View File

@@ -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
// =============================================================================

View File

@@ -28,6 +28,7 @@ function makeController(overrides: {
};
const retryPolicy: RetryPolicy = {
maxAttempts: 3,
backoffMs: [1000, 2000, 4000],
shouldRetry: vi.fn().mockReturnValue(false),
getRetryDelay: vi.fn().mockReturnValue(0),
};

View File

@@ -631,6 +631,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

View 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.`;
}

View File

@@ -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';

View File

@@ -11,7 +11,7 @@
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup, task dependency persistence |
| `file-tailer.ts` | `FileTailer` — watches output files, fires parser + raw content callbacks |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. Includes `writeErrandManifest()` for errand agent input files. |
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
| `index.ts` | Public exports, `ClaudeAgentManager` deprecated alias |
@@ -24,7 +24,7 @@
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
| `credentials/` | `AccountCredentialManager` — credential injection per account |
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution, errand) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
## Key Flows
@@ -115,6 +115,30 @@ cw account add --token <token> --email user@example.com
Stored as `credentials: {"claudeAiOauth":{"accessToken":"<token>"}}` and `configJson: {"hasCompletedOnboarding":true}`.
## Errand Agent Support
### `sendUserMessage(agentId, message)`
Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure.
**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling.
**Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt.
### `writeErrandManifest(options)`
Writes errand input files to `<agentWorkdir>/.cw/input/`:
- `errand.md` — YAML frontmatter with `id`, `description`, `branch`, `project`
- `manifest.json``{ errandId, agentId, agentName, mode: "errand" }` (no `files`/`contextFiles` arrays)
- `expected-pwd.txt` — the agent workdir path
Written in order: `errand.md` first, `manifest.json` last (same discipline as `writeInputFiles`).
### `buildErrandPrompt(description)`
Builds the initial prompt for errand agents. Exported from `prompts/errand.ts` and re-exported from `prompts/index.ts`. The prompt instructs the agent to make only the changes needed for the description and write `signal.json` when done.
## Auto-Resume for Conversations
When Agent A asks Agent B a question via `cw ask` and Agent B is idle, the conversation router automatically resumes Agent B's session. This mirrors the `resumeForCommit()` pattern.