chore: merge main into cw/small-change-flow

Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
This commit is contained in:
Lukas May
2026-03-06 16:48:12 +01:00
parent da3218b530
commit 28521e1c20
100 changed files with 9054 additions and 973 deletions

View File

@@ -481,36 +481,4 @@ describe('buildErrandPrompt', () => {
const result = buildErrandPrompt('some change');
expect(result).toContain('"status": "error"');
});
it('includes instructions for asking questions', () => {
const result = buildErrandPrompt('some change');
expect(result).toMatch(/ask|question/i);
expect(result).toMatch(/chat|message|reply/i);
});
it('includes questions signal format for session-ending questions', () => {
const result = buildErrandPrompt('some change');
expect(result).toContain('"status": "questions"');
expect(result).toContain('"questions"');
});
it('explains session ends and resumes with user answers', () => {
const result = buildErrandPrompt('some change');
expect(result).toMatch(/resume|end.*session|session.*end/i);
});
it('does not present inline asking as an alternative that bypasses signal.json', () => {
const result = buildErrandPrompt('some change');
// "session stays open" implied agents can skip signal.json — all exits must write it
expect(result).not.toMatch(/session stays open/i);
expect(result).not.toMatch(/Option A/i);
});
it('requires signal.json for all question-asking paths', () => {
const result = buildErrandPrompt('some change');
// questions status must be the mechanism for all user-input requests
expect(result).toContain('"status": "questions"');
// must not describe a path that skips signal.json
expect(result).not.toMatch(/session stays open/i);
});
});

View File

