Merge branch 'main' into cw/agent-details-conflict-1772802863659

# Conflicts:
#	docs/server-api.md
This commit is contained in:
Lukas May
2026-03-06 14:15:30 +01:00
32 changed files with 956 additions and 273 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

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

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,

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

@@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager {
cleanupManager: this.cleanupManager,
accountRepository,
debug,
eventBus,
});
// Listen for process crashed events to handle agents specially
@@ -282,14 +283,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);
}
}
}
@@ -607,6 +609,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(),
@@ -629,6 +632,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
@@ -796,6 +866,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(),

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

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

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

@@ -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;
}
/**

View File

@@ -202,6 +202,17 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
return candidates[0] ?? null;
}),
getTaskAgent: publicProcedure
.input(z.object({ taskId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
const agentManager = requireAgentManager(ctx);
const all = await agentManager.list();
const matches = all
.filter(a => a.taskId === input.taskId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return matches[0] ?? null;
}),
getActiveConflictAgent: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
@@ -225,12 +236,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
getAgentOutput: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<string> => {
.query(async ({ ctx, input }) => {
const agent = await resolveAgent(ctx, input);
const logChunkRepo = requireLogChunkRepository(ctx);
const chunks = await logChunkRepo.findByAgentId(agent.id);
return chunks.map(c => c.content).join('');
return chunks.map(c => ({
content: c.content,
createdAt: c.createdAt.toISOString(),
}));
}),
onAgentOutput: publicProcedure

View File

@@ -9,6 +9,7 @@ export interface ActiveArchitectAgent {
initiativeId: string;
mode: string;
status: string;
name?: string;
}
const MODE_TO_STATE: Record<string, InitiativeActivityState> = {
@@ -30,6 +31,18 @@ export function deriveInitiativeActivity(
if (initiative.status === 'archived') {
return { ...base, state: 'archived' };
}
// Check for active conflict resolution agent — takes priority over pending_review
// because the agent is actively working to resolve merge conflicts
const conflictAgent = activeArchitectAgents?.find(
a => a.initiativeId === initiative.id
&& a.name?.startsWith('conflict-')
&& (a.status === 'running' || a.status === 'waiting_for_input'),
);
if (conflictAgent) {
return { ...base, state: 'resolving_conflict' };
}
if (initiative.status === 'pending_review') {
return { ...base, state: 'pending_review' };
}
@@ -41,6 +54,7 @@ export function deriveInitiativeActivity(
// so architect agents (discuss/plan/detail/refine) surface activity
const activeAgent = activeArchitectAgents?.find(
a => a.initiativeId === initiative.id
&& !a.name?.startsWith('conflict-')
&& (a.status === 'running' || a.status === 'waiting_for_input'),
);
if (activeAgent) {

View File

@@ -129,27 +129,42 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
: await repo.findAll();
}
// Fetch active architect agents once for all initiatives
// Fetch active agents once for all initiatives (architect + conflict)
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
const activeArchitectAgents = allAgents
.filter(a =>
ARCHITECT_MODES.includes(a.mode ?? '')
(ARCHITECT_MODES.includes(a.mode ?? '') || a.name?.startsWith('conflict-'))
&& (a.status === 'running' || a.status === 'waiting_for_input')
&& !a.userDismissedAt,
)
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status }));
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name }));
// Batch-fetch projects for all initiatives
const projectRepo = ctx.projectRepository;
const projectsByInitiativeId = new Map<string, Array<{ id: string; name: string }>>();
if (projectRepo) {
await Promise.all(initiatives.map(async (init) => {
const projects = await projectRepo.findProjectsByInitiativeId(init.id);
projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name })));
}));
}
const addProjects = (init: typeof initiatives[0]) => ({
projects: projectsByInitiativeId.get(init.id) ?? [],
});
if (ctx.phaseRepository) {
const phaseRepo = ctx.phaseRepository;
return Promise.all(initiatives.map(async (init) => {
const phases = await phaseRepo.findByInitiativeId(init.id);
return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
}));
}
return initiatives.map(init => ({
...init,
...addProjects(init),
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
}));
}),
@@ -473,6 +488,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
initiativeId: input.initiativeId,
baseBranch: initiative.branch,
branchName: tempBranch,
skipPromptExtras: true,
});
}),
};

View File

@@ -70,6 +70,7 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
'chat:session_closed',
'initiative:pending_review',
'initiative:review_approved',
'initiative:changes_requested',
];
/**
@@ -102,6 +103,7 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
'phase:merged',
'initiative:pending_review',
'initiative:review_approved',
'initiative:changes_requested',
];
/**