From 243f24a39789614e78ab12fe470d1a55bc3aea1e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:46:39 +0100 Subject: [PATCH 01/13] fix: Eliminate content page flickering from layout shifts and double invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reserve fixed height for "Saving..." indicator instead of conditionally rendering it, preventing layout shift on every auto-save cycle - Remove getPage from updatePage mutation cache invalidation — useAutoSave already handles optimistic updates, and SSE events cover external changes. This eliminates double-invalidation (mutation + SSE) refetch storms. - Memoize TiptapEditor extensions array to avoid recreating extensions and pageLinkDeletionDetector on every render - Memoize useLiveUpdates rules array in initiative detail page --- apps/web/src/components/editor/ContentTab.tsx | 8 +-- .../src/components/editor/TiptapEditor.tsx | 56 +++++++++---------- apps/web/src/lib/invalidation.ts | 6 +- apps/web/src/routes/initiatives/$id.tsx | 9 ++- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/editor/ContentTab.tsx b/apps/web/src/components/editor/ContentTab.tsx index dcb8ff5..71de63b 100644 --- a/apps/web/src/components/editor/ContentTab.tsx +++ b/apps/web/src/components/editor/ContentTab.tsx @@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { {resolvedActivePageId && ( <> - {(isSaving || updateInitiativeMutation.isPending) && ( -
+
+ {(isSaving || updateInitiativeMutation.isPending) && ( Saving... -
- )} + )} +
{activePageQuery.isSuccess && ( { - if (node.type.name === 'heading') { - return `Heading ${node.attrs.level}`; - } - return "Type '/' for commands..."; - }, - }), - Link.configure({ - openOnClick: false, - }), - SlashCommands, - BlockSelectionExtension, - ]; - - const extensions = enablePageLinks - ? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector] - : baseExtensions; + const extensions = useMemo(() => { + const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef); + const base = [ + StarterKit, + Table.configure({ resizable: true, cellMinWidth: 50 }), + TableRow, + TableCell, + TableHeader, + Placeholder.configure({ + includeChildren: true, + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Type '/' for commands..."; + }, + }), + Link.configure({ + openOnClick: false, + }), + SlashCommands, + BlockSelectionExtension, + ]; + return enablePageLinks + ? [...base, PageLinkExtension, detector] + : base; + }, [enablePageLinks]); const editor = useEditor( { diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index 3cd6e1f..eb5b517 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -71,7 +71,11 @@ const INVALIDATION_MAP: Partial> = { revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"], // --- Pages --- - updatePage: ["listPages", "getPage", "getRootPage"], + // NOTE: getPage omitted — useAutoSave handles optimistic updates for the + // active page, and SSE `page:updated` events cover external changes. + // Including getPage here caused double-invalidation (mutation + SSE) and + // refetch storms that flickered the editor. + updatePage: ["listPages", "getRootPage"], createPage: ["listPages", "getRootPage"], deletePage: ["listPages", "getRootPage"], diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index 678100c..f56dbed 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AlertCircle } from "lucide-react"; @@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab"; import { ReviewTab } from "@/components/review"; import { PipelineTab } from "@/components/pipeline"; import { useLiveUpdates } from "@/hooks"; +import type { LiveUpdateRule } from "@/hooks/useLiveUpdates"; type Tab = "content" | "plan" | "execution" | "review"; const TABS: Tab[] = ["content", "plan", "execution", "review"]; @@ -27,15 +29,16 @@ function InitiativeDetailPage() { const { tab: activeTab } = Route.useSearch(); const navigate = useNavigate(); - // Single SSE stream for all live updates - useLiveUpdates([ + // Single SSE stream for all live updates — memoized to avoid re-subscribe on render + const liveUpdateRules = useMemo(() => [ { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] }, { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] }, { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, { prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] }, { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, - ]); + ], []); + useLiveUpdates(liveUpdateRules); // tRPC queries const initiativeQuery = trpc.getInitiative.useQuery({ id }); From b6218584eef9d76d40fc4d8735db8150d2e49150 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:06:33 +0100 Subject: [PATCH 02/13] feat: Show project pills on initiative cards in list view Add projects to the listInitiatives tRPC response and render them as outline badge pills between the initiative name and activity row. --- apps/server/trpc/routers/initiative.ts | 17 ++++++++++++++++- apps/web/src/components/InitiativeCard.tsx | 13 +++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 6b48b77..e28048b 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -140,16 +140,31 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { ) .map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status })); + // Batch-fetch projects for all initiatives + const projectRepo = ctx.projectRepository; + const projectsByInitiativeId = new Map>(); + 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), })); }), diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index a4f7e18..602892d 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -1,6 +1,7 @@ import { MoreHorizontal } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -20,6 +21,7 @@ export interface SerializedInitiative { branch: string | null; createdAt: string; updatedAt: string; + projects?: Array<{ id: string; name: string }>; activity: { state: string; activePhase?: { id: string; name: string }; @@ -113,6 +115,17 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) { + {/* Project pills */} + {initiative.projects && initiative.projects.length > 0 && ( +
+ {initiative.projects.map((p) => ( + + {p.name} + + ))} +
+ )} + {/* Row 2: Activity dot + label + active phase + progress */}
Date: Fri, 6 Mar 2026 13:07:25 +0100 Subject: [PATCH 03/13] fix: Move project pills inline after initiative name --- apps/web/src/components/InitiativeCard.tsx | 29 ++++++++++------------ 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index 602892d..6ab41ee 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -89,11 +89,19 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) { className="p-4" onClick={onClick} > - {/* Row 1: Name + overflow menu */} -
- - {initiative.name} - + {/* Row 1: Name + project pills + overflow menu */} +
+
+ + {initiative.name} + + {initiative.projects && initiative.projects.length > 0 && + initiative.projects.map((p) => ( + + {p.name} + + ))} +
e.stopPropagation()}> @@ -115,17 +123,6 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
- {/* Project pills */} - {initiative.projects && initiative.projects.length > 0 && ( -
- {initiative.projects.map((p) => ( - - {p.name} - - ))} -
- )} - {/* Row 2: Activity dot + label + active phase + progress */}
Date: Fri, 6 Mar 2026 13:10:46 +0100 Subject: [PATCH 04/13] feat: Add Agent Logs tab to task slide-over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add getTaskAgent tRPC procedure to find the most recent agent for a task. TaskSlideOver now has Details/Agent Logs tabs — logs tab renders AgentOutputViewer when an agent exists, or an empty state otherwise. --- apps/server/trpc/routers/agent.ts | 11 + .../components/execution/TaskSlideOver.tsx | 216 ++++++++++++------ 2 files changed, 156 insertions(+), 71 deletions(-) diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index a0c3660..b547b80 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -184,6 +184,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 => { + 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 => { diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index ff9a4d3..0885782 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useMemo } from "react"; +import { useCallback, useEffect, useRef, useMemo, useState } from "react"; import { motion, AnimatePresence } from "motion/react"; import { X, Trash2, MessageCircle, RotateCw } from "lucide-react"; import type { ChatTarget } from "@/components/chat/ChatSlideOver"; @@ -7,12 +7,15 @@ import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/StatusBadge"; import { StatusDot } from "@/components/StatusDot"; import { TiptapEditor } from "@/components/editor/TiptapEditor"; +import { AgentOutputViewer } from "@/components/AgentOutputViewer"; import { getCategoryConfig } from "@/lib/category"; import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap"; import { useExecutionContext } from "./ExecutionContext"; import { trpc } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +type SlideOverTab = "details" | "logs"; + interface TaskSlideOverProps { onOpenChat?: (target: ChatTarget) => void; } @@ -24,8 +27,15 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { const deleteTaskMutation = trpc.deleteTask.useMutation(); const updateTaskMutation = trpc.updateTask.useMutation(); + const [tab, setTab] = useState("details"); + const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]); + // Reset tab when task changes + useEffect(() => { + setTab("details"); + }, [selectedEntry?.task?.id]); + // Escape key closes useEffect(() => { if (!selectedEntry) return; @@ -152,80 +162,107 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
+ {/* Tab bar */} +
+ {(["details", "logs"] as const).map((t) => ( + + ))} +
+ {/* Content */} -
- {/* Metadata grid */} -
- - - - - - - - - - - {task.type} - - - - {selectedEntry.agentName ?? "Unassigned"} - - -
+
+ {tab === "details" ? ( +
+ {/* Metadata grid */} +
+ + + + + + + + + + + {task.type} + + + + {selectedEntry.agentName ?? "Unassigned"} + + +
- {/* Description — editable tiptap */} -
- -
+ {/* Description — editable tiptap */} +
+ +
- {/* Dependencies */} -
- {dependencies.length === 0 ? ( -

None

- ) : ( -
    - {dependencies.map((dep) => ( -
  • - - - {dep.name} - -
  • - ))} -
- )} -
+ {/* Dependencies */} +
+ {dependencies.length === 0 ? ( +

None

+ ) : ( +
    + {dependencies.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
- {/* Blocks */} -
- {dependents.length === 0 ? ( -

None

- ) : ( -
    - {dependents.map((dep) => ( -
  • - - - {dep.name} - -
  • - ))} -
- )} -
+ {/* Blocks */} +
+ {dependents.length === 0 ? ( +

None

+ ) : ( +
    + {dependents.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
+
+ ) : ( + + )}
{/* Footer */} @@ -293,6 +330,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { ); } +// --------------------------------------------------------------------------- +// Agent Logs Tab +// --------------------------------------------------------------------------- + +function AgentLogsTab({ taskId }: { taskId: string }) { + const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( + { taskId }, + { refetchOnWindowFocus: false }, + ); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (!agent) { + return ( +
+ No agent has been assigned to this task yet. +
+ ); + } + + return ( +
+ +
+ ); +} + // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- From 3e2a57044735a052509265f873865f6f9c1d5837 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:12:25 +0100 Subject: [PATCH 05/13] feat: Emit account_switched event on account exhaustion in lifecycle controller Passes EventBus through LifecycleFactory and AgentLifecycleController so that when an account is marked exhausted, an agent:account_switched event is emitted with the previous and new account IDs. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/lifecycle/controller.ts | 27 ++++++++++++++++++++--- apps/server/agent/lifecycle/factory.ts | 8 +++++-- apps/server/agent/manager.ts | 3 +++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/server/agent/lifecycle/controller.ts b/apps/server/agent/lifecycle/controller.ts index 833634d..537542b 100644 --- a/apps/server/agent/lifecycle/controller.ts +++ b/apps/server/agent/lifecycle/controller.ts @@ -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 { 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, diff --git a/apps/server/agent/lifecycle/factory.ts b/apps/server/agent/lifecycle/factory.ts index 4bff87b..51c502a 100644 --- a/apps/server/agent/lifecycle/factory.ts +++ b/apps/server/agent/lifecycle/factory.ts @@ -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; diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index ac36b83..ce367d7 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager { cleanupManager: this.cleanupManager, accountRepository, debug, + eventBus, }); // Listen for process crashed events to handle agents specially @@ -607,6 +608,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(), @@ -796,6 +798,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(), From 4ee71d45f42a465ebd42ded0eab4175ab1c0a9a1 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:17:32 +0100 Subject: [PATCH 06/13] test: Add regression tests for agent:account_switched emission in lifecycle controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds controller.test.ts with three test cases asserting that handleAccountExhaustion correctly emits agent:account_switched with previousAccountId, newAccountId, and reason fields — and that the emit is skipped when no new account is available or the agent has no accountId. Co-Authored-By: Claude Sonnet 4.6 --- .../server/agent/lifecycle/controller.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 apps/server/agent/lifecycle/controller.test.ts diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts new file mode 100644 index 0000000..de7462b --- /dev/null +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -0,0 +1,154 @@ +/** + * 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; + accountRepository?: Partial; + 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, + 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 = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + 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 = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + 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 = { + findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }), + }; + const accountRepository: Partial = { + markExhausted: vi.fn(), + findNextAvailable: vi.fn(), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-3'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + }); +}); From d52317ac5de860b82879195751977c1277cccc36 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:18:42 +0100 Subject: [PATCH 07/13] feat: Add timestamps to agent logs and fix horizontal scroll - getAgentOutput now returns timestamped chunks ({ content, createdAt }[]) instead of a flat string, preserving DB chunk timestamps - parseAgentOutput accepts TimestampedChunk[] and propagates timestamps to each ParsedMessage - AgentOutputViewer displays HH:MM:SS timestamps on text, tool_call, system, and session_end messages - Live subscription chunks get client-side Date.now() timestamps - Fix horizontal scroll: overflow-x-hidden + break-words on content areas - AgentLogsTab polls getTaskAgent every 5s until an agent is found, then stops polling for live subscription to take over - Fix slide-over layout: details tab scrolls independently, logs tab fills remaining flex space for proper AgentOutputViewer sizing --- apps/server/trpc/routers/agent.ts | 7 +- apps/web/src/components/AgentOutputViewer.tsx | 62 ++++-- .../components/execution/TaskSlideOver.tsx | 6 +- apps/web/src/lib/parse-agent-output.ts | 200 ++++++++++-------- docs/agent.md | 7 +- docs/server-api.md | 3 +- 6 files changed, 170 insertions(+), 115 deletions(-) diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index b547b80..d2f1461 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -218,12 +218,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { getAgentOutput: publicProcedure .input(agentIdentifierSchema) - .query(async ({ ctx, input }): Promise => { + .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 diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 3eaaeb3..48663ed 100644 --- a/apps/web/src/components/AgentOutputViewer.tsx +++ b/apps/web/src/components/AgentOutputViewer.tsx @@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; import { type ParsedMessage, + type TimestampedChunk, getMessageStyling, parseAgentOutput, } from "@/lib/parse-agent-output"; @@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO const [messages, setMessages] = useState([]); const [follow, setFollow] = useState(true); const containerRef = useRef(null); - // Accumulate raw JSONL: initial query data + live subscription chunks - const rawBufferRef = useRef(''); + // Accumulate timestamped chunks: initial query data + live subscription chunks + const chunksRef = useRef([]); // Load initial/historical output const outputQuery = trpc.getAgentOutput.useQuery( @@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // TrackedEnvelope shape: { id, data: { agentId, data: string } } const raw = event?.data?.data ?? event?.data; const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw); - rawBufferRef.current += chunk; - setMessages(parseAgentOutput(rawBufferRef.current)); + chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }]; + setMessages(parseAgentOutput(chunksRef.current)); }, onError: (error) => { console.error('Agent output subscription error:', error); @@ -54,14 +55,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // Set initial output when query loads useEffect(() => { if (outputQuery.data) { - rawBufferRef.current = outputQuery.data; + chunksRef.current = outputQuery.data; setMessages(parseAgentOutput(outputQuery.data)); } }, [outputQuery.data]); // Reset output when agent changes useEffect(() => { - rawBufferRef.current = ''; + chunksRef.current = []; setMessages([]); setFollow(true); }, [agentId]); @@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{isLoading ? (
Loading output...
) : !hasOutput ? (
No output yet...
) : ( -
+
{messages.map((message, index) => (
{message.type === 'system' && (
System {message.content} +
)} {message.type === 'text' && ( -
- {message.content} -
+ <> + +
+ {message.content} +
+ )} {message.type === 'tool_call' && ( -
- - {message.meta?.toolName} - -
+
+
+ + {message.meta?.toolName} + + +
+
{message.content}
)} {message.type === 'tool_result' && ( -
+
Result -
+
{message.content}
)} {message.type === 'error' && ( -
+
Error -
+
{message.content}
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO {message.meta?.duration && ( {(message.meta.duration / 1000).toFixed(1)}s )} +
)} @@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
); } + +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); +} + +function Timestamp({ date }: { date?: Date }) { + if (!date) return null; + return ( + + {formatTime(date)} + + ); +} diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index 0885782..1df8f13 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -184,7 +184,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
{/* Content */} -
+
{tab === "details" ? (
{/* Metadata grid */} @@ -337,7 +337,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { function AgentLogsTab({ taskId }: { taskId: string }) { const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( { taskId }, - { refetchOnWindowFocus: false }, + { refetchOnWindowFocus: false, refetchInterval: (query) => query.state.data ? false : 5000 }, ); if (isLoading) { @@ -357,7 +357,7 @@ function AgentLogsTab({ taskId }: { taskId: string }) { } return ( -
+
({ content: c.content, timestamp: new Date(c.createdAt) })); + const parsedMessages: ParsedMessage[] = []; - for (const line of lines) { - try { - const event = JSON.parse(line); + for (const chunk of chunks) { + const lines = chunk.content.split("\n").filter(Boolean); + for (const line of lines) { + try { + const event = JSON.parse(line); - // System initialization - if (event.type === "system" && event.session_id) { - parsedMessages.push({ - type: "system", - content: `Session started: ${event.session_id}`, - }); - } - - // Assistant messages with text and tool calls - else if ( - event.type === "assistant" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "text" && block.text) { - parsedMessages.push({ - type: "text", - content: block.text, - }); - } else if (block.type === "tool_use") { - parsedMessages.push({ - type: "tool_call", - content: formatToolCall(block), - meta: { toolName: block.name }, - }); - } + // System initialization + if (event.type === "system" && event.session_id) { + parsedMessages.push({ + type: "system", + content: `Session started: ${event.session_id}`, + timestamp: chunk.timestamp, + }); } - } - // User messages with tool results - else if ( - event.type === "user" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "tool_result") { - const rawContent = block.content; - const output = - typeof rawContent === "string" - ? rawContent - : Array.isArray(rawContent) - ? rawContent - .map((c: any) => c.text ?? JSON.stringify(c)) - .join("\n") - : (event.tool_use_result?.stdout || ""); - const stderr = event.tool_use_result?.stderr; - - if (stderr) { + // Assistant messages with text and tool calls + else if ( + event.type === "assistant" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "text" && block.text) { parsedMessages.push({ - type: "error", - content: stderr, - meta: { isError: true }, + type: "text", + content: block.text, + timestamp: chunk.timestamp, }); - } else if (output) { - const displayOutput = - output.length > 1000 - ? output.substring(0, 1000) + "\n... (truncated)" - : output; + } else if (block.type === "tool_use") { parsedMessages.push({ - type: "tool_result", - content: displayOutput, + type: "tool_call", + content: formatToolCall(block), + timestamp: chunk.timestamp, + meta: { toolName: block.name }, }); } } } - } - // Legacy streaming format - else if (event.type === "stream_event" && event.event?.delta?.text) { + // User messages with tool results + else if ( + event.type === "user" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "tool_result") { + const rawContent = block.content; + const output = + typeof rawContent === "string" + ? rawContent + : Array.isArray(rawContent) + ? rawContent + .map((c: any) => c.text ?? JSON.stringify(c)) + .join("\n") + : (event.tool_use_result?.stdout || ""); + const stderr = event.tool_use_result?.stderr; + + if (stderr) { + parsedMessages.push({ + type: "error", + content: stderr, + timestamp: chunk.timestamp, + meta: { isError: true }, + }); + } else if (output) { + const displayOutput = + output.length > 1000 + ? output.substring(0, 1000) + "\n... (truncated)" + : output; + parsedMessages.push({ + type: "tool_result", + content: displayOutput, + timestamp: chunk.timestamp, + }); + } + } + } + } + + // Legacy streaming format + else if (event.type === "stream_event" && event.event?.delta?.text) { + parsedMessages.push({ + type: "text", + content: event.event.delta.text, + timestamp: chunk.timestamp, + }); + } + + // Session completion + else if (event.type === "result") { + parsedMessages.push({ + type: "session_end", + content: event.is_error ? "Session failed" : "Session completed", + timestamp: chunk.timestamp, + meta: { + isError: event.is_error, + cost: event.total_cost_usd, + duration: event.duration_ms, + }, + }); + } + } catch { + // Not JSON, display as-is parsedMessages.push({ - type: "text", - content: event.event.delta.text, + type: "error", + content: line, + timestamp: chunk.timestamp, + meta: { isError: true }, }); } - - // Session completion - else if (event.type === "result") { - parsedMessages.push({ - type: "session_end", - content: event.is_error ? "Session failed" : "Session completed", - meta: { - isError: event.is_error, - cost: event.total_cost_usd, - duration: event.duration_ms, - }, - }); - } - } catch { - // Not JSON, display as-is - parsedMessages.push({ - type: "error", - content: line, - meta: { isError: true }, - }); } } return parsedMessages; diff --git a/docs/agent.md b/docs/agent.md index 7083585..bd692b9 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -153,9 +153,10 @@ Agent output is persisted to `agent_log_chunks` table and drives all live stream - DB insert → `agent:output` event emission (single source of truth for UI) - No FK to agents — survives agent deletion - Session tracking: spawn=1, resume=previousMax+1 -- Read path (`getAgentOutput` tRPC): concatenates all DB chunks (no file fallback) -- Live path (`onAgentOutput` subscription): listens for `agent:output` events -- Frontend: initial query loads from DB, subscription accumulates raw JSONL, both parsed via `parseAgentOutput()` +- Read path (`getAgentOutput` tRPC): returns timestamped chunks `{ content, createdAt }[]` from DB +- Live path (`onAgentOutput` subscription): listens for `agent:output` events (client stamps with `Date.now()`) +- Frontend: initial query loads timestamped chunks, subscription accumulates live chunks, both parsed via `parseAgentOutput()` which accepts `TimestampedChunk[]` +- Timestamps displayed inline (HH:MM:SS) on text, tool_call, system, and session_end messages ## Inter-Agent Communication diff --git a/docs/server-api.md b/docs/server-api.md index ec11000..5921317 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -62,7 +62,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getAgent | query | Single agent by name or ID | | getAgentResult | query | Execution result | | getAgentQuestions | query | Pending questions | -| getAgentOutput | query | Full output from DB log chunks | +| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) | +| getTaskAgent | query | Most recent agent assigned to a task (by taskId) | | getActiveRefineAgent | query | Active refine agent for initiative | | getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | listWaitingAgents | query | Agents waiting for input | From 52e238924c93c9bf9e2b24ef168a2ead33f83088 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:22:15 +0100 Subject: [PATCH 08/13] 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 --- apps/server/agent/file-io.test.ts | 117 +++++++++++++++++- apps/server/agent/file-io.ts | 44 +++++++ .../server/agent/lifecycle/controller.test.ts | 1 + apps/server/agent/manager.ts | 67 ++++++++++ apps/server/agent/prompts/errand.ts | 16 +++ apps/server/agent/prompts/index.ts | 1 + docs/agent.md | 28 ++++- 7 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 apps/server/agent/prompts/errand.ts diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 396453f..ae0fb9a 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -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"'); + }); +}); diff --git a/apps/server/agent/file-io.ts b/apps/server/agent/file-io.ts index 84b9c3a..4bbc296 100644 --- a/apps/server/agent/file-io.ts +++ b/apps/server/agent/file-io.ts @@ -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 { + 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 // ============================================================================= diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts index de7462b..1ce41b9 100644 --- a/apps/server/agent/lifecycle/controller.test.ts +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -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), }; diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index ce367d7..066d51d 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -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 { + 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 diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts new file mode 100644 index 0000000..e94b950 --- /dev/null +++ b/apps/server/agent/prompts/errand.ts @@ -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": "" } } + +If you cannot complete the change: + +{ "status": "error", "error": "" } + +Do not create any other output files.`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 2186994..c7167db 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -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'; diff --git a/docs/agent.md b/docs/agent.md index bd692b9..752a527 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -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 --email user@example.com Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` 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 `/.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. From ebef093d3f0ed03e62c5b2a52444d878d8e1ef05 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:25:31 +0100 Subject: [PATCH 09/13] fix: Add missing event routing for initiative status real-time refresh 7 of 12 initiative activity state transitions were broken due to missing event routing at three layers: SSE event arrays, live-update prefix rules, and mutation invalidation map. - Add initiative:changes_requested to ALL_EVENT_TYPES and TASK_EVENT_TYPES - Add initiative:/agent: prefix rules to initiatives list and detail pages - Add approveInitiativeReview, requestInitiativeChanges, requestPhaseChanges to INVALIDATION_MAP; add listInitiatives to approvePhase - Extract INITIATIVE_LIST_RULES constant for reuse --- apps/server/trpc/subscriptions.ts | 2 ++ apps/web/src/hooks/index.ts | 3 ++- apps/web/src/hooks/useLiveUpdates.ts | 11 +++++++++++ apps/web/src/lib/invalidation.ts | 5 ++++- apps/web/src/routes/initiatives/$id.tsx | 3 ++- apps/web/src/routes/initiatives/index.tsx | 7 ++----- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/server/trpc/subscriptions.ts b/apps/server/trpc/subscriptions.ts index 027e055..b4102bd 100644 --- a/apps/server/trpc/subscriptions.ts +++ b/apps/server/trpc/subscriptions.ts @@ -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', ]; /** diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index 0211b7a..a33ef3c 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -7,7 +7,8 @@ export { useAutoSave } from './useAutoSave.js'; export { useDebounce, useDebounceWithImmediate } from './useDebounce.js'; -export { useLiveUpdates } from './useLiveUpdates.js'; +export { useLiveUpdates, INITIATIVE_LIST_RULES } from './useLiveUpdates.js'; +export type { LiveUpdateRule } from './useLiveUpdates.js'; export { useRefineAgent } from './useRefineAgent.js'; export { useConflictAgent } from './useConflictAgent.js'; export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js'; diff --git a/apps/web/src/hooks/useLiveUpdates.ts b/apps/web/src/hooks/useLiveUpdates.ts index 6179619..5ab36f1 100644 --- a/apps/web/src/hooks/useLiveUpdates.ts +++ b/apps/web/src/hooks/useLiveUpdates.ts @@ -15,6 +15,17 @@ export interface LiveUpdateRule { * * Encapsulates error toast + reconnect config so pages don't duplicate boilerplate. */ +/** + * Reusable rules for any page displaying initiative cards. + * Covers all event prefixes that can change derived initiative activity state. + */ +export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [ + { prefix: 'initiative:', invalidate: ['listInitiatives'] }, + { prefix: 'task:', invalidate: ['listInitiatives'] }, + { prefix: 'phase:', invalidate: ['listInitiatives'] }, + { prefix: 'agent:', invalidate: ['listInitiatives'] }, +]; + export function useLiveUpdates(rules: LiveUpdateRule[]) { const utils = trpc.useUtils(); diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index eb5b517..ae38d45 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -49,12 +49,15 @@ const INVALIDATION_MAP: Partial> = { createInitiative: ["listInitiatives"], updateInitiative: ["listInitiatives", "getInitiative"], updateInitiativeProjects: ["getInitiative"], + approveInitiativeReview: ["listInitiatives", "getInitiative"], + requestInitiativeChanges: ["listInitiatives", "getInitiative"], // --- Phases --- createPhase: ["listPhases", "listInitiativePhaseDependencies"], deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"], updatePhase: ["listPhases", "getPhase"], - approvePhase: ["listPhases", "listInitiativeTasks"], + approvePhase: ["listPhases", "listInitiativeTasks", "listInitiatives"], + requestPhaseChanges: ["listPhases", "listInitiativeTasks", "listPhaseTasks", "getInitiative"], queuePhase: ["listPhases"], createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"], removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"], diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index f56dbed..e62de70 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -12,7 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab"; import { ReviewTab } from "@/components/review"; import { PipelineTab } from "@/components/pipeline"; import { useLiveUpdates } from "@/hooks"; -import type { LiveUpdateRule } from "@/hooks/useLiveUpdates"; +import type { LiveUpdateRule } from "@/hooks"; type Tab = "content" | "plan" | "execution" | "review"; const TABS: Tab[] = ["content", "plan", "execution", "review"]; @@ -31,6 +31,7 @@ function InitiativeDetailPage() { // Single SSE stream for all live updates — memoized to avoid re-subscribe on render const liveUpdateRules = useMemo(() => [ + { prefix: 'initiative:', invalidate: ['getInitiative'] }, { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] }, { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] }, { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, diff --git a/apps/web/src/routes/initiatives/index.tsx b/apps/web/src/routes/initiatives/index.tsx index 5407dd1..140b7c3 100644 --- a/apps/web/src/routes/initiatives/index.tsx +++ b/apps/web/src/routes/initiatives/index.tsx @@ -5,7 +5,7 @@ import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { InitiativeList } from "@/components/InitiativeList"; import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog"; -import { useLiveUpdates } from "@/hooks"; +import { useLiveUpdates, INITIATIVE_LIST_RULES } from "@/hooks"; import { trpc } from "@/lib/trpc"; export const Route = createFileRoute("/initiatives/")({ @@ -29,10 +29,7 @@ function DashboardPage() { const projectsQuery = trpc.listProjects.useQuery(); // Single SSE stream for live updates - useLiveUpdates([ - { prefix: 'task:', invalidate: ['listInitiatives'] }, - { prefix: 'phase:', invalidate: ['listInitiatives'] }, - ]); + useLiveUpdates(INITIATIVE_LIST_RULES); return ( Date: Fri, 6 Mar 2026 13:30:18 +0100 Subject: [PATCH 10/13] fix: Replace getTaskAgent polling with event-driven invalidation Add getTaskAgent to the agent: prefix SSE invalidation rule so spawned agents are picked up immediately instead of polling every 5s. --- apps/web/src/components/execution/TaskSlideOver.tsx | 2 +- apps/web/src/routes/initiatives/$id.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index 1df8f13..5b1b261 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -337,7 +337,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { function AgentLogsTab({ taskId }: { taskId: string }) { const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( { taskId }, - { refetchOnWindowFocus: false, refetchInterval: (query) => query.state.data ? false : 5000 }, + { refetchOnWindowFocus: false }, ); if (isLoading) { diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index e62de70..993c1bb 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -34,7 +34,7 @@ function InitiativeDetailPage() { { prefix: 'initiative:', invalidate: ['getInitiative'] }, { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] }, { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] }, - { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, + { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent', 'getTaskAgent', 'getActiveConflictAgent'] }, { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, { prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] }, { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, From e3246baf514ce54589c9c3df4e3710f9d9082365 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:32:37 +0100 Subject: [PATCH 11/13] feat: Show resolving_conflict activity state on initiative cards Add 'resolving_conflict' to InitiativeActivityState and detect active conflict agents (name starts with conflict-) in deriveInitiativeActivity. Conflict resolution takes priority over pending_review since the agent is actively working. - Add resolving_conflict to shared types and activity derivation - Include conflict agents in listInitiatives agent filter (name + mode) - Map resolving_conflict to urgent variant with pulse in InitiativeCard - Add merge: prefix to INITIATIVE_LIST_RULES for merge event routing - Add spawnConflictResolutionAgent to INVALIDATION_MAP - Add getActiveConflictAgent to detail page agent: SSE invalidation --- .../trpc/routers/initiative-activity.ts | 14 +++++++++++ apps/server/trpc/routers/initiative.ts | 6 ++--- apps/web/src/components/InitiativeCard.tsx | 11 +++++---- apps/web/src/components/StatusDot.tsx | 4 ++++ apps/web/src/hooks/useLiveUpdates.ts | 1 + apps/web/src/lib/invalidation.ts | 1 + docs/frontend.md | 2 +- packages/shared/src/types.ts | 23 ++++++++++--------- 8 files changed, 42 insertions(+), 20 deletions(-) diff --git a/apps/server/trpc/routers/initiative-activity.ts b/apps/server/trpc/routers/initiative-activity.ts index fc16b35..8bdbea8 100644 --- a/apps/server/trpc/routers/initiative-activity.ts +++ b/apps/server/trpc/routers/initiative-activity.ts @@ -9,6 +9,7 @@ export interface ActiveArchitectAgent { initiativeId: string; mode: string; status: string; + name?: string; } const MODE_TO_STATE: Record = { @@ -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) { diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index e28048b..1c317df 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -129,16 +129,16 @@ 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; diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index 6ab41ee..5cf86ce 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -32,11 +32,12 @@ export interface SerializedInitiative { function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } { switch (state) { - case "executing": return { label: "Executing", variant: "active", pulse: true }; - case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true }; - case "discussing": return { label: "Discussing", variant: "active", pulse: true }; - case "detailing": return { label: "Detailing", variant: "active", pulse: true }; - case "refining": return { label: "Refining", variant: "active", pulse: true }; + case "executing": return { label: "Executing", variant: "active", pulse: true }; + case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true }; + case "discussing": return { label: "Discussing", variant: "active", pulse: true }; + case "detailing": return { label: "Detailing", variant: "active", pulse: true }; + case "refining": return { label: "Refining", variant: "active", pulse: true }; + case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", pulse: true }; case "ready": return { label: "Ready", variant: "active", pulse: false }; case "blocked": return { label: "Blocked", variant: "error", pulse: false }; case "complete": return { label: "Complete", variant: "success", pulse: false }; diff --git a/apps/web/src/components/StatusDot.tsx b/apps/web/src/components/StatusDot.tsx index f57b454..30e538b 100644 --- a/apps/web/src/components/StatusDot.tsx +++ b/apps/web/src/components/StatusDot.tsx @@ -45,6 +45,10 @@ export function mapEntityStatus(rawStatus: string): StatusVariant { case "medium": return "warning"; + // Urgent / conflict resolution + case "resolving_conflict": + return "urgent"; + // Error / failed case "crashed": case "blocked": diff --git a/apps/web/src/hooks/useLiveUpdates.ts b/apps/web/src/hooks/useLiveUpdates.ts index 5ab36f1..50908c7 100644 --- a/apps/web/src/hooks/useLiveUpdates.ts +++ b/apps/web/src/hooks/useLiveUpdates.ts @@ -24,6 +24,7 @@ export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [ { prefix: 'task:', invalidate: ['listInitiatives'] }, { prefix: 'phase:', invalidate: ['listInitiatives'] }, { prefix: 'agent:', invalidate: ['listInitiatives'] }, + { prefix: 'merge:', invalidate: ['listInitiatives'] }, ]; export function useLiveUpdates(rules: LiveUpdateRule[]) { diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index ae38d45..5c4538a 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -44,6 +44,7 @@ const INVALIDATION_MAP: Partial> = { spawnArchitectDiscuss: ["listAgents"], spawnArchitectPlan: ["listAgents"], spawnArchitectDetail: ["listAgents", "listInitiativeTasks"], + spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"], // --- Initiatives --- createInitiative: ["listInitiatives"], diff --git a/docs/frontend.md b/docs/frontend.md index 6488640..dec5250 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -198,4 +198,4 @@ Components: `ChatSlideOver`, `ChatBubble`, `ChatInput`, `ChangeSetInline` in `sr `listInitiatives` returns an `activity` field on each initiative, computed server-side from phase statuses via `deriveInitiativeActivity()` in `apps/server/trpc/routers/initiative-activity.ts`. This eliminates per-card N+1 `listPhases` queries. -Activity states (priority order): active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active architect agents (modes: discuss, plan, detail, refine) are checked first — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase. +Activity states (priority order): conflict agent > `archived` > active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active conflict agents (name starts with `conflict-`) are checked first — returning `resolving_conflict` (urgent variant, pulsing). Active architect agents (modes: discuss, plan, detail, refine) are checked next — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase. diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index db8ef20..06859b0 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,17 +4,18 @@ export type { PendingQuestions, QuestionItem } from '../../../apps/server/agent/ export type ExecutionMode = 'yolo' | 'review_per_phase'; export type InitiativeActivityState = - | 'idle' // Active but no phases and no agents - | 'discussing' // Discuss agent actively scoping the initiative - | 'planning' // All phases pending (no work started) - | 'detailing' // Detail/plan agent actively decomposing phases into tasks - | 'refining' // Refine agent actively working on content - | 'ready' // Phases approved, waiting to execute - | 'executing' // At least one phase in_progress - | 'pending_review' // At least one phase pending_review - | 'blocked' // At least one phase blocked (none in_progress/pending_review) - | 'complete' // All phases completed - | 'archived'; // Initiative archived + | 'idle' // Active but no phases and no agents + | 'discussing' // Discuss agent actively scoping the initiative + | 'planning' // All phases pending (no work started) + | 'detailing' // Detail/plan agent actively decomposing phases into tasks + | 'refining' // Refine agent actively working on content + | 'resolving_conflict' // Conflict resolution agent actively fixing merge conflicts + | 'ready' // Phases approved, waiting to execute + | 'executing' // At least one phase in_progress + | 'pending_review' // At least one phase pending_review + | 'blocked' // At least one phase blocked (none in_progress/pending_review) + | 'complete' // All phases completed + | 'archived'; // Initiative archived export interface InitiativeActivity { state: InitiativeActivityState; From a0574a1ae9271e9a709276846b1c86d53720d027 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:39:19 +0100 Subject: [PATCH 12/13] feat: Add subagent usage guidance to refine and plan prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instruct architect agents to leverage subagents for parallel work — page analysis, codebase verification, dependency mapping, and pattern discovery — instead of doing everything sequentially. --- apps/server/agent/prompts/plan.ts | 9 +++++++++ apps/server/agent/prompts/refine.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/server/agent/prompts/plan.ts b/apps/server/agent/prompts/plan.ts index f11d9b5..acb1604 100644 --- a/apps/server/agent/prompts/plan.ts +++ b/apps/server/agent/prompts/plan.ts @@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar + +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. + + - Account for existing phases/tasks — don't plan work already covered - Always generate new phase IDs — never reuse existing ones diff --git a/apps/server/agent/prompts/refine.ts b/apps/server/agent/prompts/refine.ts index 843a66c..8d831bb 100644 --- a/apps/server/agent/prompts/refine.ts +++ b/apps/server/agent/prompts/refine.ts @@ -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. + +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. + + - Ask 2-4 questions if you need clarification - Preserve [[page:\$id|title]] cross-references From b419981924e953de3a169460c302f617ca77e1fe Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 14:05:23 +0100 Subject: [PATCH 13/13] perf: Speed up conflict resolution agents by trimming prompt bloat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace SESSION_STARTUP (full test suite run) and CONTEXT_MANAGEMENT (progress file refs) with a minimal startup block (pwd, git status, CLAUDE.md). Add skipPromptExtras option to SpawnAgentOptions to skip inter-agent communication and preview deployment instructions. Conflict agents now go straight to the resolution protocol — one post-resolution test run instead of two. --- apps/server/agent/manager.ts | 15 ++++++++------- apps/server/agent/prompts/conflict-resolution.ts | 10 ++++++---- apps/server/agent/types.ts | 2 ++ apps/server/trpc/routers/initiative.ts | 1 + docs/agent.md | 4 ++-- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 066d51d..152ac3c 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -283,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); + } } } diff --git a/apps/server/agent/prompts/conflict-resolution.ts b/apps/server/agent/prompts/conflict-resolution.ts index bb33ab7..e295b29 100644 --- a/apps/server/agent/prompts/conflict-resolution.ts +++ b/apps/server/agent/prompts/conflict-resolution.ts @@ -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} ${SIGNAL_FORMAT} -${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. + Follow these steps in order: @@ -57,7 +60,6 @@ Follow these steps in order: 8. **Signal done**: Write signal.json with status "done". ${GIT_WORKFLOW} -${CONTEXT_MANAGEMENT} - 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. diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 94737d9..975abae 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -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; } /** diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 1c317df..0077ad9 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -488,6 +488,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { initiativeId: input.initiativeId, baseBranch: initiative.branch, branchName: tempBranch, + skipPromptExtras: true, }); }), }; diff --git a/docs/agent.md b/docs/agent.md index 752a527..38f55db 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -24,14 +24,14 @@ | `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, errand) + 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. Conflict-resolution uses a minimal inline startup (pwd, git status, CLAUDE.md) instead of the full `SESSION_STARTUP`/`CONTEXT_MANAGEMENT` blocks. | ## Key Flows ### Spawning an Agent 1. **tRPC procedure** calls `agentManager.spawn(options)` -2. Manager generates alias (adjective-animal), creates DB record +2. Manager generates alias (adjective-animal), creates DB record. Appends inter-agent communication and preview instructions unless `skipPromptExtras: true` (used by conflict-resolution agents to keep prompts lean). 3. `AgentProcessManager.createWorktree()` — creates git worktree at `.cw-worktrees/agent//` 4. `file-io.writeInputFiles()` — writes `.cw/input/` with assignment files (initiative, pages, phase, task) and read-only context dirs (`context/phases/`, `context/tasks/`) 5. Provider config builds spawn command via `buildSpawnCommand()`