@@ -18,6 +18,7 @@ export interface AgentInfo {
status: string;
initiativeId?: string | null;
worktreeId: string;
exitCode?: number | null;
}
export interface CleanupStrategy {

View File

@@ -50,6 +50,7 @@ function makeController(overrides: {
cleanupStrategy,
overrides.accountRepository as AccountRepository | undefined,
false,
overrides.eventBus,
);
}

View File

@@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js';
import { AgentExhaustedError, AgentFailureError } from './retry-policy.js';
import type { AgentErrorAnalyzer } from './error-analyzer.js';
import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js';
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
const log = createModuleLogger('lifecycle-controller');
@@ -48,6 +49,7 @@ export class AgentLifecycleController {
private cleanupStrategy: CleanupStrategy,
private accountRepository?: AccountRepository,
private debug: boolean = false,
private eventBus?: EventBus,
) {}
/**
@@ -304,7 +306,7 @@ export class AgentLifecycleController {
}
/**
* Handle account exhaustion by marking account as exhausted.
* Handle account exhaustion by marking account as exhausted and emitting account_switched event.
*/
private async handleAccountExhaustion(agentId: string): Promise<void> {
if (!this.accountRepository) {
@@ -319,15 +321,34 @@ export class AgentLifecycleController {
return;
}
const previousAccountId = agent.accountId;
// Mark account as exhausted for 1 hour
const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000);
await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil);
await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil);
log.info({
agentId,
accountId: agent.accountId,
accountId: previousAccountId,
exhaustedUntil
}, 'marked account as exhausted due to usage limits');
// Find the next available account and emit account_switched event
const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude');
if (newAccount && this.eventBus) {
const event: AgentAccountSwitchedEvent = {
type: 'agent:account_switched',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
previousAccountId,
newAccountId: newAccount.id,
reason: 'account_exhausted',
},
};
this.eventBus.emit(event);
}
} catch (error) {
log.warn({
agentId,
@@ -353,6 +374,7 @@ export class AgentLifecycleController {
status: agent.status,
initiativeId: agent.initiativeId,
worktreeId: agent.worktreeId,
exitCode: agent.exitCode ?? null,
};
}
}

View File

@@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js'
import type { AccountRepository } from '../../db/repositories/account-repository.js';
import type { ProcessManager } from '../process-manager.js';
import type { CleanupManager } from '../cleanup-manager.js';
import type { EventBus } from '../../events/types.js';
export interface LifecycleFactoryOptions {
repository: AgentRepository;
@@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions {
cleanupManager: CleanupManager;
accountRepository?: AccountRepository;
debug?: boolean;
eventBus?: EventBus;
}
/**
@@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
processManager,
cleanupManager,
accountRepository,
debug = false
debug = false,
eventBus,
} = options;
// Create core components
@@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
cleanupManager,
cleanupStrategy,
accountRepository,
debug
debug,
eventBus,
);
return lifecycleController;

View File

@@ -463,10 +463,10 @@ describe('MultiProviderAgentManager', () => {
});
describe('sendUserMessage', () => {
it('resumes errand agent in waiting_for_input status', async () => {
it('resumes errand agent in idle status', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'waiting_for_input',
status: 'idle',
});
const mockChild = createMockChildProcess();

View File

@@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager {
cleanupManager: this.cleanupManager,
accountRepository,
debug,
eventBus,
});
// Listen for process crashed events to handle agents specially
@@ -238,8 +239,18 @@ export class MultiProviderAgentManager implements AgentManager {
log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees');
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch, branchName);
// Log projects linked to the initiative
// Verify each project worktree subdirectory actually exists
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
for (const project of projects) {
const projectWorktreePath = join(agentCwd, project.name);
if (!existsSync(projectWorktreePath)) {
throw new Error(
`Worktree subdirectory missing after createProjectWorktrees: ${projectWorktreePath}. ` +
`Agent ${alias} cannot run without an isolated worktree.`
);
}
}
log.info({
alias,
initiativeId,
@@ -254,11 +265,12 @@ export class MultiProviderAgentManager implements AgentManager {
}
// Verify the final agentCwd exists
const cwdVerified = existsSync(agentCwd);
if (!existsSync(agentCwd)) {
throw new Error(`Agent workdir does not exist after creation: ${agentCwd}`);
}
log.info({
alias,
agentCwd,
cwdVerified,
initiativeBasedAgent: !!initiativeId
}, 'agent workdir setup completed');
@@ -282,14 +294,15 @@ export class MultiProviderAgentManager implements AgentManager {
});
const agentId = agent.id;
// 3a. Append inter-agent communication instructions with actual agent ID
prompt = prompt + buildInterAgentCommunication(agentId, mode);
// 3a. Append inter-agent communication + preview instructions (skipped for focused agents)
if (!options.skipPromptExtras) {
prompt = prompt + buildInterAgentCommunication(agentId, mode);
// 3b. Append preview deployment instructions if applicable
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
if (shouldInject) {
prompt = prompt + buildPreviewInstructions(agentId);
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
if (shouldInject) {
prompt = prompt + buildPreviewInstructions(agentId);
}
}
}
@@ -297,6 +310,10 @@ export class MultiProviderAgentManager implements AgentManager {
if (options.inputContext) {
await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
log.debug({ alias }, 'input files written');
} else {
// Always create .cw/output/ at the agent workdir root so the agent
// writes signal.json here rather than in a project subdirectory.
await mkdir(join(agentCwd, '.cw', 'output'), { recursive: true });
}
// 4. Build spawn command
@@ -330,7 +347,7 @@ export class MultiProviderAgentManager implements AgentManager {
this.createLogChunkCallback(agentId, alias, 1),
);
await this.repository.update(agentId, { pid, outputFilePath });
await this.repository.update(agentId, { pid, outputFilePath, prompt });
// Register agent and start polling BEFORE non-critical I/O so that a
// diagnostic-write failure can never orphan a running process.
@@ -603,6 +620,7 @@ export class MultiProviderAgentManager implements AgentManager {
this.activeAgents.set(agentId, activeEntry);
if (this.eventBus) {
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
const event: AgentResumedEvent = {
type: 'agent:resumed',
timestamp: new Date(),
@@ -634,7 +652,7 @@ export class MultiProviderAgentManager implements AgentManager {
const agent = await this.repository.findById(agentId);
if (!agent) throw new Error(`Agent not found: ${agentId}`);
if (agent.status !== 'running' && agent.status !== 'idle' && agent.status !== 'waiting_for_input') {
if (agent.status !== 'running' && agent.status !== 'idle') {
throw new Error(`Agent is not running (status: ${agent.status})`);
}
@@ -859,6 +877,7 @@ export class MultiProviderAgentManager implements AgentManager {
log.info({ agentId, pid }, 'resume detached subprocess started');
if (this.eventBus) {
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
const event: AgentResumedEvent = {
type: 'agent:resumed',
timestamp: new Date(),
@@ -1163,6 +1182,8 @@ export class MultiProviderAgentManager implements AgentManager {
createdAt: Date;
updatedAt: Date;
userDismissedAt?: Date | null;
exitCode?: number | null;
prompt?: string | null;
}): AgentInfo {
return {
id: agent.id,
@@ -1178,6 +1199,8 @@ export class MultiProviderAgentManager implements AgentManager {
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
userDismissedAt: agent.userDismissedAt,
exitCode: agent.exitCode ?? null,
prompt: agent.prompt ?? null,
};
}
}

View File

@@ -163,6 +163,8 @@ export class MockAgentManager implements AgentManager {
accountId: null,
createdAt: now,
updatedAt: now,
exitCode: null,
prompt: null,
};
const record: MockAgentRecord = {

View File

@@ -5,9 +5,7 @@
import {
SIGNAL_FORMAT,
SESSION_STARTUP,
GIT_WORKFLOW,
CONTEXT_MANAGEMENT,
} from './shared.js';
export function buildConflictResolutionPrompt(
@@ -29,7 +27,12 @@ You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` in
${conflictList}
</conflict_details>
${SIGNAL_FORMAT}
${SESSION_STARTUP}
<session_startup>
1. \`pwd\` — confirm working directory
2. \`git status\` — check branch state
3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions you must follow.
</session_startup>
<resolution_protocol>
Follow these steps in order:
@@ -57,7 +60,6 @@ Follow these steps in order:
8. **Signal done**: Write signal.json with status "done".
</resolution_protocol>
${GIT_WORKFLOW}
${CONTEXT_MANAGEMENT}
<important>
- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around.

View File

@@ -4,21 +4,6 @@ export function buildErrandPrompt(description: string): string {
Description: ${description}
Work interactively with the user. Make only the changes needed to fulfill the description.
## Asking questions
If you need clarification before or during the change, write .cw/output/signal.json with the questions format and end your session:
{ "status": "questions", "questions": [{ "id": "q1", "question": "What is the target file?" }] }
The session will end. The user will be shown your questions in the UI or via:
cw errand chat <id> "<their answer>"
Your session will then resume with their answers. Be explicit about what you need — don't make assumptions when the task is ambiguous.
## Finishing
When you are done, write .cw/output/signal.json:
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }

View File

@@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar
</examples>
</specificity>
<subagent_usage>
Use subagents to parallelize your analysis — don't do everything sequentially:
- **Domain decomposition**: Spawn separate subagents to investigate different aspects of the initiative (e.g., one for database/schema concerns, one for API surface, one for frontend components) and synthesize their findings into your phase plan.
- **Dependency mapping**: Spawn a subagent to map existing code dependencies and file ownership while you analyze initiative requirements, so you can make informed decisions about phase boundaries and parallelism.
- **Pattern discovery**: When the initiative touches multiple subsystems, spawn subagents to search for existing patterns in each subsystem simultaneously rather than exploring them one at a time.
Don't spawn subagents for trivial initiatives with obvious structure — use judgment.
</subagent_usage>
<existing_context>
- Account for existing phases/tasks — don't plan work already covered
- Always generate new phase IDs — never reuse existing ones

View File

@@ -33,6 +33,15 @@ Ignore style, grammar, formatting unless they cause genuine ambiguity. Rough but
If all pages are already clear, signal done with no output files.
</improvement_priorities>
<subagent_usage>
Use subagents to parallelize your work:
- **Parallel page analysis**: Spawn one subagent per page (or group of related pages) to analyze clarity issues simultaneously rather than reviewing pages sequentially.
- **Codebase verification**: When checking whether a requirement is feasible or matches existing patterns, spawn a subagent to search the codebase while you continue reviewing other pages.
- **Cross-reference validation**: Spawn a subagent to verify that all [[page:$id|title]] cross-references are valid and consistent across pages.
Don't over-split — if there are only 1-2 short pages, just do the work directly.
</subagent_usage>
<rules>
- Ask 2-4 questions if you need clarification
- Preserve [[page:\$id|title]] cross-references

View File

@@ -36,5 +36,7 @@ This is an isolated git worktree. Other agents may be working in parallel on sep
The following project directories contain the source code (git worktrees):
${lines.join('\n')}
**IMPORTANT**: All \`.cw/output/\` paths (signal.json, progress.md, etc.) are relative to this working directory (\`${agentCwd}\`), NOT to any project subdirectory. Always write to \`${join(agentCwd, '.cw/output/')}\` regardless of your current \`cd\` location.
</workspace>`;
}

View File

@@ -61,6 +61,8 @@ export interface SpawnAgentOptions {
branchName?: string;
/** Context data to write as input files in agent workdir */
inputContext?: AgentInputContext;
/** Skip inter-agent communication and preview instructions (for focused agents like conflict resolution) */
skipPromptExtras?: boolean;
}
/**
@@ -93,6 +95,10 @@ export interface AgentInfo {
updatedAt: Date;
/** When the user dismissed this agent (null if not dismissed) */
userDismissedAt?: Date | null;
/** Process exit code — null while running or if not yet exited */
exitCode: number | null;
/** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */
prompt: string | null;
}
/**