From 8387d5b22c73bfde2f0bd8ca72f82a6cafd53788 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 17:29:09 +0100 Subject: [PATCH 01/85] chore: Gitignore agent-generated screenshots --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index af99b39..7cea6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ workdir/* # Agent working directories agent-workdirs/ +# Agent-generated screenshots +.screenshots/ + # Logs *.log npm-debug.log* From 2eac5b9908e37e7fb2c7b3bdcabc3cd87a68cf4b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 17:50:01 +0100 Subject: [PATCH 02/85] fix: Remove agent:output from general SSE subscriptions to prevent listener leak agent:output is high-frequency streaming data that was included in ALL_EVENT_TYPES and AGENT_EVENT_TYPES, causing every onEvent/onAgentUpdate SSE subscription to register a listener. With multiple subscriptions per browser tab plus reconnections, this exceeded the 100 listener limit. The dedicated onAgentOutput subscription handles output streaming already. Bonus: stops useLiveUpdates from refetching listAgents on every output chunk. --- apps/server/trpc/subscriptions.ts | 2 -- docs/server-api.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/trpc/subscriptions.ts b/apps/server/trpc/subscriptions.ts index 97cf38b..027e055 100644 --- a/apps/server/trpc/subscriptions.ts +++ b/apps/server/trpc/subscriptions.ts @@ -40,7 +40,6 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [ 'agent:account_switched', 'agent:deleted', 'agent:waiting', - 'agent:output', 'task:queued', 'task:dispatched', 'task:completed', @@ -84,7 +83,6 @@ export const AGENT_EVENT_TYPES: DomainEventType[] = [ 'agent:account_switched', 'agent:deleted', 'agent:waiting', - 'agent:output', ]; /** diff --git a/docs/server-api.md b/docs/server-api.md index 2b69321..f1faab4 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -204,13 +204,13 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | Procedure | Type | Events | |-----------|------|--------| | onEvent | subscription | All event types | -| onAgentUpdate | subscription | agent:* events (8 types) | +| onAgentUpdate | subscription | agent:* events (7 types, excludes agent:output) | | onTaskUpdate | subscription | task:* + phase:* events (8 types) | | onPageUpdate | subscription | page:created/updated/deleted | | onPreviewUpdate | subscription | preview:building/ready/stopped/failed | | onConversationUpdate | subscription | conversation:created/answered | -Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat. +Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat. `agent:output` is excluded from all general subscriptions (it's high-frequency streaming data); use the dedicated `onAgentOutput` subscription instead. ## Coordination Module From d81e0864f7010d65bc893e1b19f3509f65955291 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 20:41:49 +0100 Subject: [PATCH 03/85] feat: Add retry mechanism for blocked tasks Blocked tasks (from spawn failures) were a dead-end with no way to recover. Add retryBlockedTask to DispatchManager that resets status to pending and re-queues, a tRPC mutation that also kicks dispatchNext, and a Retry button in the task slide-over when status is blocked. --- apps/server/dispatch/manager.ts | 21 ++++++++++ apps/server/dispatch/phase-manager.test.ts | 1 + apps/server/dispatch/types.ts | 8 ++++ apps/server/trpc/routers/dispatch.ts | 10 +++++ .../components/execution/TaskSlideOver.tsx | 40 +++++++++++++------ docs/dispatch-events.md | 2 + 6 files changed, 70 insertions(+), 12 deletions(-) diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index b799e57..ad17f7e 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -237,6 +237,27 @@ export class DefaultDispatchManager implements DispatchManager { this.eventBus.emit(event); } + /** + * Retry a blocked task. + * Resets status to pending, clears block state, and re-queues for dispatch. + */ + async retryBlockedTask(taskId: string): Promise { + const task = await this.taskRepository.findById(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + if (task.status !== 'blocked') throw new Error(`Task ${taskId} is not blocked (status: ${task.status})`); + + // Clear blocked state + this.blockedTasks.delete(taskId); + + // Reset DB status to pending + await this.taskRepository.update(taskId, { status: 'pending' }); + + log.info({ taskId }, 'retrying blocked task'); + + // Re-queue for dispatch + await this.queue(taskId); + } + /** * Dispatch next available task to an agent. */ diff --git a/apps/server/dispatch/phase-manager.test.ts b/apps/server/dispatch/phase-manager.test.ts index 246e141..bd57241 100644 --- a/apps/server/dispatch/phase-manager.test.ts +++ b/apps/server/dispatch/phase-manager.test.ts @@ -50,6 +50,7 @@ function createMockDispatchManager(): DispatchManager { dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }), completeTask: vi.fn(), blockTask: vi.fn(), + retryBlockedTask: vi.fn(), getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), }; } diff --git a/apps/server/dispatch/types.ts b/apps/server/dispatch/types.ts index 6c86111..6478ce2 100644 --- a/apps/server/dispatch/types.ts +++ b/apps/server/dispatch/types.ts @@ -102,6 +102,14 @@ export interface DispatchManager { */ blockTask(taskId: string, reason: string): Promise; + /** + * Retry a blocked task. + * Resets status to pending, removes from blocked map, and re-queues for dispatch. + * + * @param taskId - ID of the blocked task to retry + */ + retryBlockedTask(taskId: string): Promise; + /** * Get current queue state. * Returns all queued tasks with their dispatch readiness. diff --git a/apps/server/trpc/routers/dispatch.ts b/apps/server/trpc/routers/dispatch.ts index 13f4b0c..1a41dc5 100644 --- a/apps/server/trpc/routers/dispatch.ts +++ b/apps/server/trpc/routers/dispatch.ts @@ -35,5 +35,15 @@ export function dispatchProcedures(publicProcedure: ProcedureBuilder) { await dispatchManager.completeTask(input.taskId); return { success: true }; }), + + retryBlockedTask: publicProcedure + .input(z.object({ taskId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const dispatchManager = requireDispatchManager(ctx); + await dispatchManager.retryBlockedTask(input.taskId); + // Kick dispatch loop to pick up the re-queued task + await dispatchManager.dispatchNext(); + return { success: true }; + }), }; } diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index e2df6bf..ff9a4d3 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "motion/react"; -import { X, Trash2, MessageCircle } from "lucide-react"; +import { X, Trash2, MessageCircle, RotateCw } from "lucide-react"; import type { ChatTarget } from "@/components/chat/ChatSlideOver"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ interface TaskSlideOverProps { export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { const { selectedEntry, setSelectedTaskId } = useExecutionContext(); const queueTaskMutation = trpc.queueTask.useMutation(); + const retryBlockedTaskMutation = trpc.retryBlockedTask.useMutation(); const deleteTaskMutation = trpc.deleteTask.useMutation(); const updateTaskMutation = trpc.updateTask.useMutation(); @@ -229,17 +230,32 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { {/* Footer */}
- + {task.status === "blocked" ? ( + + ) : ( + + )} +
+ ); + } + + if (preview.status === "failed") { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index 7aed391..d12a5bd 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -6,11 +6,7 @@ import { FileCode, Plus, Minus, - ExternalLink, Loader2, - Square, - CircleDot, - RotateCcw, ArrowRight, Eye, AlertCircle, @@ -18,6 +14,8 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { PreviewControls } from "./PreviewControls"; +import type { PreviewState } from "./PreviewControls"; import type { FileDiff, ReviewStatus } from "./types"; interface PhaseOption { @@ -25,15 +23,6 @@ interface PhaseOption { name: string; } -interface PreviewState { - status: "idle" | "building" | "running" | "failed"; - url?: string; - onStart: () => void; - onStop: () => void; - isStarting: boolean; - isStopping: boolean; -} - interface ReviewHeaderProps { phases: PhaseOption[]; activePhaseId: string | null; @@ -285,66 +274,3 @@ export function ReviewHeader({ ); } -function PreviewControls({ preview }: { preview: PreviewState }) { - if (preview.status === "building" || preview.isStarting) { - return ( -
- - Building... -
- ); - } - - if (preview.status === "running") { - return ( - - ); - } - - if (preview.status === "failed") { - return ( - - ); - } - - return ( - - ); -} From 0e61c48c861014db7dfa044eaa2bce9b41e21bac Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:49:01 +0100 Subject: [PATCH 21/85] fix: Use lowercase alphanumeric nanoid for Docker compose project names Docker compose requires project names to be lowercase alphanumeric with hyphens/underscores only. The default nanoid alphabet includes uppercase and special characters, causing build failures. --- apps/server/preview/manager.test.ts | 2 +- apps/server/preview/manager.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index 14dc0cd..d668b6b 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -67,7 +67,7 @@ vi.mock('node:fs/promises', () => ({ })); vi.mock('nanoid', () => ({ - nanoid: vi.fn(() => 'abc123test'), + customAlphabet: vi.fn(() => vi.fn(() => 'abc123test')), })); import { PreviewManager } from './manager.js'; diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 418e9ba..6806c4f 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -8,7 +8,7 @@ import { join } from 'node:path'; import { mkdir, writeFile, rm } from 'node:fs/promises'; -import { nanoid } from 'nanoid'; +import { customAlphabet } from 'nanoid'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; @@ -116,7 +116,8 @@ export class PreviewManager { ); // 4. Generate ID and prepare deploy dir - const id = nanoid(10); + const previewNanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10); + const id = previewNanoid(); const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`; const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id); await mkdir(deployDir, { recursive: true }); From 4958b6624d04b0b64c60dc41f2ef85cb53a6505c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:56:05 +0100 Subject: [PATCH 22/85] fix: Refetch previews on start and switch to path-based routing Two fixes: - Call previewsQuery.refetch() in startPreview.onSuccess so the UI transitions from "building" to the preview link without a page refresh. - Switch from subdomain routing (*.localhost) to path-based routing (localhost://) since macOS doesn't resolve wildcard localhost subdomains. --- apps/server/preview/compose-generator.test.ts | 24 ++++++++++--------- apps/server/preview/gateway.ts | 18 +++++++------- apps/server/preview/health-checker.ts | 6 ++--- apps/server/preview/manager.test.ts | 8 +++---- apps/server/preview/manager.ts | 4 ++-- .../components/review/InitiativeReview.tsx | 1 + apps/web/src/components/review/ReviewTab.tsx | 1 + 7 files changed, 33 insertions(+), 29 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 62448f0..37aebf2 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -156,7 +156,7 @@ describe('generateComposeFile', () => { }); describe('generateGatewayCaddyfile', () => { - it('generates single-preview Caddyfile with subdomain routing', () => { + it('generates single-preview Caddyfile with path-based routing', () => { const previews = new Map(); previews.set('abc123', [ { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, @@ -164,7 +164,8 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain('abc123.localhost:9100 {'); + expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -176,13 +177,13 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('handle_path /api/*'); + expect(caddyfile).toContain('handle_path /abc123/api/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); - expect(caddyfile).toContain('handle {'); + expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('generates multi-preview Caddyfile with separate subdomain blocks', () => { + it('generates multi-preview Caddyfile under single host block', () => { const previews = new Map(); previews.set('abc', [ { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, @@ -192,8 +193,9 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('abc.localhost:9100 {'); - expect(caddyfile).toContain('xyz.localhost:9100 {'); + expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain('handle_path /abc/*'); + expect(caddyfile).toContain('handle_path /xyz/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); }); @@ -207,12 +209,12 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - const apiAuthIdx = caddyfile.indexOf('/api/auth'); - const apiIdx = caddyfile.indexOf('handle_path /api/*'); - const handleIdx = caddyfile.indexOf('handle {'); + const apiAuthIdx = caddyfile.indexOf('/abc/api/auth'); + const apiIdx = caddyfile.indexOf('handle_path /abc/api/*'); + const rootIdx = caddyfile.indexOf('handle_path /abc/*'); expect(apiAuthIdx).toBeLessThan(apiIdx); - expect(apiIdx).toBeLessThan(handleIdx); + expect(apiIdx).toBeLessThan(rootIdx); }); }); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index 967921a..c9b67c0 100644 --- a/apps/server/preview/gateway.ts +++ b/apps/server/preview/gateway.ts @@ -2,7 +2,7 @@ * Gateway Manager * * Manages a single shared Caddy reverse proxy (the "gateway") that routes - * subdomain requests to per-preview compose stacks on a shared Docker network. + * path-prefixed requests to per-preview compose stacks on a shared Docker network. * * Architecture: * .cw-previews/gateway/ @@ -195,7 +195,8 @@ export class GatewayManager { /** * Generate a Caddyfile for the gateway from all active preview routes. * - * Each preview gets a subdomain block: `.localhost:` + * Uses path-based routing under a single `localhost:` block. + * Each preview is accessible at `//...` — no subdomain DNS needed. * Routes within a preview are sorted by specificity (longest path first). */ export function generateGatewayCaddyfile( @@ -207,6 +208,7 @@ export function generateGatewayCaddyfile( ' auto_https off', '}', '', + `localhost:${port} {`, ]; for (const [previewId, routes] of previews) { @@ -217,24 +219,22 @@ export function generateGatewayCaddyfile( return b.route.length - a.route.length; }); - lines.push(`${previewId}.localhost:${port} {`); - for (const route of sorted) { if (route.route === '/') { - lines.push(` handle {`); + lines.push(` handle_path /${previewId}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } else { const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; - lines.push(` handle_path ${path}/* {`); + lines.push(` handle_path /${previewId}${path}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } } - - lines.push('}'); - lines.push(''); } + lines.push('}'); + lines.push(''); + return lines.join('\n'); } diff --git a/apps/server/preview/health-checker.ts b/apps/server/preview/health-checker.ts index 529cdf1..0eaf38d 100644 --- a/apps/server/preview/health-checker.ts +++ b/apps/server/preview/health-checker.ts @@ -1,7 +1,7 @@ /** * Health Checker * - * Polls service healthcheck endpoints through the gateway's subdomain routing + * Polls service healthcheck endpoints through the gateway's path-based routing * to verify that preview services are ready. */ @@ -20,7 +20,7 @@ const DEFAULT_INTERVAL_MS = 3_000; * Wait for all non-internal services to become healthy by polling their * healthcheck endpoints through the gateway's subdomain routing. * - * @param previewId - The preview deployment ID (used as subdomain) + * @param previewId - The preview deployment ID (used as path prefix) * @param gatewayPort - The gateway's host port * @param config - Preview config with service definitions * @param timeoutMs - Maximum time to wait (default: 120s) @@ -60,7 +60,7 @@ export async function waitForHealthy( const route = svc.route ?? '/'; const healthPath = svc.healthcheck!.path; const basePath = route === '/' ? '' : route; - const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; + const url = `http://localhost:${gatewayPort}/${previewId}${basePath}${healthPath}`; try { const response = await fetch(url, { diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index d668b6b..39cacb7 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -220,7 +220,7 @@ describe('PreviewManager', () => { expect(result.projectId).toBe('proj-1'); expect(result.branch).toBe('feature-x'); expect(result.gatewayPort).toBe(9100); - expect(result.url).toBe('http://abc123test.localhost:9100'); + expect(result.url).toBe('http://localhost:9100/abc123test/'); expect(result.mode).toBe('preview'); expect(result.status).toBe('running'); @@ -233,7 +233,7 @@ describe('PreviewManager', () => { expect(buildingEvent).toBeDefined(); expect(readyEvent).toBeDefined(); expect((readyEvent!.payload as Record).url).toBe( - 'http://abc123test.localhost:9100', + 'http://localhost:9100/abc123test/', ); }); @@ -472,7 +472,7 @@ describe('PreviewManager', () => { expect(previews).toHaveLength(2); expect(previews[0].id).toBe('aaa'); expect(previews[0].gatewayPort).toBe(9100); - expect(previews[0].url).toBe('http://aaa.localhost:9100'); + expect(previews[0].url).toBe('http://localhost:9100/aaa/'); expect(previews[0].mode).toBe('preview'); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); @@ -573,7 +573,7 @@ describe('PreviewManager', () => { expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); expect(status!.gatewayPort).toBe(9100); - expect(status!.url).toBe('http://abc.localhost:9100'); + expect(status!.url).toBe('http://localhost:9100/abc/'); expect(status!.mode).toBe('preview'); }); diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 6806c4f..7e50bed 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -239,7 +239,7 @@ export class PreviewManager { await this.runSeeds(projectName, config); // 11. Success - const url = `http://${id}.localhost:${gatewayPort}`; + const url = `http://localhost:${gatewayPort}/${id}/`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ @@ -605,7 +605,7 @@ export class PreviewManager { projectId, branch, gatewayPort, - url: `http://${previewId}.localhost:${gatewayPort}`, + url: `http://localhost:${gatewayPort}/${previewId}/`, mode, status: 'running', services: [], diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 14b4864..50b4750 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -73,6 +73,7 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview const startPreview = trpc.startPreview.useMutation({ onSuccess: (data) => { setActivePreviewId(data.id); + previewsQuery.refetch(); toast.success(`Preview running at ${data.url}`); }, onError: (err) => toast.error(`Preview failed: ${err.message}`), diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index def288f..7d05afa 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -99,6 +99,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const startPreview = trpc.startPreview.useMutation({ onSuccess: (data) => { setActivePreviewId(data.id); + previewsQuery.refetch(); toast.success(`Preview running at ${data.url}`); }, onError: (err) => toast.error(`Preview failed: ${err.message}`), From df84a877f2b77c67c88dde3b4885e8ff409d672d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:58:02 +0100 Subject: [PATCH 23/85] fix: Prioritize polled preview status over mutation pending state The startPreview mutation blocks server-side through the entire Docker build + health check + seed cycle. isPending was checked first, so even after polling detected containers running, the UI stayed on "Building..." until the full mutation resolved. Now polled status (running/failed) takes priority, falling back to isPending only when no status exists. --- .../web/src/components/review/InitiativeReview.tsx | 14 ++++++-------- apps/web/src/components/review/ReviewTab.tsx | 14 ++++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 50b4750..2b7709c 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -129,15 +129,13 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview const previewState = firstProjectId && sourceBranch ? { - status: startPreview.isPending - ? ("building" as const) - : preview?.status === "running" - ? ("running" as const) - : preview?.status === "building" + status: preview?.status === "running" + ? ("running" as const) + : preview?.status === "failed" + ? ("failed" as const) + : (startPreview.isPending || preview?.status === "building") ? ("building" as const) - : preview?.status === "failed" - ? ("failed" as const) - : ("idle" as const), + : ("idle" as const), url: preview?.url ?? undefined, onStart: () => startPreview.mutate({ diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 7d05afa..2b8b660 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -116,15 +116,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const previewState = firstProjectId && sourceBranch ? { - status: startPreview.isPending - ? ("building" as const) - : preview?.status === "running" - ? ("running" as const) - : preview?.status === "building" + status: preview?.status === "running" + ? ("running" as const) + : preview?.status === "failed" + ? ("failed" as const) + : (startPreview.isPending || preview?.status === "building") ? ("building" as const) - : preview?.status === "failed" - ? ("failed" as const) - : ("idle" as const), + : ("idle" as const), url: preview?.url ?? undefined, onStart: () => startPreview.mutate({ From cdb3de353d2dd496e735c1d5f64a360cacd65a07 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:01:16 +0100 Subject: [PATCH 24/85] fix: Use SSE events instead of polling for preview status updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit httpBatchLink batches polling queries behind the long-running startPreview mutation, so refetchInterval never fires independently. Replace polling with preview: event invalidation via the existing useLiveUpdates SSE subscription — preview:building/ready/stopped/failed events now trigger listPreviews and getPreviewStatus invalidation. --- .../components/review/InitiativeReview.tsx | 10 ++----- apps/web/src/components/review/ReviewTab.tsx | 28 +++++++++---------- apps/web/src/routes/initiatives/$id.tsx | 9 +++--- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 2b7709c..fc0cd7a 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -53,20 +53,14 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); const firstProjectId = projectsQuery.data?.[0]?.id ?? null; - const previewsQuery = trpc.listPreviews.useQuery( - { initiativeId }, - { refetchInterval: 3000 }, - ); + const previewsQuery = trpc.listPreviews.useQuery({ initiativeId }); const existingPreview = previewsQuery.data?.find( (p) => p.initiativeId === initiativeId, ); const [activePreviewId, setActivePreviewId] = useState(null); const previewStatusQuery = trpc.getPreviewStatus.useQuery( { previewId: activePreviewId ?? existingPreview?.id ?? "" }, - { - enabled: !!(activePreviewId ?? existingPreview?.id), - refetchInterval: 3000, - }, + { enabled: !!(activePreviewId ?? existingPreview?.id) }, ); const preview = previewStatusQuery.data ?? existingPreview; diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 2b8b660..7a28e3a 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -45,14 +45,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { // Fetch phases for this initiative const phasesQuery = trpc.listPhases.useQuery({ initiativeId }); - const pendingReviewPhases = useMemo( - () => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"), + const reviewablePhases = useMemo( + () => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"), [phasesQuery.data], ); - // Select first pending review phase + // Select first pending review phase, falling back to completed phases const [selectedPhaseId, setSelectedPhaseId] = useState(null); - const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null; + const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null; + const activePhaseId = selectedPhaseId ?? defaultPhaseId; + const activePhase = reviewablePhases.find((p) => p.id === activePhaseId); + const isActivePhaseCompleted = activePhase?.status === "completed"; // Fetch projects for this initiative (needed for preview) const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); @@ -78,20 +81,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { ); // Preview state - const previewsQuery = trpc.listPreviews.useQuery( - { initiativeId }, - { refetchInterval: 3000 }, - ); + const previewsQuery = trpc.listPreviews.useQuery({ initiativeId }); const existingPreview = previewsQuery.data?.find( (p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId, ); const [activePreviewId, setActivePreviewId] = useState(null); const previewStatusQuery = trpc.getPreviewStatus.useQuery( { previewId: activePreviewId ?? existingPreview?.id ?? "" }, - { - enabled: !!(activePreviewId ?? existingPreview?.id), - refetchInterval: 3000, - }, + { enabled: !!(activePreviewId ?? existingPreview?.id) }, ); const preview = previewStatusQuery.data ?? existingPreview; const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? ""; @@ -263,7 +260,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const activePhaseName = diffQuery.data?.phaseName ?? - pendingReviewPhases.find((p) => p.id === activePhaseId)?.name ?? + reviewablePhases.find((p) => p.id === activePhaseId)?.name ?? "Phase"; // All files from the full branch diff (for sidebar file list) @@ -285,7 +282,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { ); } - if (pendingReviewPhases.length === 0) { + if (reviewablePhases.length === 0) { return (

No phases pending review

@@ -297,8 +294,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
{/* Header: phase selector + toolbar */} ({ id: p.id, name: p.name }))} + phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} + isReadOnly={isActivePhaseCompleted} onPhaseSelect={handlePhaseSelect} phaseName={activePhaseName} sourceBranch={sourceBranch} diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index 7ff848e..6662f18 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -29,10 +29,11 @@ function InitiativeDetailPage() { // Single SSE stream for all live updates useLiveUpdates([ - { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] }, - { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] }, - { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, - { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, + { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] }, + { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] }, + { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, + { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, + { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, ]); // tRPC queries From 84250955d180b731a66db4b60378056f204f8ce4 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:05:28 +0100 Subject: [PATCH 25/85] fix: Show completed phase diffs in review tab Completed phases showed "No phases pending review" because: 1. Frontend filtered only pending_review phases 2. Server rejected non-pending_review phases 3. After merge, three-dot diff returned empty (merge base moved) Fix: store pre-merge merge base hash on phase, use it to reconstruct diffs for completed phases. Frontend now shows both pending_review and completed phases with read-only mode (Merged badge) for completed ones. --- apps/server/agent/file-io.test.ts | 1 + apps/server/db/schema.ts | 1 + .../drizzle/0031_add_phase_merge_base.sql | 1 + apps/server/drizzle/meta/_journal.json | 7 + apps/server/execution/orchestrator.ts | 12 + apps/server/git/branch-manager.ts | 6 + apps/server/git/simple-git-branch-manager.ts | 6 + apps/server/trpc/routers/phase.ts | 15 +- .../src/components/review/ReviewHeader.tsx | 208 ++++++++++-------- docs/database.md | 3 +- docs/git-process-logging.md | 1 + 11 files changed, 158 insertions(+), 103 deletions(-) create mode 100644 apps/server/drizzle/0031_add_phase_merge_base.sql diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 20aec4d..396453f 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -68,6 +68,7 @@ describe('writeInputFiles', () => { name: 'Phase One', content: 'First phase', status: 'pending', + mergeBase: null, createdAt: new Date(), updatedAt: new Date(), } as Phase; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 61ae1e2..1248f7f 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -55,6 +55,7 @@ export const phases = sqliteTable('phases', { status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] }) .notNull() .default('pending'), + mergeBase: text('merge_base'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); diff --git a/apps/server/drizzle/0031_add_phase_merge_base.sql b/apps/server/drizzle/0031_add_phase_merge_base.sql new file mode 100644 index 0000000..7771d38 --- /dev/null +++ b/apps/server/drizzle/0031_add_phase_merge_base.sql @@ -0,0 +1 @@ +ALTER TABLE phases ADD COLUMN merge_base TEXT; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index ac6687d..1c5870a 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1772150400000, "tag": "0030_remove_task_approval", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1772236800000, + "tag": "0031_add_phase_merge_base", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index a08e2f1..55de53a 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -278,6 +278,18 @@ export class ExecutionOrchestrator { const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId); + // Store merge base before merging so we can reconstruct diffs for completed phases + for (const project of projects) { + const clonePath = await ensureProjectClone(project, this.workspaceRoot); + try { + const mergeBase = await this.branchManager.getMergeBase(clonePath, initBranch, phBranch); + await this.phaseRepository.update(phaseId, { mergeBase }); + break; // Only need one merge base (first project) + } catch { + // Phase branch may not exist in this project clone + } + } + for (const project of projects) { const clonePath = await ensureProjectClone(project, this.workspaceRoot); const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index f5d9b54..113ac7b 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -57,6 +57,12 @@ export interface BranchManager { */ diffCommit(repoPath: string, commitHash: string): Promise; + /** + * Get the merge base (common ancestor) of two branches. + * Returns the commit hash of the merge base. + */ + getMergeBase(repoPath: string, branch1: string, branch2: string): Promise; + /** * Push a branch to a remote. * Defaults to 'origin' if no remote specified. diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index bee747a..0c73ce7 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -141,6 +141,12 @@ export class SimpleGitBranchManager implements BranchManager { return git.diff([`${commitHash}~1`, commitHash]); } + async getMergeBase(repoPath: string, branch1: string, branch2: string): Promise { + const git = simpleGit(repoPath); + const result = await git.raw(['merge-base', branch1, branch2]); + return result.trim(); + } + async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise { const git = simpleGit(repoPath); await git.push(remote, branch); diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index 604b45a..4762232 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -219,8 +219,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); } - if (phase.status !== 'pending_review') { - throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); + if (phase.status !== 'pending_review' && phase.status !== 'completed') { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` }); } const initiative = await initiativeRepo.findById(phase.initiativeId); @@ -230,13 +230,15 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { const initBranch = initiative.branch; const phBranch = phaseBranchName(initBranch, phase.name); + // For completed phases, use stored merge base; for pending_review, use initiative branch + const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch; const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); let rawDiff = ''; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); - const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch); + const diff = await branchManager.diffBranches(clonePath, diffBase, phBranch); if (diff) { rawDiff += diff + '\n'; } @@ -270,8 +272,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); } - if (phase.status !== 'pending_review') { - throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); + if (phase.status !== 'pending_review' && phase.status !== 'completed') { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` }); } const initiative = await initiativeRepo.findById(phase.initiativeId); @@ -281,13 +283,14 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { const initBranch = initiative.branch; const phBranch = phaseBranchName(initBranch, phase.name); + const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch; const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = []; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); - const commits = await branchManager.listCommits(clonePath, initBranch, phBranch); + const commits = await branchManager.listCommits(clonePath, diffBase, phBranch); allCommits.push(...commits); } diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index d12a5bd..0b10a96 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -21,11 +21,13 @@ import type { FileDiff, ReviewStatus } from "./types"; interface PhaseOption { id: string; name: string; + status: string; } interface ReviewHeaderProps { phases: PhaseOption[]; activePhaseId: string | null; + isReadOnly?: boolean; onPhaseSelect: (id: string) => void; phaseName: string; sourceBranch: string; @@ -44,6 +46,7 @@ interface ReviewHeaderProps { export function ReviewHeader({ phases, activePhaseId, + isReadOnly, onPhaseSelect, phaseName, sourceBranch, @@ -92,6 +95,12 @@ export function ReviewHeader({
{phases.map((phase) => { const isActive = phase.id === activePhaseId; + const isCompleted = phase.status === "completed"; + const dotColor = isActive + ? "bg-primary" + : isCompleted + ? "bg-status-success-dot" + : "bg-status-warning-dot"; return ( @@ -171,102 +178,111 @@ export function ReviewHeader({ {preview && } {/* Review status / actions */} - {status === "pending" && ( - <> - -
- - - {/* Merge confirmation dropdown */} - {showConfirmation && ( -
-

- Ready to merge? -

-
-
- - - 0 unresolved comments - -
-
- - - {viewed}/{total} files viewed - -
-
-
- - -
-
- )} -
- - )} - {status === "approved" && ( + {isReadOnly ? ( - Approved - - )} - {status === "changes_requested" && ( - - - Changes Requested + Merged + ) : ( + <> + {status === "pending" && ( + <> + +
+ + + {/* Merge confirmation dropdown */} + {showConfirmation && ( +
+

+ Ready to merge? +

+
+
+ + + 0 unresolved comments + +
+
+ + + {viewed}/{total} files viewed + +
+
+
+ + +
+
+ )} +
+ + )} + {status === "approved" && ( + + + Approved + + )} + {status === "changes_requested" && ( + + + Changes Requested + + )} + )}
diff --git a/docs/database.md b/docs/database.md index c4bfc59..6afb841 100644 --- a/docs/database.md +++ b/docs/database.md @@ -29,7 +29,8 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | initiativeId | text FK → initiatives (cascade) | | | name | text NOT NULL | | | content | text nullable | Tiptap JSON | -| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' | +| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' \| 'pending_review' | +| mergeBase | text nullable | git merge-base hash stored before phase merge, enables diff reconstruction for completed phases | | createdAt, updatedAt | integer/timestamp | | ### phase_dependencies diff --git a/docs/git-process-logging.md b/docs/git-process-logging.md index 817dbfc..b35efa2 100644 --- a/docs/git-process-logging.md +++ b/docs/git-process-logging.md @@ -45,6 +45,7 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a | `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/`) | | `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) | | `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit | +| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash | `remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving. From aedf149471235ac6a6a157c6f66cda32c2f2045b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:16:19 +0100 Subject: [PATCH 26/85] fix: Use container-internal port 80 in gateway Caddyfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Caddyfile was using the host port (e.g., 9100) as the Caddy listen address, but Docker maps host:9100 → container:80. Caddy inside the container was listening on 9100 while Docker only forwarded to port 80, causing all health checks to fail with "connection reset by peer". --- apps/server/preview/compose-generator.test.ts | 4 ++-- apps/server/preview/gateway.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 37aebf2..1e1f650 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -164,7 +164,7 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain(':80 {'); expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -193,7 +193,7 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain(':80 {'); expect(caddyfile).toContain('handle_path /abc/*'); expect(caddyfile).toContain('handle_path /xyz/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index c9b67c0..57536c5 100644 --- a/apps/server/preview/gateway.ts +++ b/apps/server/preview/gateway.ts @@ -201,14 +201,16 @@ export class GatewayManager { */ export function generateGatewayCaddyfile( previews: Map, - port: number, + _port: number, ): string { + // Caddy runs inside a container where Docker maps host:${port} → container:80. + // The Caddyfile must listen on the container-internal port (80), not the host port. const lines: string[] = [ '{', ' auto_https off', '}', '', - `localhost:${port} {`, + `:80 {`, ]; for (const [previewId, routes] of previews) { From 1b8e496d395ebf7a632016ecad69e87ca73bc8b0 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:38:00 +0100 Subject: [PATCH 27/85] fix: Switch preview gateway from path-prefix to subdomain routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path-prefix routing (`localhost:9100//`) broke SPAs because absolute asset paths (`/assets/index.js`) didn't match the `handle_path //*` route. Subdomain routing (`.localhost:9100/`) resolves this since all paths are relative to the root. Chrome/Firefox resolve *.localhost to 127.0.0.1 natively — no DNS setup needed. --- apps/server/preview/compose-generator.test.ts | 24 +++++++++---------- apps/server/preview/gateway.ts | 18 +++++++------- apps/server/preview/health-checker.ts | 6 ++--- apps/server/preview/manager.test.ts | 8 +++---- apps/server/preview/manager.ts | 4 ++-- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 1e1f650..234d62b 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -156,7 +156,7 @@ describe('generateComposeFile', () => { }); describe('generateGatewayCaddyfile', () => { - it('generates single-preview Caddyfile with path-based routing', () => { + it('generates single-preview Caddyfile with subdomain routing', () => { const previews = new Map(); previews.set('abc123', [ { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, @@ -164,8 +164,8 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain(':80 {'); - expect(caddyfile).toContain('handle_path /abc123/*'); + expect(caddyfile).toContain('abc123.localhost:80 {'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -177,13 +177,14 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('handle_path /abc123/api/*'); + expect(caddyfile).toContain('abc123.localhost:80 {'); + expect(caddyfile).toContain('handle_path /api/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); - expect(caddyfile).toContain('handle_path /abc123/*'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('generates multi-preview Caddyfile under single host block', () => { + it('generates separate subdomain blocks for each preview', () => { const previews = new Map(); previews.set('abc', [ { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, @@ -193,9 +194,8 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain(':80 {'); - expect(caddyfile).toContain('handle_path /abc/*'); - expect(caddyfile).toContain('handle_path /xyz/*'); + expect(caddyfile).toContain('abc.localhost:80 {'); + expect(caddyfile).toContain('xyz.localhost:80 {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); }); @@ -209,9 +209,9 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - const apiAuthIdx = caddyfile.indexOf('/abc/api/auth'); - const apiIdx = caddyfile.indexOf('handle_path /abc/api/*'); - const rootIdx = caddyfile.indexOf('handle_path /abc/*'); + const apiAuthIdx = caddyfile.indexOf('/api/auth'); + const apiIdx = caddyfile.indexOf('handle_path /api/*'); + const rootIdx = caddyfile.indexOf('handle /* {'); expect(apiAuthIdx).toBeLessThan(apiIdx); expect(apiIdx).toBeLessThan(rootIdx); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index 57536c5..ed4ec96 100644 --- a/apps/server/preview/gateway.ts +++ b/apps/server/preview/gateway.ts @@ -2,7 +2,7 @@ * Gateway Manager * * Manages a single shared Caddy reverse proxy (the "gateway") that routes - * path-prefixed requests to per-preview compose stacks on a shared Docker network. + * subdomain-based requests to per-preview compose stacks on a shared Docker network. * * Architecture: * .cw-previews/gateway/ @@ -195,8 +195,8 @@ export class GatewayManager { /** * Generate a Caddyfile for the gateway from all active preview routes. * - * Uses path-based routing under a single `localhost:` block. - * Each preview is accessible at `//...` — no subdomain DNS needed. + * Uses subdomain-based routing: each preview gets its own `.localhost:80` block. + * Chrome/Firefox resolve `*.localhost` to 127.0.0.1 natively — no DNS setup needed. * Routes within a preview are sorted by specificity (longest path first). */ export function generateGatewayCaddyfile( @@ -209,8 +209,6 @@ export function generateGatewayCaddyfile( '{', ' auto_https off', '}', - '', - `:80 {`, ]; for (const [previewId, routes] of previews) { @@ -221,21 +219,25 @@ export function generateGatewayCaddyfile( return b.route.length - a.route.length; }); + lines.push(''); + lines.push(`${previewId}.localhost:80 {`); + for (const route of sorted) { if (route.route === '/') { - lines.push(` handle_path /${previewId}/* {`); + lines.push(` handle /* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } else { const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; - lines.push(` handle_path /${previewId}${path}/* {`); + lines.push(` handle_path ${path}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } } + + lines.push('}'); } - lines.push('}'); lines.push(''); return lines.join('\n'); diff --git a/apps/server/preview/health-checker.ts b/apps/server/preview/health-checker.ts index 0eaf38d..febe87b 100644 --- a/apps/server/preview/health-checker.ts +++ b/apps/server/preview/health-checker.ts @@ -1,7 +1,7 @@ /** * Health Checker * - * Polls service healthcheck endpoints through the gateway's path-based routing + * Polls service healthcheck endpoints through the gateway's subdomain-based routing * to verify that preview services are ready. */ @@ -20,7 +20,7 @@ const DEFAULT_INTERVAL_MS = 3_000; * Wait for all non-internal services to become healthy by polling their * healthcheck endpoints through the gateway's subdomain routing. * - * @param previewId - The preview deployment ID (used as path prefix) + * @param previewId - The preview deployment ID (used as subdomain) * @param gatewayPort - The gateway's host port * @param config - Preview config with service definitions * @param timeoutMs - Maximum time to wait (default: 120s) @@ -60,7 +60,7 @@ export async function waitForHealthy( const route = svc.route ?? '/'; const healthPath = svc.healthcheck!.path; const basePath = route === '/' ? '' : route; - const url = `http://localhost:${gatewayPort}/${previewId}${basePath}${healthPath}`; + const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; try { const response = await fetch(url, { diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index 39cacb7..4f60cfc 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -220,7 +220,7 @@ describe('PreviewManager', () => { expect(result.projectId).toBe('proj-1'); expect(result.branch).toBe('feature-x'); expect(result.gatewayPort).toBe(9100); - expect(result.url).toBe('http://localhost:9100/abc123test/'); + expect(result.url).toBe('http://abc123test.localhost:9100/'); expect(result.mode).toBe('preview'); expect(result.status).toBe('running'); @@ -233,7 +233,7 @@ describe('PreviewManager', () => { expect(buildingEvent).toBeDefined(); expect(readyEvent).toBeDefined(); expect((readyEvent!.payload as Record).url).toBe( - 'http://localhost:9100/abc123test/', + 'http://abc123test.localhost:9100/', ); }); @@ -472,7 +472,7 @@ describe('PreviewManager', () => { expect(previews).toHaveLength(2); expect(previews[0].id).toBe('aaa'); expect(previews[0].gatewayPort).toBe(9100); - expect(previews[0].url).toBe('http://localhost:9100/aaa/'); + expect(previews[0].url).toBe('http://aaa.localhost:9100/'); expect(previews[0].mode).toBe('preview'); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); @@ -573,7 +573,7 @@ describe('PreviewManager', () => { expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); expect(status!.gatewayPort).toBe(9100); - expect(status!.url).toBe('http://localhost:9100/abc/'); + expect(status!.url).toBe('http://abc.localhost:9100/'); expect(status!.mode).toBe('preview'); }); diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 7e50bed..2b04cc3 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -239,7 +239,7 @@ export class PreviewManager { await this.runSeeds(projectName, config); // 11. Success - const url = `http://localhost:${gatewayPort}/${id}/`; + const url = `http://${id}.localhost:${gatewayPort}/`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ @@ -605,7 +605,7 @@ export class PreviewManager { projectId, branch, gatewayPort, - url: `http://localhost:${gatewayPort}/${previewId}/`, + url: `http://${previewId}.localhost:${gatewayPort}/`, mode, status: 'running', services: [], From 65bcbf1a35f41ade57f4573e224867000577fa4b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 09:41:28 +0100 Subject: [PATCH 28/85] fix: Fix review task completion bug + add initiative-level Request Changes Critical: review/merge tasks hit an early return in handleTaskCompleted() that skipped the phase completion check, leaving phases stuck in in_progress forever. Changed to an if-block wrapping only the merge step. Also adds requestChangesOnInitiative() which creates/reuses a "Finalization" phase for initiative-level review feedback, with dedup guards for both phase and initiative request-changes flows. --- apps/server/events/index.ts | 1 + apps/server/events/types.ts | 12 +- apps/server/execution/orchestrator.ts | 112 +++++++++++++++--- apps/server/trpc/routers/initiative.ts | 14 +++ apps/server/trpc/routers/phase.ts | 2 +- .../components/review/InitiativeReview.tsx | 28 ++++- 6 files changed, 152 insertions(+), 17 deletions(-) diff --git a/apps/server/events/index.ts b/apps/server/events/index.ts index e53920b..64a5e0e 100644 --- a/apps/server/events/index.ts +++ b/apps/server/events/index.ts @@ -52,6 +52,7 @@ export type { AccountCredentialsValidatedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, + InitiativeChangesRequestedEvent, DomainEventMap, DomainEventType, } from './types.js'; diff --git a/apps/server/events/types.ts b/apps/server/events/types.ts index fb6a8a6..2f4b84c 100644 --- a/apps/server/events/types.ts +++ b/apps/server/events/types.ts @@ -591,6 +591,15 @@ export interface InitiativeReviewApprovedEvent extends DomainEvent { }; } +export interface InitiativeChangesRequestedEvent extends DomainEvent { + type: 'initiative:changes_requested'; + payload: { + initiativeId: string; + phaseId: string; + taskId: string; + }; +} + /** * Chat Session Events */ @@ -668,7 +677,8 @@ export type DomainEventMap = | ChatMessageCreatedEvent | ChatSessionClosedEvent | InitiativePendingReviewEvent - | InitiativeReviewApprovedEvent; + | InitiativeReviewApprovedEvent + | InitiativeChangesRequestedEvent; /** * Event type literal union for type checking diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 55de53a..b48b821 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -11,7 +11,7 @@ * - Review per-phase: pause after each phase for diff review */ -import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent } from '../events/index.js'; +import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } from '../events/index.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; @@ -150,20 +150,20 @@ export class ExecutionOrchestrator { const phase = await this.phaseRepository.findById(task.phaseId); if (!phase) return; - // Skip merge/review tasks — they already work on the phase branch directly - if (task.category === 'merge' || task.category === 'review') return; + // Skip merge for review/merge tasks — they already work on the phase branch directly + if (task.category !== 'merge' && task.category !== 'review') { + const initBranch = initiative.branch; + const phBranch = phaseBranchName(initBranch, phase.name); + const tBranch = taskBranchName(initBranch, task.id); - const initBranch = initiative.branch; - const phBranch = phaseBranchName(initBranch, phase.name); - const tBranch = taskBranchName(initBranch, task.id); - - // Serialize merges per phase - const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); - const mergeOp = lock.then(async () => { - await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); - }); - this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); - await mergeOp; + // Serialize merges per phase + const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); + const mergeOp = lock.then(async () => { + await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); + }); + this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); + await mergeOp; + } // Check if all phase tasks are done const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId); @@ -356,6 +356,15 @@ export class ExecutionOrchestrator { const initiative = await this.initiativeRepository.findById(phase.initiativeId); if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`); + // Guard: don't create duplicate review tasks + const existingTasks = await this.taskRepository.findByPhaseId(phaseId); + const activeReview = existingTasks.find( + (t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'), + ); + if (activeReview) { + return { taskId: activeReview.id }; + } + // Build revision task description from comments + summary const lines: string[] = []; if (summary) { @@ -418,6 +427,81 @@ export class ExecutionOrchestrator { return { taskId: task.id }; } + /** + * Request changes on an initiative that's pending review. + * Creates/reuses a "Finalization" phase and adds a review task to it. + */ + async requestChangesOnInitiative( + initiativeId: string, + summary: string, + ): Promise<{ taskId: string }> { + const initiative = await this.initiativeRepository.findById(initiativeId); + if (!initiative) throw new Error(`Initiative not found: ${initiativeId}`); + if (initiative.status !== 'pending_review') { + throw new Error(`Initiative ${initiativeId} is not pending review (status: ${initiative.status})`); + } + + // Find or create a "Finalization" phase + const phases = await this.phaseRepository.findByInitiativeId(initiativeId); + let finalizationPhase = phases.find((p) => p.name === 'Finalization'); + + if (!finalizationPhase) { + finalizationPhase = await this.phaseRepository.create({ + initiativeId, + name: 'Finalization', + status: 'in_progress', + }); + } else if (finalizationPhase.status === 'completed' || finalizationPhase.status === 'pending_review') { + await this.phaseRepository.update(finalizationPhase.id, { status: 'in_progress' as any }); + } + + // Guard: don't create duplicate review tasks + const existingTasks = await this.taskRepository.findByPhaseId(finalizationPhase.id); + const activeReview = existingTasks.find( + (t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'), + ); + if (activeReview) { + // Still reset initiative to active + await this.initiativeRepository.update(initiativeId, { status: 'active' as any }); + this.scheduleDispatch(); + return { taskId: activeReview.id }; + } + + // Create review task + const task = await this.taskRepository.create({ + phaseId: finalizationPhase.id, + initiativeId, + name: `Address initiative review feedback`, + description: `## Summary\n\n${summary}`, + category: 'review', + priority: 'high', + }); + + // Reset initiative status to active + await this.initiativeRepository.update(initiativeId, { status: 'active' as any }); + + // Queue task for dispatch + await this.dispatchManager.queue(task.id); + + // Emit event + const event: InitiativeChangesRequestedEvent = { + type: 'initiative:changes_requested', + timestamp: new Date(), + payload: { + initiativeId, + phaseId: finalizationPhase.id, + taskId: task.id, + }, + }; + this.eventBus.emit(event); + + log.info({ initiativeId, phaseId: finalizationPhase.id, taskId: task.id }, 'changes requested on initiative'); + + this.scheduleDispatch(); + + return { taskId: task.id }; + } + /** * Re-queue approved phases for an initiative into the in-memory dispatch queue. * Self-healing: ensures phases aren't lost if the server restarted since the diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 4b8fb66..37b77b3 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -335,5 +335,19 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { await orchestrator.approveInitiative(input.initiativeId, input.strategy); return { success: true }; }), + + requestInitiativeChanges: publicProcedure + .input(z.object({ + initiativeId: z.string().min(1), + summary: z.string().trim().min(1), + })) + .mutation(async ({ ctx, input }) => { + const orchestrator = requireExecutionOrchestrator(ctx); + const result = await orchestrator.requestChangesOnInitiative( + input.initiativeId, + input.summary, + ); + return { success: true, taskId: result.taskId }; + }), }; } diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index 4762232..d2ec9e8 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -371,7 +371,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { requestPhaseChanges: publicProcedure .input(z.object({ phaseId: z.string().min(1), - summary: z.string().optional(), + summary: z.string().trim().min(1).optional(), })) .mutation(async ({ ctx, input }) => { const orchestrator = requireExecutionOrchestrator(ctx); diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index fc0cd7a..5f7a36b 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; +import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; @@ -82,6 +82,14 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview onError: (err) => toast.error(`Failed to stop: ${err.message}`), }); + const requestChangesMutation = trpc.requestInitiativeChanges.useMutation({ + onSuccess: () => { + toast.success("Changes requested — review task created"); + onCompleted(); + }, + onError: (err) => toast.error(err.message), + }); + const approveMutation = trpc.approveInitiativeReview.useMutation({ onSuccess: (_data, variables) => { const msg = variables.strategy === "merge_and_push" @@ -189,6 +197,24 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview {/* Right: preview + action buttons */}
{previewState && } + - ) : ( - - )} -
-
-

- {comment.body} -

- + comment={comment} + replies={repliesByParent.get(comment.id) ?? []} + onResolve={onResolve} + onUnresolve={onUnresolve} + onReply={onReply} + /> ))} ); } +function RootComment({ + comment, + replies, + onResolve, + onUnresolve, + onReply, +}: { + comment: ReviewComment; + replies: ReviewComment[]; + onResolve: (id: string) => void; + onUnresolve: (id: string) => void; + onReply?: (parentCommentId: string, body: string) => void; +}) { + const [isReplying, setIsReplying] = useState(false); + const replyRef = useRef(null); + + useEffect(() => { + if (isReplying) replyRef.current?.focus(); + }, [isReplying]); + + return ( +
+ {/* Root comment */} +
+
+
+ {comment.author} + {formatTime(comment.createdAt)} + {comment.resolved && ( + + + Resolved + + )} +
+
+ {onReply && !comment.resolved && ( + + )} + {comment.resolved ? ( + + ) : ( + + )} +
+
+

{comment.body}

+
+ + {/* Replies */} + {replies.length > 0 && ( +
+ {replies.map((reply) => ( +
+
+ + {reply.author} + + {formatTime(reply.createdAt)} +
+

{reply.body}

+
+ ))} +
+ )} + + {/* Reply form */} + {isReplying && onReply && ( +
+ { + onReply(comment.id, body); + setIsReplying(false); + }} + onCancel={() => setIsReplying(false)} + placeholder="Write a reply..." + submitLabel="Reply" + /> +
+ )} +
+ ); +} + function formatTime(iso: string): string { const d = new Date(iso); return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 5cec6c5..6fac668 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -12,6 +12,7 @@ interface DiffViewerProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; viewedFiles?: Set; onToggleViewed?: (filePath: string) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; @@ -23,6 +24,7 @@ export function DiffViewer({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, viewedFiles, onToggleViewed, onRegisterRef, @@ -37,6 +39,7 @@ export function DiffViewer({ onAddComment={onAddComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} isViewed={viewedFiles?.has(file.newPath) ?? false} onToggleViewed={() => onToggleViewed?.(file.newPath)} /> diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index a1180e3..5606de4 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -52,6 +52,7 @@ interface FileCardProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; isViewed?: boolean; onToggleViewed?: () => void; } @@ -62,6 +63,7 @@ export function FileCard({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, isViewed = false, onToggleViewed = () => {}, }: FileCardProps) { @@ -157,6 +159,7 @@ export function FileCard({ onAddComment={onAddComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} tokenMap={tokenMap} /> ))} diff --git a/apps/web/src/components/review/HunkRows.tsx b/apps/web/src/components/review/HunkRows.tsx index e8f9038..eaeeb5a 100644 --- a/apps/web/src/components/review/HunkRows.tsx +++ b/apps/web/src/components/review/HunkRows.tsx @@ -15,6 +15,7 @@ interface HunkRowsProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; tokenMap?: LineTokenMap | null; } @@ -25,6 +26,7 @@ export function HunkRows({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, tokenMap, }: HunkRowsProps) { const [commentingLine, setCommentingLine] = useState<{ @@ -98,6 +100,7 @@ export function HunkRows({ onSubmitComment={handleSubmitComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} tokens={ line.newLineNumber !== null ? tokenMap?.get(line.newLineNumber) ?? undefined diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index ac4288f..57d58cf 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -15,6 +15,7 @@ interface LineWithCommentsProps { onSubmitComment: (body: string) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; /** Syntax-highlighted tokens for this line (if available) */ tokens?: TokenizedLine; } @@ -29,6 +30,7 @@ export function LineWithComments({ onSubmitComment, onResolveComment, onUnresolveComment, + onReplyComment, tokens, }: LineWithCommentsProps) { const formRef = useRef(null); @@ -141,6 +143,7 @@ export function LineWithComments({ comments={lineComments} onResolve={onResolveComment} onUnresolve={onUnresolveComment} + onReply={onReplyComment} /> diff --git a/apps/web/src/components/review/ReviewSidebar.tsx b/apps/web/src/components/review/ReviewSidebar.tsx index 3a5eb54..fe8d383 100644 --- a/apps/web/src/components/review/ReviewSidebar.tsx +++ b/apps/web/src/components/review/ReviewSidebar.tsx @@ -183,8 +183,8 @@ function FilesView({ activeFiles: FileDiff[]; viewedFiles: Set; }) { - const unresolvedCount = comments.filter((c) => !c.resolved).length; - const resolvedCount = comments.filter((c) => c.resolved).length; + const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length; + const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length; const activeFilePaths = new Set(activeFiles.map((f) => f.newPath)); const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]); @@ -263,7 +263,7 @@ function FilesView({
{group.files.map((file) => { const fileCommentCount = comments.filter( - (c) => c.filePath === file.newPath, + (c) => c.filePath === file.newPath && !c.parentCommentId, ).length; const isInView = activeFilePaths.has(file.newPath); const dimmed = selectedCommit && !isInView; diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 7a28e3a..cd5d987 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -153,6 +153,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { author: c.author, createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt), resolved: c.resolved, + parentCommentId: c.parentCommentId ?? null, })); }, [commentsQuery.data]); @@ -175,6 +176,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { }, }); + const replyToCommentMutation = trpc.replyToReviewComment.useMutation({ + onSuccess: () => { + utils.listReviewComments.invalidate({ phaseId: activePhaseId! }); + }, + onError: (err) => toast.error(`Failed to post reply: ${err.message}`), + }); + const approveMutation = trpc.approvePhaseReview.useMutation({ onSuccess: () => { setStatus("approved"); @@ -221,6 +229,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { unresolveCommentMutation.mutate({ id: commentId }); }, [unresolveCommentMutation]); + const handleReplyComment = useCallback((parentCommentId: string, body: string) => { + replyToCommentMutation.mutate({ parentCommentId, body }); + }, [replyToCommentMutation]); + const handleApprove = useCallback(() => { if (!activePhaseId) return; approveMutation.mutate({ phaseId: activePhaseId }); @@ -256,7 +268,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { setViewedFiles(new Set()); }, []); - const unresolvedCount = comments.filter((c) => !c.resolved).length; + const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length; const activePhaseName = diffQuery.data?.phaseName ?? @@ -350,6 +362,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { onAddComment={handleAddComment} onResolveComment={handleResolveComment} onUnresolveComment={handleUnresolveComment} + onReplyComment={handleReplyComment} viewedFiles={viewedFiles} onToggleViewed={toggleViewed} onRegisterRef={registerFileRef} diff --git a/apps/web/src/components/review/types.ts b/apps/web/src/components/review/types.ts index 488a832..2b99452 100644 --- a/apps/web/src/components/review/types.ts +++ b/apps/web/src/components/review/types.ts @@ -34,6 +34,7 @@ export interface ReviewComment { author: string; createdAt: string; resolved: boolean; + parentCommentId?: string | null; } export type ReviewStatus = "pending" | "approved" | "changes_requested"; diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 558a325..0c261b7 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -123,5 +123,5 @@ Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are co - **YOLO**: phase completes → auto-merge → auto-dispatch next phase → auto-dispatch tasks - **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks -- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) → phase reset to `in_progress` → agent fixes → phase returns to `pending_review` +- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) with threaded comments (`[comment:ID]` tags + reply threads) → phase reset to `in_progress` → agent reads comments, fixes code, writes `.cw/output/comment-responses.json` → OutputHandler creates reply comments and optionally resolves threads → phase returns to `pending_review` - **request-changes (initiative)**: initiative `pending_review` → user requests changes → creates/reuses "Finalization" phase → creates review task → initiative reset to `active` → agent fixes → initiative returns to `pending_review` diff --git a/docs/frontend.md b/docs/frontend.md index 4c8ebfa..cd95313 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -111,10 +111,11 @@ The initiative detail page has three tabs managed via local state (not URL param ### Review Components (`src/components/review/`) | Component | Purpose | |-----------|---------| -| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has inline comments + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push | +| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push | | `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions | -| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, comment counts, and commit navigation | -| `DiffViewer` | Unified diff renderer with inline comments | +| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation | +| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) | +| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form | | `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) | | `ProposalCard` | Individual proposal display | diff --git a/docs/server-api.md b/docs/server-api.md index 337ec2a..48c62bd 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -116,11 +116,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getPhaseReviewCommits | query | List commits between initiative and phase branch | | getCommitDiff | query | Diff for a single commit (by hash) in a phase | | approvePhaseReview | mutation | Approve and merge phase branch | -| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved comments (dedup guard skips if active review exists), resets phase to in_progress | -| listReviewComments | query | List review comments by phaseId | +| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved threaded comments (with `[comment:ID]` tags and reply threads), resets phase to in_progress | +| listReviewComments | query | List review comments by phaseId (flat list including replies, frontend groups by parentCommentId) | | createReviewComment | mutation | Create inline review comment on diff | | resolveReviewComment | mutation | Mark review comment as resolved | | unresolveReviewComment | mutation | Mark review comment as unresolved | +| replyToReviewComment | mutation | Create a threaded reply to an existing review comment (copies parent's phaseId/filePath/lineNumber) | ### Phase Dispatch | Procedure | Type | Description | From eb667dd3d7e0b9abc3e9e41e412974e887719b4b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:27:44 +0100 Subject: [PATCH 31/85] fix: Register migration 0032 in drizzle journal The migration file existed but wasn't in _journal.json, so drizzle-kit's migrator never applied it. Adds the journal entry for 0032_add_comment_threading. --- apps/server/drizzle/meta/_journal.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 1c5870a..96636a8 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1772236800000, "tag": "0031_add_phase_merge_base", "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "when": 1772323200000, + "tag": "0032_add_comment_threading", + "breakpoints": true } ] } \ No newline at end of file From 03c2abbd8f5b3e4f161f6003461a860f83f9e1ea Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:29:49 +0100 Subject: [PATCH 32/85] =?UTF-8?q?docs:=20Fix=20migration=20workflow=20?= =?UTF-8?q?=E2=80=94=20document=20hand-written=20SQL=20+=20journal=20regis?= =?UTF-8?q?tration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drizzle-kit generate has been broken since migration 0008 (stale snapshots). The actual workflow is hand-written SQL + manual _journal.json registration. Updated CLAUDE.md and database-migrations.md to reflect reality and prevent future migrations from silently failing to apply. --- CLAUDE.md | 2 +- docs/database-migrations.md | 72 ++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f9f778..c4e8e07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Pre-implementation design docs are archived in `docs/archive/`. ## Key Rules -- **Database**: Never use raw SQL for schema initialization. Use `drizzle-kit generate` and the migration system. See [docs/database-migrations.md](docs/database-migrations.md). +- **Database migrations**: Hand-write SQL in `apps/server/drizzle/NNNN_name.sql` AND register it in `apps/server/drizzle/meta/_journal.json` (increment `idx`, set `tag` to filename without `.sql`). Both files must be committed together. Do NOT use `drizzle-kit generate` — snapshots are stale since migration 0008. See [docs/database-migrations.md](docs/database-migrations.md). - **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only. - **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`. - **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`. diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 5d149fe..93b0975 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -1,11 +1,11 @@ # Database Migrations -This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for database schema management and migrations. +This project uses [drizzle-orm](https://orm.drizzle.team/) for database schema management with **hand-written SQL migrations**. ## Overview - **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions) -- **Migration output:** `apps/server/drizzle/` directory (SQL files + meta journal) +- **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json`) - **Config:** `drizzle.config.ts` - **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`) @@ -13,17 +13,55 @@ This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly. +The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json` — **not** by scanning the filesystem. A migration SQL file that isn't registered in the journal will never be applied. + ## Workflow ### Making schema changes +**Do NOT use `drizzle-kit generate`** — the snapshots in `meta/` have been stale since migration 0008 and `drizzle-kit generate` will produce incorrect interactive prompts. All migrations since 0008 are hand-written. + 1. Edit `apps/server/db/schema.ts` with your table/column changes -2. Generate a migration: - ```bash - npx drizzle-kit generate - ``` -3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql` -4. Commit the migration file along with your schema change +2. Create a new SQL migration file: `apps/server/drizzle/NNNN_descriptive_name.sql` + - Number it sequentially (check the last migration number) + - Write the SQL (ALTER TABLE, CREATE INDEX, etc.) +3. **Register it in the journal**: edit `apps/server/drizzle/meta/_journal.json` + - Add a new entry at the end of the `entries` array: + ```json + { + "idx": , + "version": "6", + "when": , + "tag": "NNNN_descriptive_name", + "breakpoints": true + } + ``` + - `idx`: sequential (previous entry's idx + 1) + - `tag`: migration filename **without `.sql` extension** + - `when`: any timestamp in milliseconds (e.g., previous + 86400000) +4. Commit **both** the SQL file and the updated `_journal.json` together +5. Run `npm run build && npm link` to pick up the changes + +### Example + +Adding a column to an existing table: + +```sql +-- apps/server/drizzle/0032_add_comment_threading.sql +ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE; +CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id); +``` + +```json +// In meta/_journal.json entries array: +{ + "idx": 32, + "version": "6", + "when": 1772323200000, + "tag": "0032_add_comment_threading", + "breakpoints": true +} +``` ### Applying migrations @@ -31,20 +69,12 @@ Migrations are applied automatically on server startup. No manual step needed. For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`. -### Checking migration status - -```bash -# See what drizzle-kit would generate (dry run) -npx drizzle-kit generate --dry-run - -# Open drizzle studio to inspect the database -npx drizzle-kit studio -``` - ## Rules -- **Never hand-write migration SQL.** Always use `drizzle-kit generate` from the schema. +- **Always hand-write migration SQL.** Do not use `drizzle-kit generate` (stale snapshots). +- **Always register in `_journal.json`.** The migrator reads the journal, not the filesystem. +- **Commit SQL + journal together.** A migration file without a journal entry is invisible to the migrator. - **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this. -- **Always commit migration files.** They are the source of truth for database evolution. - **Migration files are immutable.** Once committed, never edit them. Make a new migration instead. -- **Test with `npx vitest run`** after generating migrations to verify they work with in-memory databases. +- **Test with `npm test`** after creating migrations to verify they work with in-memory databases. +- **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure. From 4656627a592f3ebc38ac6e81e704f44afb0f4715 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:36:07 +0100 Subject: [PATCH 33/85] fix: Restore drizzle-kit generate by syncing snapshot baseline Snapshots were stale since migration 0008. Generated a schema-derived snapshot at 0032 so drizzle-kit generate works again (zero diff on current schema.ts). Also fixed migration 0032 to use statement-breakpoint separator required by better-sqlite3. - Added 0032_snapshot.json derived from current schema.ts - Fixed 0032 SQL to use --> statement-breakpoint between statements - Updated CLAUDE.md and database-migrations.md with correct workflow --- CLAUDE.md | 2 +- .../drizzle/0032_add_comment_threading.sql | 2 +- apps/server/drizzle/meta/0032_snapshot.json | 1864 +++++++++++++++++ docs/database-migrations.md | 65 +- 4 files changed, 1887 insertions(+), 46 deletions(-) create mode 100644 apps/server/drizzle/meta/0032_snapshot.json diff --git a/CLAUDE.md b/CLAUDE.md index c4e8e07..4bf881c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Pre-implementation design docs are archived in `docs/archive/`. ## Key Rules -- **Database migrations**: Hand-write SQL in `apps/server/drizzle/NNNN_name.sql` AND register it in `apps/server/drizzle/meta/_journal.json` (increment `idx`, set `tag` to filename without `.sql`). Both files must be committed together. Do NOT use `drizzle-kit generate` — snapshots are stale since migration 0008. See [docs/database-migrations.md](docs/database-migrations.md). +- **Database migrations**: Edit `apps/server/db/schema.ts`, then run `npx drizzle-kit generate`. Multi-statement migrations need `--> statement-breakpoint` between statements. See [docs/database-migrations.md](docs/database-migrations.md). - **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only. - **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`. - **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`. diff --git a/apps/server/drizzle/0032_add_comment_threading.sql b/apps/server/drizzle/0032_add_comment_threading.sql index fd17e7a..11afcd0 100644 --- a/apps/server/drizzle/0032_add_comment_threading.sql +++ b/apps/server/drizzle/0032_add_comment_threading.sql @@ -1,2 +1,2 @@ -ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE; +ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE;--> statement-breakpoint CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id); diff --git a/apps/server/drizzle/meta/0032_snapshot.json b/apps/server/drizzle/meta/0032_snapshot.json new file mode 100644 index 0000000..28a0410 --- /dev/null +++ b/apps/server/drizzle/meta/0032_snapshot.json @@ -0,0 +1,1864 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5fbe1151-1dfb-4b0c-a7fa-2177369543fd", + "prevId": "c0b6d7d3-c9da-440a-9fb8-9dd88df5672a", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_log_chunks": { + "name": "agent_log_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_number": { + "name": "session_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_log_chunks_agent_id_idx": { + "name": "agent_log_chunks_agent_id_idx", + "columns": [ + "agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_dismissed_at": { + "name": "user_dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_set_entries": { + "name": "change_set_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_state": { + "name": "previous_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_state": { + "name": "new_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_set_entries_change_set_id_idx": { + "name": "change_set_entries_change_set_id_idx", + "columns": [ + "change_set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_set_entries_change_set_id_change_sets_id_fk": { + "name": "change_set_entries_change_set_id_change_sets_id_fk", + "tableFrom": "change_set_entries", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_sets": { + "name": "change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'applied'" + }, + "reverted_at": { + "name": "reverted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_sets_initiative_id_idx": { + "name": "change_sets_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_sets_agent_id_agents_id_fk": { + "name": "change_sets_agent_id_agents_id_fk", + "tableFrom": "change_sets", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "change_sets_initiative_id_initiatives_id_fk": { + "name": "change_sets_initiative_id_initiatives_id_fk", + "tableFrom": "change_sets", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_session_id_idx": { + "name": "chat_messages_session_id_idx", + "columns": [ + "chat_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_chat_session_id_chat_sessions_id_fk": { + "name": "chat_messages_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_change_set_id_change_sets_id_fk": { + "name": "chat_messages_change_set_id_change_sets_id_fk", + "tableFrom": "chat_messages", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_sessions_target_idx": { + "name": "chat_sessions_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "chat_sessions_initiative_id_idx": { + "name": "chat_sessions_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_initiative_id_initiatives_id_fk": { + "name": "chat_sessions_initiative_id_initiatives_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_agent_id_agents_id_fk": { + "name": "chat_sessions_agent_id_agents_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_agent_id": { + "name": "from_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_agent_id": { + "name": "to_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_to_agent_status_idx": { + "name": "conversations_to_agent_status_idx", + "columns": [ + "to_agent_id", + "status" + ], + "isUnique": false + }, + "conversations_from_agent_idx": { + "name": "conversations_from_agent_idx", + "columns": [ + "from_agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_from_agent_id_agents_id_fk": { + "name": "conversations_from_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "from_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_to_agent_id_agents_id_fk": { + "name": "conversations_to_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "to_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_initiative_id_initiatives_id_fk": { + "name": "conversations_initiative_id_initiatives_id_fk", + "tableFrom": "conversations", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_phase_id_phases_id_fk": { + "name": "conversations_phase_id_phases_id_fk", + "tableFrom": "conversations", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'review_per_phase'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "depends_on_phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "merge_base": { + "name": "merge_base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "review_comments": { + "name": "review_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_type": { + "name": "line_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'you'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "review_comments_phase_id_idx": { + "name": "review_comments_phase_id_idx", + "columns": [ + "phase_id" + ], + "isUnique": false + }, + "review_comments_parent_id_idx": { + "name": "review_comments_parent_id_idx", + "columns": [ + "parent_comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "review_comments_phase_id_phases_id_fk": { + "name": "review_comments_phase_id_phases_id_fk", + "tableFrom": "review_comments", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_parent_comment_id_review_comments_id_fk": { + "name": "review_comments_parent_comment_id_review_comments_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "depends_on_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_parent_task_id_tasks_id_fk": { + "name": "tasks_parent_task_id_tasks_id_fk", + "tableFrom": "tasks", + "tableTo": "tasks", + "columnsFrom": [ + "parent_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 93b0975..603d49e 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -1,11 +1,11 @@ # Database Migrations -This project uses [drizzle-orm](https://orm.drizzle.team/) for database schema management with **hand-written SQL migrations**. +This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for database schema management and migrations. ## Overview - **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions) -- **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json`) +- **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json` + `meta/NNNN_snapshot.json`) - **Config:** `drizzle.config.ts` - **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`) @@ -13,54 +13,28 @@ This project uses [drizzle-orm](https://orm.drizzle.team/) for database schema m On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly. -The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json` — **not** by scanning the filesystem. A migration SQL file that isn't registered in the journal will never be applied. +The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json` — **not** by scanning the filesystem. ## Workflow ### Making schema changes -**Do NOT use `drizzle-kit generate`** — the snapshots in `meta/` have been stale since migration 0008 and `drizzle-kit generate` will produce incorrect interactive prompts. All migrations since 0008 are hand-written. - 1. Edit `apps/server/db/schema.ts` with your table/column changes -2. Create a new SQL migration file: `apps/server/drizzle/NNNN_descriptive_name.sql` - - Number it sequentially (check the last migration number) - - Write the SQL (ALTER TABLE, CREATE INDEX, etc.) -3. **Register it in the journal**: edit `apps/server/drizzle/meta/_journal.json` - - Add a new entry at the end of the `entries` array: - ```json - { - "idx": , - "version": "6", - "when": , - "tag": "NNNN_descriptive_name", - "breakpoints": true - } - ``` - - `idx`: sequential (previous entry's idx + 1) - - `tag`: migration filename **without `.sql` extension** - - `when`: any timestamp in milliseconds (e.g., previous + 86400000) -4. Commit **both** the SQL file and the updated `_journal.json` together -5. Run `npm run build && npm link` to pick up the changes +2. Generate a migration: + ```bash + npx drizzle-kit generate + ``` +3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql` +4. Verify multi-statement migrations use `--> statement-breakpoint` between statements (required by better-sqlite3 which only allows one statement per `prepare()` call) +5. Commit the migration file, snapshot, and journal update together -### Example +### Important: statement breakpoints -Adding a column to an existing table: +better-sqlite3 rejects SQL with multiple statements in a single `prepare()` call. Drizzle-kit splits on `--> statement-breakpoint`. If you hand-write or edit a migration with multiple statements, append `--> statement-breakpoint` to the end of each statement line (before the next statement): ```sql --- apps/server/drizzle/0032_add_comment_threading.sql -ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE; -CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id); -``` - -```json -// In meta/_journal.json entries array: -{ - "idx": 32, - "version": "6", - "when": 1772323200000, - "tag": "0032_add_comment_threading", - "breakpoints": true -} +ALTER TABLE foo ADD COLUMN bar TEXT;--> statement-breakpoint +CREATE INDEX foo_bar_idx ON foo(bar); ``` ### Applying migrations @@ -69,12 +43,15 @@ Migrations are applied automatically on server startup. No manual step needed. For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`. +## History + +Migrations 0000–0007 were generated by `drizzle-kit generate`. Migrations 0008–0032 were hand-written (the snapshots fell out of sync). A schema-derived snapshot was restored at 0032, so `drizzle-kit generate` works normally from that point forward. + ## Rules -- **Always hand-write migration SQL.** Do not use `drizzle-kit generate` (stale snapshots). -- **Always register in `_journal.json`.** The migrator reads the journal, not the filesystem. -- **Commit SQL + journal together.** A migration file without a journal entry is invisible to the migrator. +- **Use `drizzle-kit generate`** for new migrations. It reads schema.ts, diffs against the last snapshot, and generates both SQL + snapshot automatically. - **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this. +- **Always commit migration files.** They are the source of truth for database evolution. - **Migration files are immutable.** Once committed, never edit them. Make a new migration instead. -- **Test with `npm test`** after creating migrations to verify they work with in-memory databases. - **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure. +- **Test with `npm test`** after generating migrations to verify they work with in-memory databases. From 1e2819eeff8c8f326540d9022f13218572b83000 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:43:36 +0100 Subject: [PATCH 34/85] fix: Request changes uses in-app confirmation, requires review comments - Replace window.prompt with in-app dropdown confirmation (matches merge dialog pattern) - Disable button when no unresolved comments exist (comments-only, no summary) - Remove initiative-level request changes button (no comment system there) --- .../components/review/InitiativeReview.tsx | 28 +------ .../src/components/review/ReviewHeader.tsx | 82 +++++++++++++++---- apps/web/src/components/review/ReviewTab.tsx | 4 +- 3 files changed, 68 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 5f7a36b..fc0cd7a 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, RotateCcw } from "lucide-react"; +import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; import { Button } from "@/components/ui/button"; import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; @@ -82,14 +82,6 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview onError: (err) => toast.error(`Failed to stop: ${err.message}`), }); - const requestChangesMutation = trpc.requestInitiativeChanges.useMutation({ - onSuccess: () => { - toast.success("Changes requested — review task created"); - onCompleted(); - }, - onError: (err) => toast.error(err.message), - }); - const approveMutation = trpc.approveInitiativeReview.useMutation({ onSuccess: (_data, variables) => { const msg = variables.strategy === "merge_and_push" @@ -197,24 +189,6 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview {/* Right: preview + action buttons */}
{previewState && } - + + {showRequestConfirm && ( +
+

+ Request changes? +

+
+
+ + + {unresolvedCount} unresolved {unresolvedCount === 1 ? "comment" : "comments"} will be sent + +
+
+
+ + +
+
)} - Request Changes - +
From 157fa445c53ed40d0b75423810050ac6a7dd506c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:03:27 +0100 Subject: [PATCH 37/85] fix: Show individual discussion threads in review sidebar with navigation Discussions section was showing only aggregate counts (total/resolved/unresolved) with no way to see or navigate to individual threads. Now lists each root comment with file:line location, body preview, resolved status, and reply count. Clicking a discussion scrolls to its file in the diff viewer. --- .../src/components/review/ReviewSidebar.tsx | 77 ++++++++++++++----- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/review/ReviewSidebar.tsx b/apps/web/src/components/review/ReviewSidebar.tsx index fe8d383..cc7042e 100644 --- a/apps/web/src/components/review/ReviewSidebar.tsx +++ b/apps/web/src/components/review/ReviewSidebar.tsx @@ -213,29 +213,66 @@ function FilesView({ )} - {/* Comment summary */} + {/* Discussions — individual threads */} {comments.length > 0 && (
-

- Discussions -

-
- - - {comments.length} +

+ Discussions + + {unresolvedCount > 0 && ( + + + {unresolvedCount} + + )} + {resolvedCount > 0 && ( + + + {resolvedCount} + + )} - {resolvedCount > 0 && ( - - - {resolvedCount} - - )} - {unresolvedCount > 0 && ( - - - {unresolvedCount} - - )} +

+
+ {comments + .filter((c) => !c.parentCommentId) + .map((thread) => { + const replyCount = comments.filter( + (c) => c.parentCommentId === thread.id, + ).length; + return ( + + ); + })}
)} From 14d09b16dfd988c90ac73de2c582664861095a39 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:07:01 +0100 Subject: [PATCH 38/85] fix: Phase completion check runs regardless of branch/merge status handleTaskCompleted and handlePhaseAllTasksDone both bailed early when initiative had no branch, silently skipping phase status transitions. Also, merge failures would skip the phase completion check entirely. - Decouple phase completion check from branch existence - Wrap merge in try/catch so phase check runs even if merge fails - Route updateTaskStatus through dispatchManager.completeTask when completing, so the task:completed event fires for orchestration --- apps/server/execution/orchestrator.test.ts | 306 +++++++++++++++++++++ apps/server/execution/orchestrator.ts | 39 +-- apps/server/trpc/routers/task.ts | 8 + 3 files changed, 336 insertions(+), 17 deletions(-) create mode 100644 apps/server/execution/orchestrator.test.ts diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts new file mode 100644 index 0000000..57efcd5 --- /dev/null +++ b/apps/server/execution/orchestrator.test.ts @@ -0,0 +1,306 @@ +/** + * ExecutionOrchestrator Tests + * + * Tests phase completion transitions, especially when initiative has no branch. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExecutionOrchestrator } from './orchestrator.js'; +import type { BranchManager } from '../git/branch-manager.js'; +import type { PhaseRepository } from '../db/repositories/phase-repository.js'; +import type { TaskRepository } from '../db/repositories/task-repository.js'; +import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; +import type { ProjectRepository } from '../db/repositories/project-repository.js'; +import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; +import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js'; +import type { EventBus, TaskCompletedEvent, DomainEvent } from '../events/types.js'; + +function createMockEventBus(): EventBus & { handlers: Map; emitted: DomainEvent[] } { + const handlers = new Map(); + const emitted: DomainEvent[] = []; + return { + handlers, + emitted, + emit: vi.fn((event: DomainEvent) => { + emitted.push(event); + const fns = handlers.get(event.type) ?? []; + for (const fn of fns) fn(event); + }), + on: vi.fn((type: string, handler: Function) => { + const fns = handlers.get(type) ?? []; + fns.push(handler); + handlers.set(type, fns); + }), + off: vi.fn(), + once: vi.fn(), + }; +} + +function createMocks() { + const branchManager: BranchManager = { + ensureBranch: vi.fn(), + mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged' }), + diffBranches: vi.fn().mockResolvedValue(''), + deleteBranch: vi.fn(), + branchExists: vi.fn().mockResolvedValue(true), + remoteBranchExists: vi.fn().mockResolvedValue(false), + listCommits: vi.fn().mockResolvedValue([]), + diffCommit: vi.fn().mockResolvedValue(''), + getMergeBase: vi.fn().mockResolvedValue('abc123'), + pushBranch: vi.fn(), + }; + + const phaseRepository = { + findById: vi.fn(), + findByInitiativeId: vi.fn().mockResolvedValue([]), + update: vi.fn().mockImplementation(async (id: string, data: any) => ({ id, ...data })), + create: vi.fn(), + } as unknown as PhaseRepository; + + const taskRepository = { + findById: vi.fn(), + findByPhaseId: vi.fn().mockResolvedValue([]), + findByInitiativeId: vi.fn().mockResolvedValue([]), + } as unknown as TaskRepository; + + const initiativeRepository = { + findById: vi.fn(), + findByStatus: vi.fn().mockResolvedValue([]), + update: vi.fn(), + } as unknown as InitiativeRepository; + + const projectRepository = { + findProjectsByInitiativeId: vi.fn().mockResolvedValue([]), + } as unknown as ProjectRepository; + + const phaseDispatchManager: PhaseDispatchManager = { + queuePhase: vi.fn(), + getNextDispatchablePhase: vi.fn().mockResolvedValue(null), + dispatchNextPhase: vi.fn().mockResolvedValue({ success: false, phaseId: '', reason: 'none' }), + completePhase: vi.fn(), + blockPhase: vi.fn(), + getPhaseQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), + }; + + const dispatchManager = { + queue: vi.fn(), + getNextDispatchable: vi.fn().mockResolvedValue(null), + dispatchNext: vi.fn().mockResolvedValue({ success: false, taskId: '' }), + completeTask: vi.fn(), + blockTask: vi.fn(), + retryBlockedTask: vi.fn(), + getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), + } as unknown as DispatchManager; + + const conflictResolutionService: ConflictResolutionService = { + handleConflict: vi.fn(), + }; + + const eventBus = createMockEventBus(); + + return { + branchManager, + phaseRepository, + taskRepository, + initiativeRepository, + projectRepository, + phaseDispatchManager, + dispatchManager, + conflictResolutionService, + eventBus, + }; +} + +function createOrchestrator(mocks: ReturnType) { + const orchestrator = new ExecutionOrchestrator( + mocks.branchManager, + mocks.phaseRepository, + mocks.taskRepository, + mocks.initiativeRepository, + mocks.projectRepository, + mocks.phaseDispatchManager, + mocks.dispatchManager, + mocks.conflictResolutionService, + mocks.eventBus, + '/tmp/test-workspace', + ); + orchestrator.start(); + return orchestrator; +} + +function emitTaskCompleted(eventBus: ReturnType, taskId: string) { + const event: TaskCompletedEvent = { + type: 'task:completed', + timestamp: new Date(), + payload: { taskId, agentId: 'agent-1', success: true, message: 'done' }, + }; + eventBus.emit(event); +} + +describe('ExecutionOrchestrator', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = createMocks(); + }); + + describe('phase completion when initiative has no branch', () => { + it('should transition phase to pending_review in review mode even without a branch', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: null, executionMode: 'review_per_phase' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + // Allow async handler to complete + await vi.waitFor(() => { + expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' }); + }); + }); + + it('should complete phase in yolo mode even without a branch', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: null, executionMode: 'yolo' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.initiativeRepository.findByStatus).mockResolvedValue([]); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + vi.mocked(mocks.phaseRepository.findByInitiativeId).mockResolvedValue([phase] as any); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + await vi.waitFor(() => { + expect(mocks.phaseDispatchManager.completePhase).toHaveBeenCalledWith('phase-1'); + }); + + // Should NOT have attempted any branch merges + expect(mocks.branchManager.mergeBranch).not.toHaveBeenCalled(); + }); + }); + + describe('phase completion when merge fails', () => { + it('should still check phase completion even if task merge throws', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' }; + const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any); + // Merge fails + vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ + success: false, + message: 'conflict', + conflicts: ['file.ts'], + }); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + // Phase should still transition despite merge failure + await vi.waitFor(() => { + expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' }); + }); + }); + }); + + describe('phase completion with branch (normal flow)', () => { + it('should merge task branch and transition phase when all tasks done', async () => { + const task = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' }; + const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any); + vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true); + vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok' }); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + await vi.waitFor(() => { + expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' }); + }); + }); + + it('should not transition phase when some tasks are still pending', async () => { + const task1 = { + id: 'task-1', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'completed', + }; + const task2 = { + id: 'task-2', + phaseId: 'phase-1', + initiativeId: 'init-1', + category: 'execute', + status: 'pending', + }; + const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' }; + const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' }; + + vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task1 as any); + vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any); + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task1, task2] as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([]); + + createOrchestrator(mocks); + + emitTaskCompleted(mocks.eventBus, 'task-1'); + + // Give the async handler time to run + await new Promise((r) => setTimeout(r, 50)); + + expect(mocks.phaseRepository.update).not.toHaveBeenCalled(); + expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 973cc59..2883f18 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -145,27 +145,29 @@ export class ExecutionOrchestrator { if (!task?.phaseId || !task.initiativeId) return; const initiative = await this.initiativeRepository.findById(task.initiativeId); - if (!initiative?.branch) return; - const phase = await this.phaseRepository.findById(task.phaseId); if (!phase) return; - // Skip merge for review/merge tasks — they already work on the phase branch directly - if (task.category !== 'merge' && task.category !== 'review') { - const initBranch = initiative.branch; - const phBranch = phaseBranchName(initBranch, phase.name); - const tBranch = taskBranchName(initBranch, task.id); + // Merge task branch into phase branch (only when branches exist) + if (initiative?.branch && task.category !== 'merge' && task.category !== 'review') { + try { + const initBranch = initiative.branch; + const phBranch = phaseBranchName(initBranch, phase.name); + const tBranch = taskBranchName(initBranch, task.id); - // Serialize merges per phase - const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); - const mergeOp = lock.then(async () => { - await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); - }); - this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); - await mergeOp; + // Serialize merges per phase + const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve(); + const mergeOp = lock.then(async () => { + await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch); + }); + this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {})); + await mergeOp; + } catch (err) { + log.error({ taskId, err: err instanceof Error ? err.message : String(err) }, 'task merge failed, still checking phase completion'); + } } - // Check if all phase tasks are done + // Check if all phase tasks are done — always, regardless of branch/merge status const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId); const allDone = phaseTasks.every((t) => t.status === 'completed'); if (allDone) { @@ -233,10 +235,13 @@ export class ExecutionOrchestrator { if (!phase) return; const initiative = await this.initiativeRepository.findById(phase.initiativeId); - if (!initiative?.branch) return; + if (!initiative) return; if (initiative.executionMode === 'yolo') { - await this.mergePhaseIntoInitiative(phaseId); + // Merge phase branch into initiative branch (only when branches exist) + if (initiative.branch) { + await this.mergePhaseIntoInitiative(phaseId); + } await this.phaseDispatchManager.completePhase(phaseId); // Re-queue approved phases (self-healing: survives server restarts that wipe in-memory queue) diff --git a/apps/server/trpc/routers/task.ts b/apps/server/trpc/routers/task.ts index 1f21074..44f0e95 100644 --- a/apps/server/trpc/routers/task.ts +++ b/apps/server/trpc/routers/task.ts @@ -50,6 +50,14 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { message: `Task '${input.id}' not found`, }); } + + // Route through dispatchManager when completing — emits task:completed + // event so the orchestrator can check phase completion and merge branches + if (input.status === 'completed' && ctx.dispatchManager) { + await ctx.dispatchManager.completeTask(input.id); + return (await taskRepository.findById(input.id))!; + } + return taskRepository.update(input.id, { status: input.status }); }), From b1233dd013110ed634088cd698d2ee010cba036f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:07:28 +0100 Subject: [PATCH 39/85] docs: Update dispatch-events for branchless phase completion --- docs/dispatch-events.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 0c261b7..5356b0e 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -113,7 +113,7 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId } |-------|--------| | `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents | | `agent:stopped` | Re-dispatch queued tasks (freed agent slot) | -| `task:completed` | Merge task branch, then dispatch next queued task | +| `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task | ### Coalesced Scheduling @@ -121,7 +121,8 @@ Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are co ### Execution Mode Behavior -- **YOLO**: phase completes → auto-merge → auto-dispatch next phase → auto-dispatch tasks +- **YOLO**: phase completes → auto-merge (if branch exists, skipped otherwise) → auto-dispatch next phase → auto-dispatch tasks - **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks +- **No branch**: Phase completion check runs regardless of branch existence. Merge steps are skipped; status transitions still fire. `updateTaskStatus` tRPC routes completions through `dispatchManager.completeTask()` to emit `task:completed`. - **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) with threaded comments (`[comment:ID]` tags + reply threads) → phase reset to `in_progress` → agent reads comments, fixes code, writes `.cw/output/comment-responses.json` → OutputHandler creates reply comments and optionally resolves threads → phase returns to `pending_review` - **request-changes (initiative)**: initiative `pending_review` → user requests changes → creates/reuses "Finalization" phase → creates review task → initiative reset to `active` → agent fixes → initiative returns to `pending_review` From 3fcfa619149786d4f9903ee5eb28cb43f60850ab Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:09:07 +0100 Subject: [PATCH 40/85] fix: Scroll to exact comment location when clicking sidebar discussions Adds data-comment-id attributes to comment thread rows so clicking a discussion in the sidebar scrolls directly to the comment, not just the file card. Includes a brief ring highlight on the target row. --- apps/web/src/components/review/LineWithComments.tsx | 2 +- apps/web/src/components/review/ReviewSidebar.tsx | 7 ++++++- apps/web/src/components/review/ReviewTab.tsx | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index 57d58cf..579db1b 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -134,7 +134,7 @@ export function LineWithComments({ {/* Existing comments on this line */} {lineComments.length > 0 && ( - + !c.parentCommentId)?.id}> void; + onCommentClick?: (commentId: string) => void; selectedCommit: string | null; activeFiles: FileDiff[]; commits: CommitInfo[]; @@ -29,6 +30,7 @@ export function ReviewSidebar({ files, comments, onFileClick, + onCommentClick, selectedCommit, activeFiles, commits, @@ -63,6 +65,7 @@ export function ReviewSidebar({ files={files} comments={comments} onFileClick={onFileClick} + onCommentClick={onCommentClick} selectedCommit={selectedCommit} activeFiles={activeFiles} viewedFiles={viewedFiles} @@ -172,6 +175,7 @@ function FilesView({ files, comments, onFileClick, + onCommentClick, selectedCommit, activeFiles, viewedFiles, @@ -179,6 +183,7 @@ function FilesView({ files: FileDiff[]; comments: ReviewComment[]; onFileClick: (filePath: string) => void; + onCommentClick?: (commentId: string) => void; selectedCommit: string | null; activeFiles: FileDiff[]; viewedFiles: Set; @@ -248,7 +253,7 @@ function FilesView({ transition-colors hover:bg-accent/50 ${thread.resolved ? "opacity-50" : ""} `} - onClick={() => onFileClick(thread.filePath)} + onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)} >
{thread.resolved ? ( diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index fbeb072..164ec52 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -259,6 +259,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } }, []); + const handleCommentClick = useCallback((commentId: string) => { + const el = document.querySelector(`[data-comment-id="${commentId}"]`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Brief highlight flash + el.classList.add("ring-2", "ring-primary/50"); + setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500); + } + }, []); + const handlePhaseSelect = useCallback((id: string) => { setSelectedPhaseId(id); setSelectedCommit(null); @@ -331,6 +341,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { files={allFiles} comments={comments} onFileClick={handleFileClick} + onCommentClick={handleCommentClick} selectedCommit={selectedCommit} activeFiles={files} commits={commits} From 50043f4c6138923036d03dd0c9568ac6545ab282 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:10:50 +0100 Subject: [PATCH 41/85] fix: Use instant scroll for discussion navigation --- apps/web/src/components/review/ReviewTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 164ec52..494a7f9 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -262,7 +262,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const handleCommentClick = useCallback((commentId: string) => { const el = document.querySelector(`[data-comment-id="${commentId}"]`); if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.scrollIntoView({ behavior: "instant", block: "center" }); // Brief highlight flash el.classList.add("ring-2", "ring-primary/50"); setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500); From d4b466ce6dbd7855171c29668621470ae8be8a60 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:11:46 +0100 Subject: [PATCH 42/85] fix: Make review sidebar sticky to viewport with internal scrolling Removed overflow-hidden from grid container and changed sidebar to sticky positioning with viewport-relative max-height. Sidebar content scrolls independently while staying visible during diff navigation. --- apps/web/src/components/review/ReviewTab.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 494a7f9..ec78480 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -333,10 +333,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { /> {/* Main content area — sidebar always rendered to preserve state */} -
- {/* Left: Sidebar — sticky so icon strip stays visible */} +
+ {/* Left: Sidebar — sticky to viewport, scrolls independently */}
-
+
Date: Fri, 6 Mar 2026 11:15:02 +0100 Subject: [PATCH 43/85] fix: Sidebar accounts for sticky header height via ResizeObserver Measures the review header dynamically and offsets the sidebar's sticky top and max-height accordingly, eliminating the gap when scrolled. --- apps/web/src/components/review/ReviewTab.tsx | 24 ++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index ec78480..91c7c4e 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { trpc } from "@/lib/trpc"; @@ -18,6 +18,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const [selectedCommit, setSelectedCommit] = useState(null); const [viewedFiles, setViewedFiles] = useState>(new Set()); const fileRefs = useRef>(new Map()); + const headerRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(0); + + useEffect(() => { + const el = headerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setHeaderHeight(entry.contentRect.height); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); const toggleViewed = useCallback((filePath: string) => { setViewedFiles(prev => { @@ -313,6 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} +
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} @@ -331,12 +344,19 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { viewedCount={viewedFiles.size} totalCount={allFiles.length} /> +
{/* Main content area — sidebar always rendered to preserve state */}
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
-
+
Date: Fri, 6 Mar 2026 11:15:25 +0100 Subject: [PATCH 44/85] fix: Restore sticky positioning on header wrapper div --- apps/web/src/components/review/ReviewTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 91c7c4e..a532253 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -325,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} -
+
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} From 6cf6bd076f25482971ea6ec1829bccd48027e14f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:17:25 +0100 Subject: [PATCH 45/85] feat: Add merge conflict detection and agent resolution in initiative review Pre-merge mergeability check via `git merge-tree --write-tree` (dry-run, no side effects). When conflicts exist the "Merge & Push" button is disabled and a ConflictResolutionPanel shows conflict files with options to resolve manually or spawn a conflict-resolution agent. Agent questions appear inline via QuestionForm; on completion the mergeability re-checks automatically. New server-side: MergeabilityResult type, BranchManager.checkMergeability, conflict-resolution prompt, checkInitiativeMergeability query, spawnConflictResolutionAgent mutation, getActiveConflictAgent query. New frontend: useConflictAgent hook, ConflictResolutionPanel component, mergeability badge + panel integration in InitiativeReview. --- .../agent/prompts/conflict-resolution.ts | 74 ++++++ apps/server/agent/prompts/index.ts | 1 + apps/server/execution/orchestrator.test.ts | 1 + apps/server/git/branch-manager.ts | 9 +- apps/server/git/index.ts | 2 +- apps/server/git/simple-git-branch-manager.ts | 39 +++- apps/server/git/types.ts | 13 ++ apps/server/trpc/routers/agent.ts | 21 ++ apps/server/trpc/routers/initiative.ts | 125 +++++++++- .../review/ConflictResolutionPanel.tsx | 182 +++++++++++++++ .../components/review/InitiativeReview.tsx | 56 ++++- apps/web/src/hooks/index.ts | 8 +- apps/web/src/hooks/useConflictAgent.ts | 214 ++++++++++++++++++ docs/agent.md | 2 +- docs/frontend.md | 2 + docs/git-process-logging.md | 2 + docs/server-api.md | 3 + 17 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 apps/server/agent/prompts/conflict-resolution.ts create mode 100644 apps/web/src/components/review/ConflictResolutionPanel.tsx create mode 100644 apps/web/src/hooks/useConflictAgent.ts diff --git a/apps/server/agent/prompts/conflict-resolution.ts b/apps/server/agent/prompts/conflict-resolution.ts new file mode 100644 index 0000000..876df7b --- /dev/null +++ b/apps/server/agent/prompts/conflict-resolution.ts @@ -0,0 +1,74 @@ +/** + * Conflict resolution prompt — spawned when initiative branch has merge conflicts + * with the target branch. + */ + +import { + SIGNAL_FORMAT, + SESSION_STARTUP, + GIT_WORKFLOW, + CONTEXT_MANAGEMENT, +} from './shared.js'; + +export function buildConflictResolutionPrompt( + sourceBranch: string, + targetBranch: string, + conflicts: string[], +): string { + const conflictList = conflicts.map(f => `- \`${f}\``).join('\n'); + + return ` +You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` into \`${sourceBranch}\` and resolve all merge conflicts so the initiative branch is up to date with the target branch. + + + +**Source branch (initiative):** \`${sourceBranch}\` +**Target branch (default):** \`${targetBranch}\` + +**Conflicting files:** +${conflictList} + +${SIGNAL_FORMAT} +${SESSION_STARTUP} + + +Follow these steps in order: + +1. **Inspect divergence**: Run \`git log --oneline ${targetBranch}..${sourceBranch}\` and \`git log --oneline ${sourceBranch}..${targetBranch}\` to understand what each side changed. + +2. **Review conflicting files**: For each conflicting file, read both versions: + - \`git show ${sourceBranch}:\` + - \`git show ${targetBranch}:\` + +3. **Merge**: Run \`git merge ${targetBranch} --no-edit\`. This will produce conflict markers. + +4. **Resolve each file**: For each conflicting file: + - Read the file to see conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) + - Understand both sides' intent from step 1-2 + - Choose the correct resolution — keep both changes when they don't overlap, prefer the more complete version when they do + - If you genuinely cannot determine the correct resolution, signal "questions" explaining the ambiguity + +5. **Verify**: Run \`git diff --check\` to confirm no conflict markers remain. Run the test suite to confirm nothing is broken. + +6. **Commit**: Stage resolved files with \`git add \` (never \`git add .\`), then \`git commit --no-edit\` to complete the merge commit. + +7. **Signal done**: Write signal.json with status "done". + +${GIT_WORKFLOW} +${CONTEXT_MANAGEMENT} + + +- You are merging ${targetBranch} INTO ${sourceBranch} — bringing the initiative branch up to date, NOT the other way around. +- Do NOT force-push or rebase. A merge commit is the correct approach. +- If tests fail after resolution, fix the code — don't skip tests. +- If a conflict is genuinely ambiguous (e.g., both sides rewrote the same function differently), signal "questions" with the specific ambiguity and your proposed resolution. +`; +} + +export function buildConflictResolutionDescription( + sourceBranch: string, + targetBranch: string, + conflicts: string[], +): string { + return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 7722872..2186994 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -15,3 +15,4 @@ export { buildChatPrompt } from './chat.js'; export type { ChatHistoryEntry } from './chat.js'; export { buildWorkspaceLayout } from './workspace.js'; export { buildPreviewInstructions } from './preview.js'; +export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js'; diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index 57efcd5..698b9d2 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -48,6 +48,7 @@ function createMocks() { diffCommit: vi.fn().mockResolvedValue(''), getMergeBase: vi.fn().mockResolvedValue('abc123'), pushBranch: vi.fn(), + checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), }; const phaseRepository = { diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 113ac7b..901413d 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -6,7 +6,7 @@ * a worktree to be checked out. */ -import type { MergeResult, BranchCommit } from './types.js'; +import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js'; export interface BranchManager { /** @@ -68,4 +68,11 @@ export interface BranchManager { * Defaults to 'origin' if no remote specified. */ pushBranch(repoPath: string, branch: string, remote?: string): Promise; + + /** + * Dry-run merge check — determines if sourceBranch can be cleanly merged + * into targetBranch without actually performing the merge. + * Uses `git merge-tree --write-tree` (git 2.38+). + */ + checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise; } diff --git a/apps/server/git/index.ts b/apps/server/git/index.ts index 41bd3b0..f7b9351 100644 --- a/apps/server/git/index.ts +++ b/apps/server/git/index.ts @@ -13,7 +13,7 @@ export type { WorktreeManager } from './types.js'; // Domain types -export type { Worktree, WorktreeDiff, MergeResult } from './types.js'; +export type { Worktree, WorktreeDiff, MergeResult, MergeabilityResult } from './types.js'; // Adapters export { SimpleGitWorktreeManager } from './manager.js'; diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index a7678e7..66c5754 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { simpleGit } from 'simple-git'; import type { BranchManager } from './branch-manager.js'; -import type { MergeResult, BranchCommit } from './types.js'; +import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('branch-manager'); @@ -164,4 +164,41 @@ export class SimpleGitBranchManager implements BranchManager { await git.push(remote, branch); log.info({ repoPath, branch, remote }, 'branch pushed to remote'); } + + async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise { + const git = simpleGit(repoPath); + + try { + // git merge-tree --write-tree merges source INTO target virtually. + // Exit 0 = clean merge, non-zero = conflicts. + await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]); + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); + return { mergeable: true }; + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err); + + // Parse conflict file names from "CONFLICT (content): Merge conflict in " + const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g; + const conflicts: string[] = []; + let match: RegExpExecArray | null; + while ((match = conflictPattern.exec(stderr)) !== null) { + conflicts.push(match[1].trim()); + } + + if (conflicts.length > 0) { + log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts'); + return { mergeable: false, conflicts }; + } + + // If we couldn't parse conflicts but the command failed, it's still a conflict + // (could be add/add, rename conflicts, etc.) + if (stderr.includes('CONFLICT')) { + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts'); + return { mergeable: false, conflicts: ['(unable to parse conflict details)'] }; + } + + // Genuine error (not a conflict) + throw err; + } + } } diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 17d56ae..8471b75 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -58,6 +58,19 @@ export interface MergeResult { message: string; } +// ============================================================================= +// Mergeability Check +// ============================================================================= + +/** + * Result of a dry-run merge check. + * No side effects — only tells you whether the merge would succeed. + */ +export interface MergeabilityResult { + mergeable: boolean; + conflicts?: string[]; +} + // ============================================================================= // Branch Commit Info // ============================================================================= diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 74fdc50..a0c3660 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -184,6 +184,27 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { return candidates[0] ?? null; }), + getActiveConflictAgent: publicProcedure + .input(z.object({ initiativeId: z.string().min(1) })) + .query(async ({ ctx, input }): Promise => { + const agentManager = requireAgentManager(ctx); + const allAgents = await agentManager.list(); + const candidates = allAgents + .filter( + (a) => + a.mode === 'execute' && + a.initiativeId === input.initiativeId && + a.name?.startsWith('conflict-') && + ['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) && + !a.userDismissedAt, + ) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return candidates[0] ?? null; + }), + getAgentOutput: publicProcedure .input(agentIdentifierSchema) .query(async ({ ctx, input }): Promise => { diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 37b77b3..373639c 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js'; import { deriveInitiativeActivity } from './initiative-activity.js'; -import { buildRefinePrompt } from '../../agent/prompts/index.js'; +import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResolutionDescription } from '../../agent/prompts/index.js'; import type { PageForSerialization } from '../../agent/content-serializer.js'; import { ensureProjectClone } from '../../git/project-clones.js'; @@ -349,5 +349,128 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { ); return { success: true, taskId: result.taskId }; }), + + checkInitiativeMergeability: publicProcedure + .input(z.object({ initiativeId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const initiativeRepo = requireInitiativeRepository(ctx); + const projectRepo = requireProjectRepository(ctx); + const branchManager = requireBranchManager(ctx); + + const initiative = await initiativeRepo.findById(input.initiativeId); + if (!initiative) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); + } + if (!initiative.branch) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); + } + + const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); + const allConflicts: string[] = []; + let mergeable = true; + + for (const project of projects) { + const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); + const result = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch); + if (!result.mergeable) { + mergeable = false; + if (result.conflicts) allConflicts.push(...result.conflicts); + } + } + + return { + mergeable, + conflictFiles: allConflicts, + targetBranch: projects[0]?.defaultBranch ?? 'main', + }; + }), + + spawnConflictResolutionAgent: publicProcedure + .input(z.object({ + initiativeId: z.string().min(1), + provider: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const agentManager = requireAgentManager(ctx); + const initiativeRepo = requireInitiativeRepository(ctx); + const projectRepo = requireProjectRepository(ctx); + const taskRepo = requireTaskRepository(ctx); + const branchManager = requireBranchManager(ctx); + + const initiative = await initiativeRepo.findById(input.initiativeId); + if (!initiative) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); + } + if (!initiative.branch) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); + } + + const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); + if (projects.length === 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no linked projects' }); + } + + // Auto-dismiss stale conflict agents + const allAgents = await agentManager.list(); + const staleAgents = allAgents.filter( + (a) => + a.mode === 'execute' && + a.initiativeId === input.initiativeId && + a.name?.startsWith('conflict-') && + ['crashed', 'idle'].includes(a.status) && + !a.userDismissedAt, + ); + for (const stale of staleAgents) { + await agentManager.dismiss(stale.id); + } + + // Reject if active conflict agent already running + const activeConflictAgents = allAgents.filter( + (a) => + a.mode === 'execute' && + a.initiativeId === input.initiativeId && + a.name?.startsWith('conflict-') && + ['running', 'waiting_for_input'].includes(a.status), + ); + if (activeConflictAgents.length > 0) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'A conflict resolution agent is already running for this initiative', + }); + } + + // Re-check mergeability to get current conflict list + const project = projects[0]; + const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); + const mergeCheck = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch); + if (mergeCheck.mergeable) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'No merge conflicts detected — merge is clean' }); + } + + const conflicts = mergeCheck.conflicts ?? []; + const targetBranch = project.defaultBranch; + + // Create task + const task = await taskRepo.create({ + initiativeId: input.initiativeId, + name: `Resolve conflicts: ${initiative.name}`, + description: buildConflictResolutionDescription(initiative.branch, targetBranch, conflicts), + category: 'merge', + status: 'in_progress', + }); + + // Spawn agent + const prompt = buildConflictResolutionPrompt(initiative.branch, targetBranch, conflicts); + return agentManager.spawn({ + name: `conflict-${Date.now()}`, + taskId: task.id, + prompt, + mode: 'execute', + provider: input.provider, + initiativeId: input.initiativeId, + baseBranch: targetBranch, + branchName: initiative.branch, + }); + }), }; } diff --git a/apps/web/src/components/review/ConflictResolutionPanel.tsx b/apps/web/src/components/review/ConflictResolutionPanel.tsx new file mode 100644 index 0000000..f4d99fd --- /dev/null +++ b/apps/web/src/components/review/ConflictResolutionPanel.tsx @@ -0,0 +1,182 @@ +import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { QuestionForm } from '@/components/QuestionForm'; +import { useConflictAgent } from '@/hooks/useConflictAgent'; + +interface ConflictResolutionPanelProps { + initiativeId: string; + conflicts: string[]; + onResolved: () => void; +} + +export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) { + const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId); + const [showManual, setShowManual] = useState(false); + + if (state === 'none') { + return ( +
+
+ +
+

+ {conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected +

+
    + {conflicts.map((file) => ( +
  • {file}
  • + ))} +
+
+ + +
+ {spawn.error && ( +

{spawn.error.message}

+ )} + {showManual && ( +
+

+ In your project clone, run: +

+
+{`git checkout 
+git merge 
+# Resolve conflicts in each file
+git add 
+git commit --no-edit`}
+                
+
+ )} +
+
+
+ ); + } + + if (state === 'running') { + return ( +
+
+
+ + Resolving merge conflicts... +
+ +
+
+ ); + } + + if (state === 'waiting' && questions) { + return ( +
+
+ +

Agent needs input

+
+ resume.mutate(answers)} + onCancel={() => {}} + onDismiss={() => stop.mutate()} + isSubmitting={resume.isPending} + isDismissing={stop.isPending} + /> +
+ ); + } + + if (state === 'completed') { + return ( +
+
+
+ + Conflicts resolved +
+
+ +
+
+
+ ); + } + + if (state === 'crashed') { + return ( +
+
+
+ + Conflict resolution agent crashed +
+
+ + +
+
+
+ ); + } + + return null; +} diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index a23a83a..1f28963 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -1,12 +1,14 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; +import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; import { DiffViewer } from "./DiffViewer"; import { ReviewSidebar } from "./ReviewSidebar"; import { PreviewControls } from "./PreviewControls"; +import { ConflictResolutionPanel } from "./ConflictResolutionPanel"; interface InitiativeReviewProps { initiativeId: string; @@ -49,6 +51,26 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview { enabled: !!selectedCommit }, ); + // Mergeability check + const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery( + { initiativeId }, + { refetchInterval: 30_000 }, + ); + const mergeability = mergeabilityQuery.data ?? null; + + // Auto-refresh mergeability when a conflict agent completes + const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId }); + const conflictAgentStatus = conflictAgentQuery.data?.status; + const prevConflictStatusRef = useRef(conflictAgentStatus); + useEffect(() => { + const prev = prevConflictStatusRef.current; + prevConflictStatusRef.current = conflictAgentStatus; + // When agent transitions from running/waiting to idle (completed) + if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') { + void mergeabilityQuery.refetch(); + } + }, [conflictAgentStatus, mergeabilityQuery]); + // Preview state const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); const firstProjectId = projectsQuery.data?.[0]?.id ?? null; @@ -184,6 +206,24 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview {totalDeletions}
+ + {/* Mergeability badge */} + {mergeabilityQuery.isLoading ? ( + + + Checking... + + ) : mergeability?.mergeable ? ( + + + Clean merge + + ) : mergeability && !mergeability.mergeable ? ( + + + {mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''} + + ) : null}
{/* Right: preview + action buttons */} @@ -206,7 +246,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
+ {/* Conflict resolution panel */} + {mergeability && !mergeability.mergeable && ( + mergeabilityQuery.refetch()} + /> + )} + {/* Main content */}
diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index 8d0cbdd..0211b7a 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -9,10 +9,16 @@ export { useAutoSave } from './useAutoSave.js'; export { useDebounce, useDebounceWithImmediate } from './useDebounce.js'; export { useLiveUpdates } from './useLiveUpdates.js'; export { useRefineAgent } from './useRefineAgent.js'; +export { useConflictAgent } from './useConflictAgent.js'; export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js'; export type { RefineAgentState, SpawnRefineAgentOptions, UseRefineAgentResult, -} from './useRefineAgent.js'; \ No newline at end of file +} from './useRefineAgent.js'; + +export type { + ConflictAgentState, + UseConflictAgentResult, +} from './useConflictAgent.js'; \ No newline at end of file diff --git a/apps/web/src/hooks/useConflictAgent.ts b/apps/web/src/hooks/useConflictAgent.ts new file mode 100644 index 0000000..3594fdf --- /dev/null +++ b/apps/web/src/hooks/useConflictAgent.ts @@ -0,0 +1,214 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { trpc } from '@/lib/trpc'; +import type { PendingQuestions } from '@codewalk-district/shared'; + +export type ConflictAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed'; + +type ConflictAgent = NonNullable['data']>; + +export interface UseConflictAgentResult { + agent: ConflictAgent | null; + state: ConflictAgentState; + questions: PendingQuestions | null; + spawn: { + mutate: (options: { initiativeId: string; provider?: string }) => void; + isPending: boolean; + error: Error | null; + }; + resume: { + mutate: (answers: Record) => void; + isPending: boolean; + error: Error | null; + }; + stop: { + mutate: () => void; + isPending: boolean; + }; + dismiss: () => void; + isLoading: boolean; + refresh: () => void; +} + +export function useConflictAgent(initiativeId: string): UseConflictAgentResult { + const utils = trpc.useUtils(); + + const agentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId }); + const agent = agentQuery.data ?? null; + + const state: ConflictAgentState = useMemo(() => { + if (!agent) return 'none'; + switch (agent.status) { + case 'running': + return 'running'; + case 'waiting_for_input': + return 'waiting'; + case 'idle': + return 'completed'; + case 'crashed': + return 'crashed'; + default: + return 'none'; + } + }, [agent]); + + const questionsQuery = trpc.getAgentQuestions.useQuery( + { id: agent?.id ?? '' }, + { enabled: state === 'waiting' && !!agent }, + ); + + const spawnMutation = trpc.spawnConflictResolutionAgent.useMutation({ + onMutate: async () => { + await utils.listAgents.cancel(); + await utils.getActiveConflictAgent.cancel({ initiativeId }); + + const previousAgents = utils.listAgents.getData(); + const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId }); + + const tempAgent = { + id: `temp-${Date.now()}`, + name: `conflict-${Date.now()}`, + mode: 'execute' as const, + status: 'running' as const, + initiativeId, + taskId: null, + phaseId: null, + provider: 'claude', + accountId: null, + instruction: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userDismissedAt: null, + completedAt: null, + }; + + utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]); + utils.getActiveConflictAgent.setData({ initiativeId }, tempAgent as any); + + return { previousAgents, previousConflictAgent }; + }, + onError: (_err, _variables, context) => { + if (context?.previousAgents) { + utils.listAgents.setData(undefined, context.previousAgents); + } + if (context?.previousConflictAgent !== undefined) { + utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent); + } + }, + onSettled: () => { + void utils.listAgents.invalidate(); + void utils.getActiveConflictAgent.invalidate({ initiativeId }); + }, + }); + + const resumeMutation = trpc.resumeAgent.useMutation({ + onSuccess: () => { + void utils.listAgents.invalidate(); + }, + }); + + const stopMutation = trpc.stopAgent.useMutation({ + onSuccess: () => { + void utils.listAgents.invalidate(); + void utils.listWaitingAgents.invalidate(); + }, + }); + + const dismissMutation = trpc.dismissAgent.useMutation({ + onMutate: async ({ id }) => { + await utils.listAgents.cancel(); + await utils.getActiveConflictAgent.cancel({ initiativeId }); + + const previousAgents = utils.listAgents.getData(); + const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId }); + + utils.listAgents.setData(undefined, (old = []) => old.filter(a => a.id !== id)); + utils.getActiveConflictAgent.setData({ initiativeId }, null); + + return { previousAgents, previousConflictAgent }; + }, + onError: (_err, _variables, context) => { + if (context?.previousAgents) { + utils.listAgents.setData(undefined, context.previousAgents); + } + if (context?.previousConflictAgent !== undefined) { + utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent); + } + }, + onSettled: () => { + void utils.listAgents.invalidate(); + void utils.getActiveConflictAgent.invalidate({ initiativeId }); + }, + }); + + const spawnMutateRef = useRef(spawnMutation.mutate); + spawnMutateRef.current = spawnMutation.mutate; + const agentRef = useRef(agent); + agentRef.current = agent; + const resumeMutateRef = useRef(resumeMutation.mutate); + resumeMutateRef.current = resumeMutation.mutate; + const stopMutateRef = useRef(stopMutation.mutate); + stopMutateRef.current = stopMutation.mutate; + const dismissMutateRef = useRef(dismissMutation.mutate); + dismissMutateRef.current = dismissMutation.mutate; + + const spawnFn = useCallback(({ initiativeId, provider }: { initiativeId: string; provider?: string }) => { + spawnMutateRef.current({ initiativeId, provider }); + }, []); + + const spawn = useMemo(() => ({ + mutate: spawnFn, + isPending: spawnMutation.isPending, + error: spawnMutation.error, + }), [spawnFn, spawnMutation.isPending, spawnMutation.error]); + + const resumeFn = useCallback((answers: Record) => { + const a = agentRef.current; + if (a) { + resumeMutateRef.current({ id: a.id, answers }); + } + }, []); + + const resume = useMemo(() => ({ + mutate: resumeFn, + isPending: resumeMutation.isPending, + error: resumeMutation.error, + }), [resumeFn, resumeMutation.isPending, resumeMutation.error]); + + const stopFn = useCallback(() => { + const a = agentRef.current; + if (a) { + stopMutateRef.current({ id: a.id }); + } + }, []); + + const stop = useMemo(() => ({ + mutate: stopFn, + isPending: stopMutation.isPending, + }), [stopFn, stopMutation.isPending]); + + const dismiss = useCallback(() => { + const a = agentRef.current; + if (a) { + dismissMutateRef.current({ id: a.id }); + } + }, []); + + const refresh = useCallback(() => { + void utils.getActiveConflictAgent.invalidate({ initiativeId }); + }, [utils, initiativeId]); + + const isLoading = agentQuery.isLoading || + (state === 'waiting' && questionsQuery.isLoading); + + return { + agent, + state, + questions: questionsQuery.data ?? null, + spawn, + resume, + stop, + dismiss, + isLoading, + refresh, + }; +} diff --git a/docs/agent.md b/docs/agent.md index 5529f63..7083585 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -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) + 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) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | ## Key Flows diff --git a/docs/frontend.md b/docs/frontend.md index cd95313..6488640 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -116,6 +116,7 @@ The initiative detail page has three tabs managed via local state (not URL param | `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation | | `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) | | `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form | +| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion | | `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) | | `ProposalCard` | Individual proposal display | @@ -127,6 +128,7 @@ shadcn/ui components: badge (6 status variants + xs size), button, card, dialog, | Hook | Purpose | |------|---------| | `useRefineAgent` | Manages refine agent lifecycle for initiative | +| `useConflictAgent` | Manages conflict resolution agent lifecycle for initiative review | | `useDetailAgent` | Manages detail agent for phase planning | | `useAgentOutput` | Subscribes to live agent output stream | | `useChatSession` | Manages chat session for phase/task refinement | diff --git a/docs/git-process-logging.md b/docs/git-process-logging.md index b35efa2..2e5d8c4 100644 --- a/docs/git-process-logging.md +++ b/docs/git-process-logging.md @@ -46,6 +46,8 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a | `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) | | `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit | | `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash | +| `pushBranch(repoPath, branch, remote?)` | Push branch to remote (default: 'origin') | +| `checkMergeability(repoPath, source, target)` | Dry-run merge check via `git merge-tree --write-tree` (git 2.38+). Returns `{ mergeable, conflicts? }` with no side effects | `remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving. diff --git a/docs/server-api.md b/docs/server-api.md index 48c62bd..ec11000 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -64,6 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getAgentQuestions | query | Pending questions | | getAgentOutput | query | Full output from DB log chunks | | 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 | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus | @@ -96,6 +97,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getInitiativeCommitDiff | query | Single commit diff for initiative review | | approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` | | requestInitiativeChanges | mutation | Request changes on initiative: `{initiativeId, summary}` → creates review task in Finalization phase, resets initiative to active | +| checkInitiativeMergeability | query | Dry-run merge check: `{initiativeId}` → `{mergeable, conflictFiles[], targetBranch}` | +| spawnConflictResolutionAgent | mutation | Spawn agent to resolve merge conflicts: `{initiativeId, provider?}` → auto-dismisses stale conflict agents, creates merge task | ### Phases | Procedure | Type | Description | From 01f22797359142cec6837a0988fc3a55036153c4 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:17:29 +0100 Subject: [PATCH 46/85] fix: Eliminate whitespace above sticky review header Moved card border/rounding onto the sticky header wrapper itself so it scrolls flush to top-0 with no gap. The body grid gets its own border-x and border-b to preserve the card appearance. ResizeObserver now measures border-box size for accurate sidebar offset. --- apps/web/src/components/review/ReviewTab.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index a532253..09bdd7e 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -25,9 +25,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const el = headerRef.current; if (!el) return; const ro = new ResizeObserver(([entry]) => { - setHeaderHeight(entry.contentRect.height); + setHeaderHeight(entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height); }); - ro.observe(el); + ro.observe(el, { box: 'border-box' }); return () => ro.disconnect(); }, []); @@ -323,9 +323,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } return ( -
+
{/* Header: phase selector + toolbar */} -
+
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} @@ -347,7 +347,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
{/* Main content area — sidebar always rendered to preserve state */} -
+
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
Date: Fri, 6 Mar 2026 11:20:05 +0100 Subject: [PATCH 47/85] migration: Drop orphaned approval columns from initiatives and tasks merge_requires_approval (initiatives) and requires_approval (tasks) were removed from schema.ts in the task-approval removal but left in the DB because 0030 assumed SQLite couldn't DROP COLUMN. SQLite 3.35+ supports it. These orphaned columns caused the old stale-build approval code path to silently set detail tasks to pending_approval, stranding them and blocking phase completion. --- apps/server/drizzle/0033_drop_approval_columns.sql | 5 +++++ apps/server/drizzle/meta/_journal.json | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 apps/server/drizzle/0033_drop_approval_columns.sql diff --git a/apps/server/drizzle/0033_drop_approval_columns.sql b/apps/server/drizzle/0033_drop_approval_columns.sql new file mode 100644 index 0000000..ea73e34 --- /dev/null +++ b/apps/server/drizzle/0033_drop_approval_columns.sql @@ -0,0 +1,5 @@ +-- Drop orphaned approval columns left behind by 0030_remove_task_approval. +-- These columns were removed from schema.ts but left in the DB because +-- 0030 assumed SQLite couldn't DROP COLUMN. SQLite 3.35+ supports it. +ALTER TABLE initiatives DROP COLUMN merge_requires_approval;--> statement-breakpoint +ALTER TABLE tasks DROP COLUMN requires_approval; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 96636a8..2c92726 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1772323200000, "tag": "0032_add_comment_threading", "breakpoints": true + }, + { + "idx": 33, + "version": "6", + "when": 1772409600000, + "tag": "0033_drop_approval_columns", + "breakpoints": true } ] } \ No newline at end of file From 19cd0a2cb04edb4ae0c8381d6b6b1e88f5edc0df Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:21:08 +0100 Subject: [PATCH 48/85] fix: Cover transparent gap above sticky review header Use negative margin to pull sticky header into the parent space-y-3 gap, with matching padding and bg-background to paint over it when stuck. --- apps/web/src/components/review/ReviewTab.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 09bdd7e..361623a 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -325,7 +325,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} -
+
+
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} @@ -345,6 +346,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { totalCount={allFiles.length} />
+
{/* Main content area — sidebar always rendered to preserve state */}
From 4664644cdac121af2dbadcd28351e2beb77f4cf3 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:22:30 +0100 Subject: [PATCH 49/85] fix: Use pseudo-element to cover all transparent space above sticky header Replaces negative margin hack with a ::before that extends upward from the sticky header to paint bg-background over the main padding gap. --- apps/web/src/components/review/ReviewTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 361623a..b44a1ef 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -325,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { return (
{/* Header: phase selector + toolbar */} -
+
({ id: p.id, name: p.name, status: p.status }))} From 5b497b84a04ece7d4e9386f739157a8feecc8940 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:26:43 +0100 Subject: [PATCH 50/85] fix: Restore sticky header and sidebar by simplifying layout Removed wrapper divs that broke sticky positioning. ReviewHeader now accepts a ref prop directly, with sticky top-0 on its own root element. Card wrapper restored as the tall containing block so both header and sidebar have room to stick within it. --- apps/web/src/components/review/ReviewHeader.tsx | 4 +++- apps/web/src/components/review/ReviewTab.tsx | 9 +++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index d0b19d3..44e55fc 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -25,6 +25,7 @@ interface PhaseOption { } interface ReviewHeaderProps { + ref?: React.Ref; phases: PhaseOption[]; activePhaseId: string | null; isReadOnly?: boolean; @@ -44,6 +45,7 @@ interface ReviewHeaderProps { } export function ReviewHeader({ + ref, phases, activePhaseId, isReadOnly, @@ -95,7 +97,7 @@ export function ReviewHeader({ const total = totalCount ?? 0; return ( -
+
{/* Phase selector row */} {phases.length > 1 && (
diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index b44a1ef..4c4fa13 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -323,11 +323,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } return ( -
+
{/* Header: phase selector + toolbar */} -
-
({ id: p.id, name: p.name, status: p.status }))} activePhaseId={activePhaseId} isReadOnly={isActivePhaseCompleted} @@ -345,11 +344,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { viewedCount={viewedFiles.size} totalCount={allFiles.length} /> -
-
{/* Main content area — sidebar always rendered to preserve state */} -
+
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
Date: Fri, 6 Mar 2026 11:27:32 +0100 Subject: [PATCH 51/85] fix: Parse merge-tree output from stdout instead of catch block simple-git's .raw() resolves successfully even on exit code 1, returning stdout content. git merge-tree --write-tree outputs CONFLICT markers to stdout (not stderr), so the catch block never fired and conflicts were reported as clean merges. --- apps/server/git/simple-git-branch-manager.ts | 54 +++++++++----------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index 66c5754..fdca4b9 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -168,37 +168,31 @@ export class SimpleGitBranchManager implements BranchManager { async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise { const git = simpleGit(repoPath); - try { - // git merge-tree --write-tree merges source INTO target virtually. - // Exit 0 = clean merge, non-zero = conflicts. - await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]); - log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); - return { mergeable: true }; - } catch (err) { - const stderr = err instanceof Error ? err.message : String(err); + // git merge-tree --write-tree outputs everything to stdout. + // simple-git's .raw() resolves with stdout even on exit code 1 (conflicts), + // so we parse the output text instead of relying on catch. + const output = await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]); - // Parse conflict file names from "CONFLICT (content): Merge conflict in " - const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g; - const conflicts: string[] = []; - let match: RegExpExecArray | null; - while ((match = conflictPattern.exec(stderr)) !== null) { - conflicts.push(match[1].trim()); - } - - if (conflicts.length > 0) { - log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts'); - return { mergeable: false, conflicts }; - } - - // If we couldn't parse conflicts but the command failed, it's still a conflict - // (could be add/add, rename conflicts, etc.) - if (stderr.includes('CONFLICT')) { - log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts'); - return { mergeable: false, conflicts: ['(unable to parse conflict details)'] }; - } - - // Genuine error (not a conflict) - throw err; + // Parse conflict file names from "CONFLICT (content): Merge conflict in " + const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g; + const conflicts: string[] = []; + let match: RegExpExecArray | null; + while ((match = conflictPattern.exec(output)) !== null) { + conflicts.push(match[1].trim()); } + + if (conflicts.length > 0) { + log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts'); + return { mergeable: false, conflicts }; + } + + // Fallback: check for any CONFLICT text we couldn't parse specifically + if (output.includes('CONFLICT')) { + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts'); + return { mergeable: false, conflicts: ['(unable to parse conflict details)'] }; + } + + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); + return { mergeable: true }; } } From c87aac44cce89cd772d766226c9fe26e3c34727f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:33:31 +0100 Subject: [PATCH 52/85] fix: Cover transparent gap above sticky header with upward box-shadow Uses a 50px upward box-shadow in bg-background color to paint over the main padding gap that shows above the stuck review header. --- apps/web/src/components/review/ReviewHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index 44e55fc..a8225eb 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -97,7 +97,7 @@ export function ReviewHeader({ const total = totalCount ?? 0; return ( -
+
{/* Phase selector row */} {phases.length > 1 && (
From f428ec027e57c1800427e41538f539669e118cda Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:36:28 +0100 Subject: [PATCH 53/85] fix: Sticky file headers sit below review header using CSS variable Sets --review-header-h on the card wrapper from measured header height. FileCard reads it for sticky top offset so file headers dock just below the review header instead of overlapping it. --- apps/web/src/components/review/FileCard.tsx | 3 ++- apps/web/src/components/review/ReviewTab.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index 5606de4..16100db 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -82,7 +82,8 @@ export function FileCard({
{/* File header — sticky so it stays visible when scrolling */}
@@ -45,20 +47,30 @@ function RootComment({ onResolve, onUnresolve, onReply, + onEdit, }: { comment: ReviewComment; replies: ReviewComment[]; onResolve: (id: string) => void; onUnresolve: (id: string) => void; onReply?: (parentCommentId: string, body: string) => void; + onEdit?: (commentId: string, body: string) => void; }) { const [isReplying, setIsReplying] = useState(false); + const [editingId, setEditingId] = useState(null); const replyRef = useRef(null); + const editRef = useRef(null); useEffect(() => { if (isReplying) replyRef.current?.focus(); }, [isReplying]); + useEffect(() => { + if (editingId) editRef.current?.focus(); + }, [editingId]); + + const isEditingRoot = editingId === comment.id; + return (
{/* Root comment */} @@ -75,6 +87,17 @@ function RootComment({ )}
+ {onEdit && comment.author !== "agent" && !comment.resolved && ( + + )} {onReply && !comment.resolved && (
-

{comment.body}

+ {isEditingRoot ? ( + { + onEdit!(comment.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit comment..." + submitLabel="Save" + /> + ) : ( +

{comment.body}

+ )}
{/* Replies */} @@ -114,13 +151,40 @@ function RootComment({ : "border-l-muted-foreground/30" }`} > -
- - {reply.author} - - {formatTime(reply.createdAt)} +
+
+ + {reply.author} + + {formatTime(reply.createdAt)} +
+ {onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && ( + + )}
-

{reply.body}

+ {editingId === reply.id ? ( + { + onEdit!(reply.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit reply..." + submitLabel="Save" + /> + ) : ( +

{reply.body}

+ )}
))}
diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 6fac668..5b9c1e2 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -13,6 +13,7 @@ interface DiffViewerProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; viewedFiles?: Set; onToggleViewed?: (filePath: string) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; @@ -25,6 +26,7 @@ export function DiffViewer({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, viewedFiles, onToggleViewed, onRegisterRef, @@ -40,6 +42,7 @@ export function DiffViewer({ onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} onReplyComment={onReplyComment} + onEditComment={onEditComment} isViewed={viewedFiles?.has(file.newPath) ?? false} onToggleViewed={() => onToggleViewed?.(file.newPath)} /> diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index 87b2b5c..d7056c8 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -53,6 +53,7 @@ interface FileCardProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; isViewed?: boolean; onToggleViewed?: () => void; } @@ -64,6 +65,7 @@ export function FileCard({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, isViewed = false, onToggleViewed = () => {}, }: FileCardProps) { @@ -161,6 +163,7 @@ export function FileCard({ onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} onReplyComment={onReplyComment} + onEditComment={onEditComment} tokenMap={tokenMap} /> ))} diff --git a/apps/web/src/components/review/HunkRows.tsx b/apps/web/src/components/review/HunkRows.tsx index eaeeb5a..86cf6dd 100644 --- a/apps/web/src/components/review/HunkRows.tsx +++ b/apps/web/src/components/review/HunkRows.tsx @@ -16,6 +16,7 @@ interface HunkRowsProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; tokenMap?: LineTokenMap | null; } @@ -27,6 +28,7 @@ export function HunkRows({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, tokenMap, }: HunkRowsProps) { const [commentingLine, setCommentingLine] = useState<{ @@ -101,6 +103,7 @@ export function HunkRows({ onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} onReplyComment={onReplyComment} + onEditComment={onEditComment} tokens={ line.newLineNumber !== null ? tokenMap?.get(line.newLineNumber) ?? undefined diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index 579db1b..c5b8d12 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -16,6 +16,7 @@ interface LineWithCommentsProps { onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; onReplyComment?: (parentCommentId: string, body: string) => void; + onEditComment?: (commentId: string, body: string) => void; /** Syntax-highlighted tokens for this line (if available) */ tokens?: TokenizedLine; } @@ -31,6 +32,7 @@ export function LineWithComments({ onResolveComment, onUnresolveComment, onReplyComment, + onEditComment, tokens, }: LineWithCommentsProps) { const formRef = useRef(null); @@ -144,6 +146,7 @@ export function LineWithComments({ onResolve={onResolveComment} onUnresolve={onUnresolveComment} onReply={onReplyComment} + onEdit={onEditComment} /> diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 0ce2079..099f380 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -195,6 +195,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { onError: (err) => toast.error(`Failed to post reply: ${err.message}`), }); + const editCommentMutation = trpc.updateReviewComment.useMutation({ + onSuccess: () => { + utils.listReviewComments.invalidate({ phaseId: activePhaseId! }); + }, + onError: (err) => toast.error(`Failed to update comment: ${err.message}`), + }); + const approveMutation = trpc.approvePhaseReview.useMutation({ onSuccess: () => { setStatus("approved"); @@ -245,6 +252,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { replyToCommentMutation.mutate({ parentCommentId, body }); }, [replyToCommentMutation]); + const handleEditComment = useCallback((commentId: string, body: string) => { + editCommentMutation.mutate({ id: commentId, body }); + }, [editCommentMutation]); + const handleApprove = useCallback(() => { if (!activePhaseId) return; approveMutation.mutate({ phaseId: activePhaseId }); @@ -394,6 +405,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { onResolveComment={handleResolveComment} onUnresolveComment={handleUnresolveComment} onReplyComment={handleReplyComment} + onEditComment={handleEditComment} viewedFiles={viewedFiles} onToggleViewed={toggleViewed} onRegisterRef={registerFileRef} From 2814c2d3b2ec6bd8fced7dcdb041223884a458fb Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:59:16 +0100 Subject: [PATCH 57/85] fix: Fetch remote before merge/push in initiative approval approveInitiative was merging and pushing with a stale local defaultBranch, causing "rejected (fetch first)" when origin/main had advanced since the last project sync. Now fetches remote and fast-forwards the target branch before merging. --- apps/server/execution/orchestrator.test.ts | 2 ++ apps/server/execution/orchestrator.ts | 9 +++++++++ apps/server/git/branch-manager.ts | 13 +++++++++++++ apps/server/git/simple-git-branch-manager.ts | 13 +++++++++++++ 4 files changed, 37 insertions(+) diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index 698b9d2..fb52e13 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -49,6 +49,8 @@ function createMocks() { getMergeBase: vi.fn().mockResolvedValue('abc123'), pushBranch: vi.fn(), checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), + fetchRemote: vi.fn(), + fastForwardBranch: vi.fn(), }; const phaseRepository = { diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 2883f18..e29e5a4 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -637,7 +637,16 @@ export class ExecutionOrchestrator { continue; } + // Fetch remote so local branches are up-to-date before merge/push + await this.branchManager.fetchRemote(clonePath); + if (strategy === 'merge_and_push') { + // Fast-forward local defaultBranch to match origin before merging + try { + await this.branchManager.fastForwardBranch(clonePath, project.defaultBranch); + } catch (ffErr) { + log.warn({ project: project.name, err: (ffErr as Error).message }, 'fast-forward of default branch failed — attempting merge anyway'); + } const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch); if (!result.success) { throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 901413d..ceb399c 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -75,4 +75,17 @@ export interface BranchManager { * Uses `git merge-tree --write-tree` (git 2.38+). */ checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise; + + /** + * Fetch all refs from a remote. + * Defaults to 'origin' if no remote specified. + */ + fetchRemote(repoPath: string, remote?: string): Promise; + + /** + * Fast-forward a local branch to match its remote-tracking counterpart. + * No-op if already up to date. Throws if fast-forward is not possible + * (i.e. the branches have diverged). + */ + fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise; } diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index fdca4b9..e686a6f 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -195,4 +195,17 @@ export class SimpleGitBranchManager implements BranchManager { log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); return { mergeable: true }; } + + async fetchRemote(repoPath: string, remote = 'origin'): Promise { + const git = simpleGit(repoPath); + await git.fetch(remote); + log.info({ repoPath, remote }, 'fetched remote'); + } + + async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise { + const git = simpleGit(repoPath); + const remoteBranch = `${remote}/${branch}`; + await git.raw(['merge', '--ff-only', remoteBranch, branch]); + log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); + } } From b853b287513b07776b15486f2c9d7be21bf701d3 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:03:20 +0100 Subject: [PATCH 58/85] fix: Resolve agent workdir probing for initiative project subdirectories Conflict-resolution agents (and any initiative-based agent) can write .cw/output/signal.json inside a project subdirectory (e.g. agent-workdirs//codewalk-district/.cw/output/) rather than the parent agent workdir. This caused two failures: 1. spawnInternal wrote spawn-diagnostic.json before registering the agent in activeAgents and starting pollForCompletion. If the .cw/ directory didn't exist (no inputContext provided), the write threw ENOENT, orphaning the running process with no completion monitoring. 2. resolveAgentCwd in cleanup-manager and output-handler only probed for a workspace/ subdirectory (standalone agents) but not project subdirectories, so reconciliation and completion handling couldn't find signal.json and marked the agent as crashed. Fixes: - Move activeAgents registration and pollForCompletion setup before the diagnostic write; make the write non-fatal with mkdir -p - Add project subdirectory probing to resolveAgentCwd in both cleanup-manager.ts and output-handler.ts --- apps/server/agent/cleanup-manager.ts | 29 +++++++++++++- apps/server/agent/manager.ts | 59 ++++++++++++++++------------ apps/server/agent/output-handler.ts | 35 ++++++++++++++--- 3 files changed, 91 insertions(+), 32 deletions(-) diff --git a/apps/server/agent/cleanup-manager.ts b/apps/server/agent/cleanup-manager.ts index e35d406..17586ac 100644 --- a/apps/server/agent/cleanup-manager.ts +++ b/apps/server/agent/cleanup-manager.ts @@ -8,7 +8,7 @@ import { promisify } from 'node:util'; import { execFile } from 'node:child_process'; import { readFile, readdir, rm, cp, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; @@ -49,10 +49,35 @@ export class CleanupManager { */ private resolveAgentCwd(worktreeId: string): string { const base = this.getAgentWorkdir(worktreeId); + + // Fast path: .cw/output exists at the base level + if (existsSync(join(base, '.cw', 'output'))) { + return base; + } + + // Standalone agents use a workspace/ subdirectory const workspaceSub = join(base, 'workspace'); - if (!existsSync(join(base, '.cw', 'output')) && existsSync(join(workspaceSub, '.cw'))) { + if (existsSync(join(workspaceSub, '.cw'))) { return workspaceSub; } + + // Initiative-based agents may have written .cw/ inside a project + // subdirectory (e.g. agent-workdirs//codewalk-district/.cw/). + // Probe immediate children for a .cw/output directory. + try { + const entries = readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== '.cw') { + const projectSub = join(base, entry.name); + if (existsSync(join(projectSub, '.cw', 'output'))) { + return projectSub; + } + } + } + } catch { + // base dir may not exist + } + return base; } diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 0572edb..d567fcc 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -43,7 +43,7 @@ import { getProvider } from './providers/registry.js'; import { createModuleLogger } from '../logger/index.js'; import { getProjectCloneDir } from '../git/project-clones.js'; import { join } from 'node:path'; -import { unlink, readFile, writeFile as writeFileAsync } from 'node:fs/promises'; +import { unlink, readFile, writeFile as writeFileAsync, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import type { AccountCredentialManager } from './credentials/types.js'; import { ProcessManager } from './process-manager.js'; @@ -332,32 +332,10 @@ export class MultiProviderAgentManager implements AgentManager { await this.repository.update(agentId, { pid, outputFilePath }); - // Write spawn diagnostic file for post-execution verification - const diagnostic = { - timestamp: new Date().toISOString(), - agentId, - alias, - intendedCwd: finalCwd, - worktreeId: agent.worktreeId, - provider: providerName, - command, - args, - env: processEnv, - cwdExistsAtSpawn: existsSync(finalCwd), - initiativeId: initiativeId || null, - customCwdProvided: !!cwd, - accountId: accountId || null, - }; - - await writeFileAsync( - join(finalCwd, '.cw', 'spawn-diagnostic.json'), - JSON.stringify(diagnostic, null, 2), - 'utf-8' - ); - + // Register agent and start polling BEFORE non-critical I/O so that a + // diagnostic-write failure can never orphan a running process. const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd }; this.activeAgents.set(agentId, activeEntry); - log.info({ agentId, alias, pid, diagnosticWritten: true }, 'detached subprocess started with diagnostic'); // Emit spawned event if (this.eventBus) { @@ -377,6 +355,37 @@ export class MultiProviderAgentManager implements AgentManager { ); activeEntry.cancelPoll = cancel; + // Write spawn diagnostic file (non-fatal — .cw/ may not exist yet for + // agents spawned without inputContext, e.g. conflict-resolution agents) + try { + const diagnosticDir = join(finalCwd, '.cw'); + await mkdir(diagnosticDir, { recursive: true }); + const diagnostic = { + timestamp: new Date().toISOString(), + agentId, + alias, + intendedCwd: finalCwd, + worktreeId: agent.worktreeId, + provider: providerName, + command, + args, + env: processEnv, + cwdExistsAtSpawn: existsSync(finalCwd), + initiativeId: initiativeId || null, + customCwdProvided: !!cwd, + accountId: accountId || null, + }; + await writeFileAsync( + join(diagnosticDir, 'spawn-diagnostic.json'), + JSON.stringify(diagnostic, null, 2), + 'utf-8' + ); + } catch (err) { + log.warn({ agentId, alias, err: err instanceof Error ? err.message : String(err) }, 'failed to write spawn diagnostic'); + } + + log.info({ agentId, alias, pid }, 'detached subprocess started'); + return this.toAgentInfo(agent); } diff --git a/apps/server/agent/output-handler.ts b/apps/server/agent/output-handler.ts index a43f09e..28fdaf6 100644 --- a/apps/server/agent/output-handler.ts +++ b/apps/server/agent/output-handler.ts @@ -7,7 +7,7 @@ */ import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js'; @@ -233,10 +233,10 @@ export class OutputHandler { log.debug({ agentId }, 'detached agent completed'); - // Resolve actual agent working directory — standalone agents run in a - // "workspace/" subdirectory inside getAgentWorkdir, so prefer agentCwd - // recorded at spawn time when available. - const agentWorkdir = active?.agentCwd ?? getAgentWorkdir(agent.worktreeId); + // Resolve actual agent working directory. + // The recorded agentCwd may be the parent dir (agent-workdirs//) while + // the agent actually writes .cw/output/ inside a project subdirectory. + const agentWorkdir = this.resolveAgentWorkdir(active?.agentCwd ?? getAgentWorkdir(agent.worktreeId)); const outputDir = join(agentWorkdir, '.cw', 'output'); const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt'); const diagnosticFile = join(agentWorkdir, '.cw', 'spawn-diagnostic.json'); @@ -1158,6 +1158,31 @@ export class OutputHandler { } } + /** + * Resolve the actual agent working directory. The recorded agentCwd may be + * the parent (agent-workdirs//) but .cw/output/ could be inside a + * project subdirectory (e.g. codewalk-district/.cw/output/). + */ + private resolveAgentWorkdir(base: string): string { + if (existsSync(join(base, '.cw', 'output'))) return base; + + // Standalone agents: workspace/ subdirectory + const workspaceSub = join(base, 'workspace'); + if (existsSync(join(workspaceSub, '.cw'))) return workspaceSub; + + // Initiative-based agents: probe project subdirectories + try { + for (const entry of readdirSync(base, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== '.cw') { + const sub = join(base, entry.name); + if (existsSync(join(sub, '.cw', 'output'))) return sub; + } + } + } catch { /* base may not exist */ } + + return base; + } + private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void { if (this.eventBus) { const event: AgentCrashedEvent = { From f4dbaae0e3a1c77ace8b9976587118b8f1de6e01 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:12:32 +0100 Subject: [PATCH 59/85] fix: Guard worktree creation against branch === baseBranch Throws if branch and baseBranch are identical, preventing git branch -f from force-resetting shared branches (like the initiative branch) when accidentally passed as both. --- apps/server/git/manager.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/git/manager.ts b/apps/server/git/manager.ts index f7d3c1b..a28dd2a 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -61,10 +61,18 @@ export class SimpleGitWorktreeManager implements WorktreeManager { const worktreePath = path.join(this.worktreesDir, id); log.info({ id, branch, baseBranch }, 'creating worktree'); + // Safety: never force-reset a branch to its own base — this would nuke + // shared branches like the initiative branch if passed as both branch and baseBranch. + if (branch === baseBranch) { + throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`); + } + // Create worktree — reuse existing branch or create new one const branchExists = await this.branchExists(branch); if (branchExists) { - // Branch exists from a previous run — reset it to baseBranch and check it out + // Branch exists from a previous run — reset it to baseBranch and check it out. + // Only safe because branch !== baseBranch (checked above), so we're resetting + // an agent-scoped branch, not a shared branch like main or the initiative branch. await this.git.raw(['branch', '-f', branch, baseBranch]); await this.git.raw(['worktree', 'add', worktreePath, branch]); } else { From 9f5715558e34f7d97964fd206a39e7d5a846d351 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:14:16 +0100 Subject: [PATCH 60/85] fix: Auto-dismiss conflict panel and re-check mergeability on completion Instead of showing a manual "Re-check Mergeability" button after the conflict agent finishes, auto-dismiss the agent and trigger mergeability re-check immediately when the state transitions to completed. --- .../review/ConflictResolutionPanel.tsx | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/review/ConflictResolutionPanel.tsx b/apps/web/src/components/review/ConflictResolutionPanel.tsx index f4d99fd..cc55c06 100644 --- a/apps/web/src/components/review/ConflictResolutionPanel.tsx +++ b/apps/web/src/components/review/ConflictResolutionPanel.tsx @@ -1,5 +1,5 @@ import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { QuestionForm } from '@/components/QuestionForm'; import { useConflictAgent } from '@/hooks/useConflictAgent'; @@ -13,6 +13,17 @@ interface ConflictResolutionPanelProps { export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) { const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId); const [showManual, setShowManual] = useState(false); + const prevStateRef = useRef(state); + + // Auto-dismiss and re-check mergeability when conflict agent completes + useEffect(() => { + const prev = prevStateRef.current; + prevStateRef.current = state; + if (prev !== 'completed' && state === 'completed') { + dismiss(); + onResolved(); + } + }, [state, dismiss, onResolved]); if (state === 'none') { return ( @@ -117,26 +128,13 @@ git commit --no-edit`} } if (state === 'completed') { + // Auto-dismiss effect above handles this — show brief success message during transition return (
-
-
- - Conflicts resolved -
-
- -
+
+ + Conflicts resolved — re-checking mergeability... +
); From 17f92040c732c12d690466fe8bb311209e93d44c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:14:37 +0100 Subject: [PATCH 61/85] fix: Ensure agents write signal.json to the correct directory Two additional fixes to prevent agents from writing .cw/output/ in the wrong location: 1. Always create .cw/output/ at the agent workdir root during spawn, even when no inputContext is provided. This gives the agent a visible anchor directory so it doesn't create one inside a project subdir. 2. Add absolute output path instruction to the workspace layout prompt for multi-project agents, explicitly telling them to write .cw/output/ relative to the workdir root, not their current cd location. --- apps/server/agent/manager.ts | 4 ++++ apps/server/agent/prompts/workspace.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index d567fcc..ac36b83 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -297,6 +297,10 @@ export class MultiProviderAgentManager implements AgentManager { if (options.inputContext) { await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias }); log.debug({ alias }, 'input files written'); + } else { + // Always create .cw/output/ at the agent workdir root so the agent + // writes signal.json here rather than in a project subdirectory. + await mkdir(join(agentCwd, '.cw', 'output'), { recursive: true }); } // 4. Build spawn command diff --git a/apps/server/agent/prompts/workspace.ts b/apps/server/agent/prompts/workspace.ts index 846850a..f01c6d3 100644 --- a/apps/server/agent/prompts/workspace.ts +++ b/apps/server/agent/prompts/workspace.ts @@ -36,5 +36,7 @@ This is an isolated git worktree. Other agents may be working in parallel on sep The following project directories contain the source code (git worktrees): ${lines.join('\n')} + +**IMPORTANT**: All \`.cw/output/\` paths (signal.json, progress.md, etc.) are relative to this working directory (\`${agentCwd}\`), NOT to any project subdirectory. Always write to \`${join(agentCwd, '.cw/output/')}\` regardless of your current \`cd\` location. `; } From a69527b7d61c65f3dac8ded7d9cd6b31e40c1b6c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:18:31 +0100 Subject: [PATCH 62/85] fix: Remove upward box-shadow on ReviewHeader that covers tab bar The sticky ReviewHeader had shadow-[0_-50px_0_0_hsl(var(--background))] which painted a 50px background-color rectangle upward, overlapping the tab navigation bar (only ~12px away). The header's bg-card is already opaque, making the shadow unnecessary for scroll coverage. --- apps/web/src/components/review/ReviewHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index a8225eb..44e55fc 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -97,7 +97,7 @@ export function ReviewHeader({ const total = totalCount ?? 0; return ( -
+
{/* Phase selector row */} {phases.length > 1 && (
From eac03862e392afe1b109fdb80d96ddca0a72d24c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:19:59 +0100 Subject: [PATCH 63/85] fix: Prevent lost task completions after server restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs causing empty phase diffs when server restarts during agent execution: 1. Startup ordering race: reconcileAfterRestart() emitted agent:stopped before orchestrator registered listeners — events lost. Moved reconciliation to after orchestrator.start(). 2. Stuck in_progress tasks: recoverDispatchQueues() only re-queued pending tasks. Added recovery for in_progress tasks whose agents are dead (not running/waiting_for_input). 3. Branch force-reset destroys work: git branch -f wiped commits when a second agent was dispatched for the same task. Now checks if the branch has commits beyond baseBranch before resetting. Also adds: - agent:crashed handler with auto-retry (MAX_TASK_RETRIES=3) - retryCount column on tasks table + migration - retryCount reset on manual retryBlockedTask() --- apps/server/container.ts | 11 +++-- apps/server/db/schema.ts | 1 + apps/server/dispatch/manager.ts | 4 +- .../drizzle/0034_add_task_retry_count.sql | 1 + apps/server/drizzle/meta/_journal.json | 7 +++ apps/server/execution/orchestrator.ts | 48 ++++++++++++++++++- apps/server/git/manager.ts | 19 ++++++-- docs/database.md | 1 + docs/dispatch-events.md | 15 +++++- 9 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 apps/server/drizzle/0034_add_task_retry_count.sql diff --git a/apps/server/container.ts b/apps/server/container.ts index 5e6aefd..4468184 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -187,10 +187,6 @@ export async function createContainer(options?: ContainerOptions): Promise> = new Map(); @@ -44,6 +48,7 @@ export class ExecutionOrchestrator { private conflictResolutionService: ConflictResolutionService, private eventBus: EventBus, private workspaceRoot: string, + private agentRepository?: AgentRepository, ) {} /** @@ -66,6 +71,13 @@ export class ExecutionOrchestrator { }); }); + // Auto-retry crashed agent tasks (up to MAX_TASK_RETRIES) + this.eventBus.on('agent:crashed', (event) => { + this.handleAgentCrashed(event).catch((err) => { + log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling agent:crashed'); + }); + }); + // Recover in-memory dispatch queues from DB state (survives server restarts) this.recoverDispatchQueues().catch((err) => { log.error({ err: err instanceof Error ? err.message : String(err) }, 'dispatch queue recovery failed'); @@ -111,6 +123,27 @@ export class ExecutionOrchestrator { this.scheduleDispatch(); } + private async handleAgentCrashed(event: AgentCrashedEvent): Promise { + const { taskId, agentId, error } = event.payload; + if (!taskId) return; + + const task = await this.taskRepository.findById(taskId); + if (!task || task.status !== 'in_progress') return; + + const retryCount = (task.retryCount ?? 0) + 1; + if (retryCount > MAX_TASK_RETRIES) { + log.warn({ taskId, agentId, retryCount, error }, 'task exceeded max retries, leaving in_progress'); + return; + } + + // Reset task for re-dispatch with incremented retry count + await this.taskRepository.update(taskId, { status: 'pending', retryCount }); + await this.dispatchManager.queue(taskId); + log.info({ taskId, agentId, retryCount, error }, 'crashed task re-queued for retry'); + + this.scheduleDispatch(); + } + private async runDispatchCycle(): Promise { this.dispatchRunning = true; try { @@ -560,7 +593,7 @@ export class ExecutionOrchestrator { } } - // Re-queue pending tasks for in_progress phases into the task dispatch queue + // Re-queue pending tasks and recover stuck in_progress tasks for in_progress phases if (phase.status === 'in_progress') { const tasks = await this.taskRepository.findByPhaseId(phase.id); for (const task of tasks) { @@ -571,6 +604,17 @@ export class ExecutionOrchestrator { } catch { // Already queued or task issue } + } else if (task.status === 'in_progress' && this.agentRepository) { + // Check if the assigned agent is still alive + const agent = await this.agentRepository.findByTaskId(task.id); + const isAlive = agent && (agent.status === 'running' || agent.status === 'waiting_for_input'); + if (!isAlive) { + // Agent is dead — reset task for re-dispatch + await this.taskRepository.update(task.id, { status: 'pending' }); + await this.dispatchManager.queue(task.id); + tasksRecovered++; + log.info({ taskId: task.id, agentId: agent?.id }, 'recovered stuck in_progress task (dead agent)'); + } } } } diff --git a/apps/server/git/manager.ts b/apps/server/git/manager.ts index a28dd2a..95539af 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -70,10 +70,21 @@ export class SimpleGitWorktreeManager implements WorktreeManager { // Create worktree — reuse existing branch or create new one const branchExists = await this.branchExists(branch); if (branchExists) { - // Branch exists from a previous run — reset it to baseBranch and check it out. - // Only safe because branch !== baseBranch (checked above), so we're resetting - // an agent-scoped branch, not a shared branch like main or the initiative branch. - await this.git.raw(['branch', '-f', branch, baseBranch]); + // Branch exists from a previous run. Check if it has commits beyond baseBranch + // before resetting — a previous agent may have done real work on this branch. + try { + const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]); + if (parseInt(aheadCount.trim(), 10) > 0) { + log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving'); + } else { + await this.git.raw(['branch', '-f', branch, baseBranch]); + } + } catch { + // If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset + await this.git.raw(['branch', '-f', branch, baseBranch]); + } + // Prune stale worktree references before adding new one + await this.git.raw(['worktree', 'prune']); await this.git.raw(['worktree', 'add', worktreePath, branch]); } else { // git worktree add -b diff --git a/docs/database.md b/docs/database.md index 6afb841..dd12a0b 100644 --- a/docs/database.md +++ b/docs/database.md @@ -51,6 +51,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' | | order | integer | default 0 | | summary | text nullable | Agent result summary — propagated to dependent tasks as context | +| retryCount | integer NOT NULL | default 0, incremented on agent crash auto-retry, reset on manual retry | | createdAt, updatedAt | integer/timestamp | | ### task_dependencies diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 5356b0e..d9e336d 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -112,9 +112,22 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId } | Event | Action | |-------|--------| | `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents | -| `agent:stopped` | Re-dispatch queued tasks (freed agent slot) | +| `agent:stopped` | Auto-complete task (unless user_requested), re-dispatch queued tasks (freed agent slot) | +| `agent:crashed` | Auto-retry crashed task up to `MAX_TASK_RETRIES` (3). Increments `retryCount`, resets status to `pending`, re-queues. Exceeding retries leaves task `in_progress` for manual intervention. | | `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task | +### Crash Recovery + +When an agent crashes (`agent:crashed` event), the orchestrator automatically retries the task: +1. Finds the task associated with the crashed agent +2. Checks `task.retryCount` against `MAX_TASK_RETRIES` (3) +3. If under limit: increments `retryCount`, resets task to `pending`, re-queues for dispatch +4. If over limit: logs warning, task stays `in_progress` for manual intervention + +On server restart, `recoverDispatchQueues()` also recovers stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`). These are reset to `pending` and re-queued. + +Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries. + ### Coalesced Scheduling Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are coalesced into a single async dispatch cycle via `scheduleDispatch()`. The cycle loops `dispatchNextPhase()` + `dispatchNext()` until both queues are drained, then re-runs if new events arrived during execution. From 00e426ac00823f2c1cc77a1a6f59a51e0a5c2d07 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:31:35 +0100 Subject: [PATCH 64/85] fix: Roll back merge when push fails in initiative approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When merge_and_push failed at the push step, the local defaultBranch ref was left pointing at the merge commit. This made the three-dot diff (defaultBranch...initiativeBranch) return empty because main already contained all changes — causing the review tab to show "no changes." Now mergeBranch returns the previous ref, and approveInitiative restores it on push failure. Also repaired the corrupted clone state. --- apps/server/execution/orchestrator.test.ts | 62 +++++++++++++++++++- apps/server/execution/orchestrator.ts | 13 +++- apps/server/git/branch-manager.ts | 6 ++ apps/server/git/simple-git-branch-manager.ts | 11 +++- apps/server/git/types.ts | 2 + 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index fb52e13..6cf293d 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -6,7 +6,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ExecutionOrchestrator } from './orchestrator.js'; +import { ensureProjectClone } from '../git/project-clones.js'; import type { BranchManager } from '../git/branch-manager.js'; + +vi.mock('../git/project-clones.js', () => ({ + ensureProjectClone: vi.fn().mockResolvedValue('/tmp/test-workspace/clones/test'), +})); import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; @@ -39,7 +44,7 @@ function createMockEventBus(): EventBus & { handlers: Map; e function createMocks() { const branchManager: BranchManager = { ensureBranch: vi.fn(), - mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged' }), + mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged', previousRef: 'abc000' }), diffBranches: vi.fn().mockResolvedValue(''), deleteBranch: vi.fn(), branchExists: vi.fn().mockResolvedValue(true), @@ -51,6 +56,7 @@ function createMocks() { checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), fetchRemote: vi.fn(), fastForwardBranch: vi.fn(), + updateRef: vi.fn(), }; const phaseRepository = { @@ -306,4 +312,58 @@ describe('ExecutionOrchestrator', () => { expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled(); }); }); + + describe('approveInitiative', () => { + function setupApproveTest(mocks: ReturnType) { + const initiative = { id: 'init-1', branch: 'cw/test', status: 'pending_review' }; + const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' }; + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any); + vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true); + vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok', previousRef: 'abc000' }); + return { initiative, project }; + } + + it('should roll back merge when push fails', async () => { + setupApproveTest(mocks); + vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('non-fast-forward')); + + const orchestrator = createOrchestrator(mocks); + + await expect(orchestrator.approveInitiative('init-1', 'merge_and_push')).rejects.toThrow('non-fast-forward'); + + // Should have rolled back the merge by restoring the previous ref + expect(mocks.branchManager.updateRef).toHaveBeenCalledWith( + expect.any(String), + 'main', + 'abc000', + ); + + // Should NOT have marked initiative as completed + expect(mocks.initiativeRepository.update).not.toHaveBeenCalled(); + }); + + it('should complete initiative when push succeeds', async () => { + setupApproveTest(mocks); + + const orchestrator = createOrchestrator(mocks); + + await orchestrator.approveInitiative('init-1', 'merge_and_push'); + + expect(mocks.branchManager.updateRef).not.toHaveBeenCalled(); + expect(mocks.initiativeRepository.update).toHaveBeenCalledWith('init-1', { status: 'completed' }); + }); + + it('should not attempt rollback for push_branch strategy', async () => { + setupApproveTest(mocks); + vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('auth failed')); + + const orchestrator = createOrchestrator(mocks); + + await expect(orchestrator.approveInitiative('init-1', 'push_branch')).rejects.toThrow('auth failed'); + + // No merge happened, so no rollback needed + expect(mocks.branchManager.updateRef).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 9dba50c..5b8c521 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -695,7 +695,18 @@ export class ExecutionOrchestrator { if (!result.success) { throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`); } - await this.branchManager.pushBranch(clonePath, project.defaultBranch); + try { + await this.branchManager.pushBranch(clonePath, project.defaultBranch); + } catch (pushErr) { + // Roll back the merge so the diff doesn't disappear from the review tab. + // Without rollback, defaultBranch includes the initiative changes and the + // three-dot diff (defaultBranch...initiativeBranch) becomes empty. + if (result.previousRef) { + log.warn({ project: project.name, previousRef: result.previousRef }, 'push failed — rolling back merge'); + await this.branchManager.updateRef(clonePath, project.defaultBranch, result.previousRef); + } + throw pushErr; + } log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed'); } else { await this.branchManager.pushBranch(clonePath, initiative.branch); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index ceb399c..9ba6d85 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -88,4 +88,10 @@ export interface BranchManager { * (i.e. the branches have diverged). */ fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise; + + /** + * Force-update a branch ref to point at a specific commit. + * Used to roll back a merge when a subsequent push fails. + */ + updateRef(repoPath: string, branch: string, commitHash: string): Promise; } diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index e686a6f..b8147d0 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -39,6 +39,9 @@ export class SimpleGitBranchManager implements BranchManager { const tempBranch = `cw-merge-${Date.now()}`; try { + // Capture the target branch ref before merge so callers can roll back on push failure + const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim(); + // Create worktree with a temp branch starting at targetBranch's commit await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]); @@ -53,7 +56,7 @@ export class SimpleGitBranchManager implements BranchManager { await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]); log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly'); - return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` }; + return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef }; } catch (mergeErr) { // Check for merge conflicts const status = await wtGit.status(); @@ -208,4 +211,10 @@ export class SimpleGitBranchManager implements BranchManager { await git.raw(['merge', '--ff-only', remoteBranch, branch]); log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); } + + async updateRef(repoPath: string, branch: string, commitHash: string): Promise { + const git = simpleGit(repoPath); + await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]); + log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated'); + } } diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 8471b75..51a35b7 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -56,6 +56,8 @@ export interface MergeResult { conflicts?: string[]; /** Human-readable message describing the result */ message: string; + /** The target branch's commit hash before the merge (for rollback on push failure) */ + previousRef?: string; } // ============================================================================= From 6a76e17cef0a37ff6ff3556760653d4091c4376e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:31:41 +0100 Subject: [PATCH 65/85] feat: Add errands table, errand agent mode, and push rollback on merge failure - Add `errands` table to schema with status enum and relations to agents/projects - Add `errand` mode to agents.mode enum - Add push rollback in orchestrator: if push fails after merge, reset to previousRef to preserve the review diff - Extend MergeResult with previousRef for rollback support; update branch-manager and simple-git-branch-manager - Add orchestrator tests for push rollback behaviour Co-Authored-By: Claude Sonnet 4.6 --- apps/server/db/schema.ts | 34 +++++++++++++++++++++++++- apps/server/drizzle/meta/_journal.json | 7 ++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 3889111..bbdfc36 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -262,7 +262,7 @@ export const agents = sqliteTable('agents', { }) .notNull() .default('idle'), - mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] }) + mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand'] }) .notNull() .default('execute'), pid: integer('pid'), @@ -629,3 +629,35 @@ export const reviewComments = sqliteTable('review_comments', { export type ReviewComment = InferSelectModel; export type NewReviewComment = InferInsertModel; + +// ============================================================================ +// ERRANDS +// ============================================================================ + +export const errands = sqliteTable('errands', { + id: text('id').primaryKey(), + description: text('description').notNull(), + branch: text('branch').notNull(), + baseBranch: text('base_branch').notNull().default('main'), + agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }), + projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['active', 'pending_review', 'conflict', 'merged', 'abandoned'], + }).notNull().default('active'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const errandsRelations = relations(errands, ({ one }) => ({ + agent: one(agents, { + fields: [errands.agentId], + references: [agents.id], + }), + project: one(projects, { + fields: [errands.projectId], + references: [projects.id], + }), +})); + +export type Errand = InferSelectModel; +export type NewErrand = InferInsertModel; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 6e91f90..cf73e84 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1772496000000, "tag": "0034_add_task_retry_count", "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1772796561474, + "tag": "0035_faulty_human_fly", + "breakpoints": true } ] } \ No newline at end of file From 5d1292c7adb7336a630df0d333537cae904db52c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:34:21 +0100 Subject: [PATCH 66/85] fix: Use update-ref for fast-forward to avoid dirty working tree failures fastForwardBranch used git merge --ff-only which fails when the clone has uncommitted files. This caused the ff to be silently skipped, the merge to proceed on stale main, and the push to fail (non-fast-forward). Switched to update-ref which only moves the branch pointer without touching the working tree. --- apps/server/git/simple-git-branch-manager.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index b8147d0..5b46640 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -208,7 +208,18 @@ export class SimpleGitBranchManager implements BranchManager { async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise { const git = simpleGit(repoPath); const remoteBranch = `${remote}/${branch}`; - await git.raw(['merge', '--ff-only', remoteBranch, branch]); + + // Verify it's a genuine fast-forward (branch is ancestor of remote) + try { + await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]); + } catch { + throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`); + } + + // Use update-ref instead of git merge so dirty working trees don't block it. + // The clone may have uncommitted agent work; we only need to advance the ref. + const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim(); + await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]); log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); } From 940b0f8ed291eda4ed1e95ae84b72c61a08d3e37 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:35:06 +0100 Subject: [PATCH 67/85] =?UTF-8?q?feat:=20Add=20errands=20persistence=20lay?= =?UTF-8?q?er=20=E2=80=94=20repository=20port,=20Drizzle=20adapter,=20migr?= =?UTF-8?q?ation,=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add errand-repository.ts port with ErrandRepository, ErrandWithAlias, ErrandStatus types - Add DrizzleErrandRepository adapter with create, findById (left-joins agents for alias), findAll (optional projectId/status filters, desc by createdAt), update, delete - Wire exports into repositories/index.ts and repositories/drizzle/index.ts - Add migration 0035_faulty_human_fly.sql (CREATE TABLE errands) and drizzle snapshot - Add 13 tests covering CRUD, filtering, ordering, agentAlias join, cascade/set-null FK behaviour - Update docs/database.md to document the errands table and ErrandRepository Co-Authored-By: Claude Sonnet 4.6 --- .../db/repositories/drizzle/errand.test.ts | 336 +++ apps/server/db/repositories/drizzle/errand.ts | 89 + apps/server/db/repositories/drizzle/index.ts | 1 + .../db/repositories/errand-repository.ts | 15 + apps/server/db/repositories/index.ts | 8 + apps/server/drizzle/0035_faulty_human_fly.sql | 13 + apps/server/drizzle/meta/0035_snapshot.json | 1974 +++++++++++++++++ docs/database.md | 24 +- 8 files changed, 2456 insertions(+), 4 deletions(-) create mode 100644 apps/server/db/repositories/drizzle/errand.test.ts create mode 100644 apps/server/db/repositories/drizzle/errand.ts create mode 100644 apps/server/db/repositories/errand-repository.ts create mode 100644 apps/server/drizzle/0035_faulty_human_fly.sql create mode 100644 apps/server/drizzle/meta/0035_snapshot.json diff --git a/apps/server/db/repositories/drizzle/errand.test.ts b/apps/server/db/repositories/drizzle/errand.test.ts new file mode 100644 index 0000000..749b2ad --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.test.ts @@ -0,0 +1,336 @@ +/** + * DrizzleErrandRepository Tests + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DrizzleErrandRepository } from './errand.js'; +import { createTestDatabase } from './test-helpers.js'; +import type { DrizzleDatabase } from '../../index.js'; +import { projects, agents, errands } from '../../schema.js'; +import { nanoid } from 'nanoid'; +import { eq } from 'drizzle-orm'; + +describe('DrizzleErrandRepository', () => { + let db: DrizzleDatabase; + let repo: DrizzleErrandRepository; + + beforeEach(() => { + db = createTestDatabase(); + repo = new DrizzleErrandRepository(db); + }); + + // Helper: create a project record + async function createProject(name = 'Test Project', suffix = '') { + const id = nanoid(); + const now = new Date(); + const [project] = await db.insert(projects).values({ + id, + name: name + suffix + id, + url: `https://github.com/test/${id}`, + defaultBranch: 'main', + createdAt: now, + updatedAt: now, + }).returning(); + return project; + } + + // Helper: create an agent record + async function createAgent(name?: string) { + const id = nanoid(); + const now = new Date(); + const agentName = name ?? `agent-${id}`; + const [agent] = await db.insert(agents).values({ + id, + name: agentName, + worktreeId: `agent-workdirs/${agentName}`, + provider: 'claude', + status: 'idle', + mode: 'execute', + createdAt: now, + updatedAt: now, + }).returning(); + return agent; + } + + // Helper: create an errand + async function createErrand(overrides: Partial<{ + id: string; + description: string; + branch: string; + baseBranch: string; + agentId: string | null; + projectId: string | null; + status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; + createdAt: Date; + }> = {}) { + const project = await createProject(); + const id = overrides.id ?? nanoid(); + return repo.create({ + id, + description: overrides.description ?? 'Test errand', + branch: overrides.branch ?? 'feature/test', + baseBranch: overrides.baseBranch ?? 'main', + agentId: overrides.agentId !== undefined ? overrides.agentId : null, + projectId: overrides.projectId !== undefined ? overrides.projectId : project.id, + status: overrides.status ?? 'active', + }); + } + + describe('create + findById', () => { + it('should create errand and find by id with all fields', async () => { + const project = await createProject(); + const id = nanoid(); + + await repo.create({ + id, + description: 'Fix the bug', + branch: 'fix/bug-123', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.id).toBe(id); + expect(found!.description).toBe('Fix the bug'); + expect(found!.branch).toBe('fix/bug-123'); + expect(found!.baseBranch).toBe('main'); + expect(found!.status).toBe('active'); + expect(found!.projectId).toBe(project.id); + expect(found!.agentId).toBeNull(); + expect(found!.agentAlias).toBeNull(); + }); + }); + + describe('findAll', () => { + it('should return all errands ordered by createdAt desc', async () => { + const project = await createProject(); + const t1 = new Date('2024-01-01T00:00:00Z'); + const t2 = new Date('2024-01-02T00:00:00Z'); + const t3 = new Date('2024-01-03T00:00:00Z'); + + const id1 = nanoid(); + const id2 = nanoid(); + const id3 = nanoid(); + + await db.insert(errands).values([ + { id: id1, description: 'Errand 1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t1, updatedAt: t1 }, + { id: id2, description: 'Errand 2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t2, updatedAt: t2 }, + { id: id3, description: 'Errand 3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t3, updatedAt: t3 }, + ]); + + const result = await repo.findAll(); + expect(result.length).toBeGreaterThanOrEqual(3); + // Find our three in the results + const ids = result.map((e) => e.id); + expect(ids.indexOf(id3)).toBeLessThan(ids.indexOf(id2)); + expect(ids.indexOf(id2)).toBeLessThan(ids.indexOf(id1)); + }); + + it('should filter by projectId', async () => { + const projectA = await createProject('A'); + const projectB = await createProject('B'); + const now = new Date(); + + const idA1 = nanoid(); + const idA2 = nanoid(); + const idB1 = nanoid(); + + await db.insert(errands).values([ + { id: idA1, description: 'A1', branch: 'b-a1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idA2, description: 'A2', branch: 'b-a2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idB1, description: 'B1', branch: 'b-b1', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ projectId: projectA.id }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id).sort()).toEqual([idA1, idA2].sort()); + }); + + it('should filter by status', async () => { + const project = await createProject(); + const now = new Date(); + + const id1 = nanoid(); + const id2 = nanoid(); + const id3 = nanoid(); + + await db.insert(errands).values([ + { id: id1, description: 'E1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: now, updatedAt: now }, + { id: id2, description: 'E2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: id3, description: 'E3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'merged', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ status: 'pending_review' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(id2); + }); + + it('should filter by both projectId and status', async () => { + const projectA = await createProject('PA'); + const projectB = await createProject('PB'); + const now = new Date(); + + const idMatch = nanoid(); + const idOtherStatus = nanoid(); + const idOtherProject = nanoid(); + const idNeither = nanoid(); + + await db.insert(errands).values([ + { id: idMatch, description: 'Match', branch: 'b1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: idOtherStatus, description: 'Wrong status', branch: 'b2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idOtherProject, description: 'Wrong project', branch: 'b3', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: idNeither, description: 'Neither', branch: 'b4', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ projectId: projectA.id, status: 'pending_review' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(idMatch); + }); + }); + + describe('findById', () => { + it('should return agentAlias when agentId is set', async () => { + const agent = await createAgent('known-agent'); + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'With agent', + branch: 'feature/x', + baseBranch: 'main', + agentId: agent.id, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.agentAlias).toBe(agent.name); + }); + + it('should return agentAlias as null when agentId is null', async () => { + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'No agent', + branch: 'feature/y', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.agentAlias).toBeNull(); + }); + + it('should return undefined for unknown id', async () => { + const found = await repo.findById('nonexistent'); + expect(found).toBeUndefined(); + }); + }); + + describe('update', () => { + it('should update status and advance updatedAt', async () => { + const project = await createProject(); + const id = nanoid(); + const past = new Date('2024-01-01T00:00:00Z'); + + await db.insert(errands).values({ + id, + description: 'Errand', + branch: 'feature/update', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: past, + updatedAt: past, + }); + + const updated = await repo.update(id, { status: 'pending_review' }); + expect(updated.status).toBe('pending_review'); + expect(updated.updatedAt.getTime()).toBeGreaterThan(past.getTime()); + }); + + it('should throw on unknown id', async () => { + await expect( + repo.update('nonexistent', { status: 'merged' }) + ).rejects.toThrow('Errand not found'); + }); + }); + + describe('delete', () => { + it('should delete errand and findById returns undefined', async () => { + const errand = await createErrand(); + await repo.delete(errand.id); + const found = await repo.findById(errand.id); + expect(found).toBeUndefined(); + }); + }); + + describe('cascade and set null', () => { + it('should cascade delete errands when project is deleted', async () => { + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'Cascade test', + branch: 'feature/cascade', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + // Delete project — should cascade delete errands + await db.delete(projects).where(eq(projects.id, project.id)); + + const found = await repo.findById(id); + expect(found).toBeUndefined(); + }); + + it('should set agentId to null when agent is deleted', async () => { + const agent = await createAgent(); + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'Agent null test', + branch: 'feature/agent-null', + baseBranch: 'main', + agentId: agent.id, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + // Delete agent — should set null + await db.delete(agents).where(eq(agents.id, agent.id)); + + const [errand] = await db.select().from(errands).where(eq(errands.id, id)); + expect(errand).toBeDefined(); + expect(errand.agentId).toBeNull(); + }); + }); +}); diff --git a/apps/server/db/repositories/drizzle/errand.ts b/apps/server/db/repositories/drizzle/errand.ts new file mode 100644 index 0000000..0774e4b --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.ts @@ -0,0 +1,89 @@ +/** + * Drizzle Errand Repository Adapter + * + * Implements ErrandRepository interface using Drizzle ORM. + */ + +import { eq, desc, and } from 'drizzle-orm'; +import type { DrizzleDatabase } from '../../index.js'; +import { errands, agents } from '../../schema.js'; +import type { + ErrandRepository, + ErrandWithAlias, + ErrandStatus, + CreateErrandData, + UpdateErrandData, +} from '../errand-repository.js'; +import type { Errand } from '../../schema.js'; + +export class DrizzleErrandRepository implements ErrandRepository { + constructor(private db: DrizzleDatabase) {} + + async create(data: CreateErrandData): Promise { + const now = new Date(); + const [created] = await this.db + .insert(errands) + .values({ ...data, createdAt: now, updatedAt: now }) + .returning(); + return created; + } + + async findById(id: string): Promise { + const result = await this.db + .select({ + id: errands.id, + description: errands.description, + branch: errands.branch, + baseBranch: errands.baseBranch, + agentId: errands.agentId, + projectId: errands.projectId, + status: errands.status, + createdAt: errands.createdAt, + updatedAt: errands.updatedAt, + agentAlias: agents.name, + }) + .from(errands) + .leftJoin(agents, eq(errands.agentId, agents.id)) + .where(eq(errands.id, id)) + .limit(1); + return result[0] ?? undefined; + } + + async findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise { + const conditions = []; + if (opts?.projectId) conditions.push(eq(errands.projectId, opts.projectId)); + if (opts?.status) conditions.push(eq(errands.status, opts.status)); + + return this.db + .select({ + id: errands.id, + description: errands.description, + branch: errands.branch, + baseBranch: errands.baseBranch, + agentId: errands.agentId, + projectId: errands.projectId, + status: errands.status, + createdAt: errands.createdAt, + updatedAt: errands.updatedAt, + agentAlias: agents.name, + }) + .from(errands) + .leftJoin(agents, eq(errands.agentId, agents.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(errands.createdAt)); + } + + async update(id: string, data: UpdateErrandData): Promise { + const [updated] = await this.db + .update(errands) + .set({ ...data, updatedAt: new Date() }) + .where(eq(errands.id, id)) + .returning(); + if (!updated) throw new Error(`Errand not found: ${id}`); + return updated; + } + + async delete(id: string): Promise { + await this.db.delete(errands).where(eq(errands.id, id)); + } +} diff --git a/apps/server/db/repositories/drizzle/index.ts b/apps/server/db/repositories/drizzle/index.ts index c29daba..78afdea 100644 --- a/apps/server/db/repositories/drizzle/index.ts +++ b/apps/server/db/repositories/drizzle/index.ts @@ -18,3 +18,4 @@ export { DrizzleLogChunkRepository } from './log-chunk.js'; export { DrizzleConversationRepository } from './conversation.js'; export { DrizzleChatSessionRepository } from './chat-session.js'; export { DrizzleReviewCommentRepository } from './review-comment.js'; +export { DrizzleErrandRepository } from './errand.js'; diff --git a/apps/server/db/repositories/errand-repository.ts b/apps/server/db/repositories/errand-repository.ts new file mode 100644 index 0000000..9502e34 --- /dev/null +++ b/apps/server/db/repositories/errand-repository.ts @@ -0,0 +1,15 @@ +import type { Errand, NewErrand } from '../schema.js'; + +export type ErrandStatus = 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; +export type ErrandWithAlias = Errand & { agentAlias: string | null }; + +export type CreateErrandData = Omit; +export type UpdateErrandData = Partial>; + +export interface ErrandRepository { + create(data: CreateErrandData): Promise; + findById(id: string): Promise; + findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise; + update(id: string, data: UpdateErrandData): Promise; + delete(id: string): Promise; +} diff --git a/apps/server/db/repositories/index.ts b/apps/server/db/repositories/index.ts index 809214c..c1407df 100644 --- a/apps/server/db/repositories/index.ts +++ b/apps/server/db/repositories/index.ts @@ -82,3 +82,11 @@ export type { ReviewCommentRepository, CreateReviewCommentData, } from './review-comment-repository.js'; + +export type { + ErrandRepository, + ErrandWithAlias, + ErrandStatus, + CreateErrandData, + UpdateErrandData, +} from './errand-repository.js'; diff --git a/apps/server/drizzle/0035_faulty_human_fly.sql b/apps/server/drizzle/0035_faulty_human_fly.sql new file mode 100644 index 0000000..5afe9b5 --- /dev/null +++ b/apps/server/drizzle/0035_faulty_human_fly.sql @@ -0,0 +1,13 @@ +CREATE TABLE `errands` ( + `id` text PRIMARY KEY NOT NULL, + `description` text NOT NULL, + `branch` text NOT NULL, + `base_branch` text DEFAULT 'main' NOT NULL, + `agent_id` text, + `project_id` text, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0035_snapshot.json b/apps/server/drizzle/meta/0035_snapshot.json new file mode 100644 index 0000000..d735a97 --- /dev/null +++ b/apps/server/drizzle/meta/0035_snapshot.json @@ -0,0 +1,1974 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c84e499f-7df8-4091-b2a5-6b12847898bd", + "prevId": "5fbe1151-1dfb-4b0c-a7fa-2177369543fd", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_log_chunks": { + "name": "agent_log_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_number": { + "name": "session_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_log_chunks_agent_id_idx": { + "name": "agent_log_chunks_agent_id_idx", + "columns": [ + "agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_dismissed_at": { + "name": "user_dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_set_entries": { + "name": "change_set_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_state": { + "name": "previous_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_state": { + "name": "new_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_set_entries_change_set_id_idx": { + "name": "change_set_entries_change_set_id_idx", + "columns": [ + "change_set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_set_entries_change_set_id_change_sets_id_fk": { + "name": "change_set_entries_change_set_id_change_sets_id_fk", + "tableFrom": "change_set_entries", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_sets": { + "name": "change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'applied'" + }, + "reverted_at": { + "name": "reverted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_sets_initiative_id_idx": { + "name": "change_sets_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_sets_agent_id_agents_id_fk": { + "name": "change_sets_agent_id_agents_id_fk", + "tableFrom": "change_sets", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "change_sets_initiative_id_initiatives_id_fk": { + "name": "change_sets_initiative_id_initiatives_id_fk", + "tableFrom": "change_sets", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_session_id_idx": { + "name": "chat_messages_session_id_idx", + "columns": [ + "chat_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_chat_session_id_chat_sessions_id_fk": { + "name": "chat_messages_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_change_set_id_change_sets_id_fk": { + "name": "chat_messages_change_set_id_change_sets_id_fk", + "tableFrom": "chat_messages", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_sessions_target_idx": { + "name": "chat_sessions_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "chat_sessions_initiative_id_idx": { + "name": "chat_sessions_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_initiative_id_initiatives_id_fk": { + "name": "chat_sessions_initiative_id_initiatives_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_agent_id_agents_id_fk": { + "name": "chat_sessions_agent_id_agents_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_agent_id": { + "name": "from_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_agent_id": { + "name": "to_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_to_agent_status_idx": { + "name": "conversations_to_agent_status_idx", + "columns": [ + "to_agent_id", + "status" + ], + "isUnique": false + }, + "conversations_from_agent_idx": { + "name": "conversations_from_agent_idx", + "columns": [ + "from_agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_from_agent_id_agents_id_fk": { + "name": "conversations_from_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "from_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_to_agent_id_agents_id_fk": { + "name": "conversations_to_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "to_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_initiative_id_initiatives_id_fk": { + "name": "conversations_initiative_id_initiatives_id_fk", + "tableFrom": "conversations", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_phase_id_phases_id_fk": { + "name": "conversations_phase_id_phases_id_fk", + "tableFrom": "conversations", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "errands": { + "name": "errands", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "errands_agent_id_agents_id_fk": { + "name": "errands_agent_id_agents_id_fk", + "tableFrom": "errands", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "errands_project_id_projects_id_fk": { + "name": "errands_project_id_projects_id_fk", + "tableFrom": "errands", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'review_per_phase'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "depends_on_phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "merge_base": { + "name": "merge_base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "review_comments": { + "name": "review_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_type": { + "name": "line_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'you'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "review_comments_phase_id_idx": { + "name": "review_comments_phase_id_idx", + "columns": [ + "phase_id" + ], + "isUnique": false + }, + "review_comments_parent_id_idx": { + "name": "review_comments_parent_id_idx", + "columns": [ + "parent_comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "review_comments_phase_id_phases_id_fk": { + "name": "review_comments_phase_id_phases_id_fk", + "tableFrom": "review_comments", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_parent_comment_id_review_comments_id_fk": { + "name": "review_comments_parent_comment_id_review_comments_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "depends_on_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_parent_task_id_tasks_id_fk": { + "name": "tasks_parent_task_id_tasks_id_fk", + "tableFrom": "tasks", + "tableTo": "tasks", + "columnsFrom": [ + "parent_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/docs/database.md b/docs/database.md index dd12a0b..0cc848f 100644 --- a/docs/database.md +++ b/docs/database.md @@ -5,8 +5,8 @@ ## Architecture - **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations -- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 13 repository interfaces -- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 13 Drizzle adapters +- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 14 repository interfaces +- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 14 Drizzle adapters - **Barrel exports**: `apps/server/db/index.ts` re-exports everything All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes. @@ -196,6 +196,21 @@ Messages within a chat session. Index: `(chatSessionId)`. +### errands + +Tracks errand work items linked to a project branch, optionally assigned to an agent. + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | caller-supplied | +| description | text NOT NULL | human-readable description | +| branch | text NOT NULL | working branch name | +| baseBranch | text NOT NULL | default 'main' | +| agentId | text FK → agents (set null) | assigned agent; null if unassigned | +| projectId | text FK → projects (cascade) | owning project | +| status | text enum | active, pending_review, conflict, merged, abandoned; default 'active' | +| createdAt, updatedAt | integer/timestamp | | + ### review_comments Inline review comments on phase diffs, persisted across page reloads. @@ -216,7 +231,7 @@ Index: `(phaseId)`. ## Repository Interfaces -13 repositories, each with standard CRUD plus domain-specific methods: +14 repositories, each with standard CRUD plus domain-specific methods: | Repository | Key Methods | |-----------|-------------| @@ -233,6 +248,7 @@ Index: `(phaseId)`. | ConversationRepository | create, findById, findPendingForAgent, answer | | ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId | | ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete | +| ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete | ## Migrations @@ -244,4 +260,4 @@ Key rules: - See [database-migrations.md](database-migrations.md) for full workflow - Snapshots stale after 0008; migrations 0008+ are hand-written -Current migrations: 0000 through 0030 (31 total). +Current migrations: 0000 through 0035 (36 total). From a86a373d42f966f32ab45813a4b458456df10cd7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:37:21 +0100 Subject: [PATCH 68/85] fix: Handle push to checked-out branch in local non-bare repos git refuses to push to a branch that is currently checked out in a non-bare repository. When the clone's origin is the user's local repo, this blocks merge_and_push entirely. On "branch is currently checked out" error, temporarily set receive.denyCurrentBranch=updateInstead on the remote and retry. This uses git's built-in mechanism to update the working tree safely (rejects if dirty). --- apps/server/git/simple-git-branch-manager.ts | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index 5b46640..47b690e 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -6,7 +6,7 @@ * on project clones without requiring a worktree. */ -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { simpleGit } from 'simple-git'; @@ -164,7 +164,26 @@ export class SimpleGitBranchManager implements BranchManager { async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise { const git = simpleGit(repoPath); - await git.push(remote, branch); + try { + await git.push(remote, branch); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('branch is currently checked out')) throw err; + + // Local non-bare repo with the branch checked out — temporarily allow it. + // receive.denyCurrentBranch=updateInstead updates the remote's working tree + // and index to match, or rejects if the working tree is dirty. + const remoteUrl = (await git.remote(['get-url', remote]))?.trim(); + if (!remoteUrl) throw err; + const remotePath = resolve(repoPath, remoteUrl); + const remoteGit = simpleGit(remotePath); + await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead'); + try { + await git.push(remote, branch); + } finally { + await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']); + } + } log.info({ repoPath, branch, remote }, 'branch pushed to remote'); } From 243f24a39789614e78ab12fe470d1a55bc3aea1e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:46:39 +0100 Subject: [PATCH 69/85] 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 70/85] 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 71/85] 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 72/85] 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 73/85] 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 b2f4004191b063a908182bfd154c6ee6062a3c3f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:13:01 +0100 Subject: [PATCH 74/85] feat: Persist agent prompt in DB so getAgentPrompt survives log cleanup The `getAgentPrompt` tRPC procedure previously read exclusively from `.cw/agent-logs//PROMPT.md`. Once the cleanup-manager removes that directory, the prompt is gone forever. Adds a `prompt` text column to the `agents` table and writes the fully assembled prompt (including workspace layout, inter-agent comms, and preview sections) to the DB in the same `repository.update()` call that saves pid/outputFilePath after spawn. `getAgentPrompt` now reads from DB first (`agent.prompt`) and falls back to the filesystem only for agents spawned before this change. Addresses review comment [MMcmVlEK16bBfkJuXvG6h]. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/manager.ts | 4 +- apps/server/agent/mock-manager.ts | 1 + apps/server/agent/types.ts | 2 + .../db/repositories/agent-repository.ts | 1 + apps/server/db/schema.ts | 1 + apps/server/dispatch/manager.test.ts | 2 + apps/server/drizzle/0031_icy_silvermane.sql | 1 + apps/server/drizzle/meta/0031_snapshot.json | 1159 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + .../integration/crash-race-condition.test.ts | 4 +- apps/server/trpc/routers/agent.test.ts | 41 +- apps/server/trpc/routers/agent.ts | 24 +- docs/database.md | 1 + docs/server-api.md | 2 +- 14 files changed, 1239 insertions(+), 11 deletions(-) create mode 100644 apps/server/drizzle/0031_icy_silvermane.sql create mode 100644 apps/server/drizzle/meta/0031_snapshot.json diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 1b25798..1fb3655 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -328,7 +328,7 @@ export class MultiProviderAgentManager implements AgentManager { this.createLogChunkCallback(agentId, alias, 1), ); - await this.repository.update(agentId, { pid, outputFilePath }); + await this.repository.update(agentId, { pid, outputFilePath, prompt }); // Write spawn diagnostic file for post-execution verification const diagnostic = { @@ -1086,6 +1086,7 @@ export class MultiProviderAgentManager implements AgentManager { updatedAt: Date; userDismissedAt?: Date | null; exitCode?: number | null; + prompt?: string | null; }): AgentInfo { return { id: agent.id, @@ -1102,6 +1103,7 @@ export class MultiProviderAgentManager implements AgentManager { updatedAt: agent.updatedAt, userDismissedAt: agent.userDismissedAt, exitCode: agent.exitCode ?? null, + prompt: agent.prompt ?? null, }; } } diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index d8cb009..68d49be 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -143,6 +143,7 @@ export class MockAgentManager implements AgentManager { createdAt: now, updatedAt: now, exitCode: null, + prompt: null, }; const record: MockAgentRecord = { diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 1f5e029..dcd44c8 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -95,6 +95,8 @@ export interface AgentInfo { userDismissedAt?: Date | null; /** Process exit code — null while running or if not yet exited */ exitCode: number | null; + /** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */ + prompt: string | null; } /** diff --git a/apps/server/db/repositories/agent-repository.ts b/apps/server/db/repositories/agent-repository.ts index c54ca40..f4f4994 100644 --- a/apps/server/db/repositories/agent-repository.ts +++ b/apps/server/db/repositories/agent-repository.ts @@ -45,6 +45,7 @@ export interface UpdateAgentData { accountId?: string | null; pid?: number | null; exitCode?: number | null; + prompt?: string | null; outputFilePath?: string | null; result?: string | null; pendingQuestions?: string | null; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 1e371db..3fdb362 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -265,6 +265,7 @@ export const agents = sqliteTable('agents', { .default('execute'), pid: integer('pid'), exitCode: integer('exit_code'), // Process exit code for debugging crashes + prompt: text('prompt'), // Full assembled prompt passed to the agent process (persisted for durability after log cleanup) outputFilePath: text('output_file_path'), result: text('result'), pendingQuestions: text('pending_questions'), diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index c6558ad..cb0f4e6 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -71,6 +71,7 @@ function createMockAgentManager( createdAt: new Date(), updatedAt: new Date(), exitCode: null, + prompt: null, }; mockAgents.push(newAgent); return newAgent; @@ -103,6 +104,7 @@ function createIdleAgent(id: string, name: string): AgentInfo { createdAt: new Date(), updatedAt: new Date(), exitCode: null, + prompt: null, }; } diff --git a/apps/server/drizzle/0031_icy_silvermane.sql b/apps/server/drizzle/0031_icy_silvermane.sql new file mode 100644 index 0000000..43dbeed --- /dev/null +++ b/apps/server/drizzle/0031_icy_silvermane.sql @@ -0,0 +1 @@ +ALTER TABLE `agents` ADD `prompt` text; diff --git a/apps/server/drizzle/meta/0031_snapshot.json b/apps/server/drizzle/meta/0031_snapshot.json new file mode 100644 index 0000000..f60484b --- /dev/null +++ b/apps/server/drizzle/meta/0031_snapshot.json @@ -0,0 +1,1159 @@ +{ + "id": "f85b9df3-dead-4c46-90ac-cf36bcaa6eb4", + "prevId": "c0b6d7d3-c9da-440a-9fb8-9dd88df5672a", + "version": "6", + "dialect": "sqlite", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_dir": { + "name": "config_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "merge_requires_approval": { + "name": "merge_requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "merge_target": { + "name": "merge_target", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "sender_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "recipient_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "tableTo": "messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "columnsFrom": [ + "depends_on_phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plans": { + "name": "plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plans_phase_id_phases_id_fk": { + "name": "plans_phase_id_phases_id_fk", + "tableFrom": "plans", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "depends_on_task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "requires_approval": { + "name": "requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_plan_id_plans_id_fk": { + "name": "tasks_plan_id_plans_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "plan_id" + ], + "tableTo": "plans", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index ac6687d..e4b74fa 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1772150400000, "tag": "0030_remove_task_approval", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1772798869413, + "tag": "0031_icy_silvermane", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/test/integration/crash-race-condition.test.ts b/apps/server/test/integration/crash-race-condition.test.ts index f7ce25f..4af02a1 100644 --- a/apps/server/test/integration/crash-race-condition.test.ts +++ b/apps/server/test/integration/crash-race-condition.test.ts @@ -32,6 +32,7 @@ interface TestAgent { initiativeId: string | null; userDismissedAt: Date | null; exitCode: number | null; + prompt: string | null; } describe('Crash marking race condition', () => { @@ -72,7 +73,8 @@ describe('Crash marking race condition', () => { pendingQuestions: null, initiativeId: 'init-1', userDismissedAt: null, - exitCode: null + exitCode: null, + prompt: null, }; // Mock repository that tracks all update calls diff --git a/apps/server/trpc/routers/agent.test.ts b/apps/server/trpc/routers/agent.test.ts index 49de47e..21bcc6d 100644 --- a/apps/server/trpc/routers/agent.test.ts +++ b/apps/server/trpc/routers/agent.test.ts @@ -50,6 +50,7 @@ function makeAgentInfo(overrides: Record = {}) { updatedAt: new Date('2026-01-01T00:00:00Z'), userDismissedAt: null, exitCode: null, + prompt: null, ...overrides, }; } @@ -273,7 +274,7 @@ describe('getAgentPrompt', () => { await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent); const mockManager = { - get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName })), + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })), }; const ctx = createTestContext({ @@ -285,4 +286,42 @@ describe('getAgentPrompt', () => { expect(result).toEqual({ content: promptContent }); }); + + it('returns prompt from DB when agent.prompt is set (no file needed)', async () => { + const dbPromptContent = '# DB Prompt\nThis is persisted in the database'; + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })), + }; + + // workspaceRoot has no PROMPT.md — but DB value takes precedence + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: dbPromptContent }); + }); + + it('falls back to PROMPT.md when agent.prompt is null in DB', async () => { + const agentName = 'test-agent'; + const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName); + await fs.mkdir(promptDir, { recursive: true }); + const fileContent = '# File Prompt\nThis is from the file (legacy)'; + await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent); + + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: fileContent }); + }); }); diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 53b42fc..8c2f2fe 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -342,6 +342,22 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { .query(async ({ ctx, input }) => { const agent = await resolveAgent(ctx, { id: input.id }); + const MAX_BYTES = 1024 * 1024; // 1 MB + + function truncateIfNeeded(text: string): string { + if (Buffer.byteLength(text, 'utf-8') > MAX_BYTES) { + const buf = Buffer.from(text, 'utf-8'); + return buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]'; + } + return text; + } + + // Prefer DB-persisted prompt (durable even after log file cleanup) + if (agent.prompt !== null) { + return { content: truncateIfNeeded(agent.prompt) }; + } + + // Fall back to filesystem for agents spawned before DB persistence was added const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md'); let raw: string; @@ -357,13 +373,7 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { }); } - const MAX_BYTES = 1024 * 1024; // 1 MB - if (Buffer.byteLength(raw, 'utf-8') > MAX_BYTES) { - const buf = Buffer.from(raw, 'utf-8'); - raw = buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]'; - } - - return { content: raw }; + return { content: truncateIfNeeded(raw) }; }), }; } diff --git a/docs/database.md b/docs/database.md index a32cac5..1c683b7 100644 --- a/docs/database.md +++ b/docs/database.md @@ -70,6 +70,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' | | pid | integer nullable | OS process ID | | exitCode | integer nullable | | +| prompt | text nullable | Full assembled prompt passed to agent at spawn; persisted for durability after log cleanup | | outputFilePath | text nullable | | | result | text nullable | JSON | | pendingQuestions | text nullable | JSON | diff --git a/docs/server-api.md b/docs/server-api.md index dc22e20..929c748 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -64,7 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getAgentQuestions | query | Pending questions | | getAgentOutput | query | Full output from DB log chunks | | getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) | -| getAgentPrompt | query | Content of `.cw/agent-logs//PROMPT.md` (1 MB cap) | +| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs//PROMPT.md` for pre-persistence agents (1 MB cap) | | getActiveRefineAgent | query | Active refine agent for initiative | | listWaitingAgents | query | Agents waiting for input | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus | From 4ee71d45f42a465ebd42ded0eab4175ab1c0a9a1 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:17:32 +0100 Subject: [PATCH 75/85] 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 76/85] 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 7088c511a9a5f3e49256fd815b495dd8cbce4884 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:21:59 +0100 Subject: [PATCH 77/85] feat: Add Details tab to agent right-panel with metadata, input files, and prompt sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Output/Details tab bar to the agents page right-panel. The Details tab renders AgentDetailsPanel, which surfaces agent metadata (status, mode, provider, initiative link, task name, exit code), input files with a file-picker UI, and the effective prompt text — all streamed via the new getAgent/getAgentInputFiles/getAgentPrompt tRPC procedures. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/AgentDetailsPanel.tsx | 230 ++++++++++++++++++ apps/web/src/routes/agents.tsx | 58 ++++- docs/frontend.md | 4 +- 3 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/AgentDetailsPanel.tsx diff --git a/apps/web/src/components/AgentDetailsPanel.tsx b/apps/web/src/components/AgentDetailsPanel.tsx new file mode 100644 index 0000000..6086f3d --- /dev/null +++ b/apps/web/src/components/AgentDetailsPanel.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from "react"; +import { Link } from "@tanstack/react-router"; +import { trpc } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/Skeleton"; +import { Button } from "@/components/ui/button"; +import { StatusDot } from "@/components/StatusDot"; +import { formatRelativeTime } from "@/lib/utils"; +import { modeLabel } from "@/lib/labels"; + +export function AgentDetailsPanel({ agentId }: { agentId: string }) { + return ( +
+
+

Metadata

+ +
+
+

Input Files

+ +
+
+

Effective Prompt

+ +
+
+ ); +} + +function MetadataSection({ agentId }: { agentId: string }) { + const query = trpc.getAgent.useQuery({ id: agentId }); + + if (query.isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const agent = query.data; + if (!agent) return null; + + const showExitCode = !['idle', 'running', 'waiting_for_input'].includes(agent.status); + + const rows: Array<{ label: string; value: React.ReactNode }> = [ + { + label: 'Status', + value: ( + + + {agent.status} + + ), + }, + { + label: 'Mode', + value: modeLabel(agent.mode), + }, + { + label: 'Provider', + value: agent.provider, + }, + { + label: 'Initiative', + value: agent.initiativeId ? ( + + {(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId} + + ) : '—', + }, + { + label: 'Task', + value: (agent as { taskName?: string | null }).taskName ?? (agent.taskId ? agent.taskId : '—'), + }, + { + label: 'Created', + value: formatRelativeTime(String(agent.createdAt)), + }, + ]; + + if (showExitCode) { + rows.push({ + label: 'Exit Code', + value: ( + + {agent.exitCode ?? '—'} + + ), + }); + } + + return ( +
+ {rows.map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+ ); +} + +function InputFilesSection({ agentId }: { agentId: string }) { + const query = trpc.getAgentInputFiles.useQuery({ id: agentId }); + const [selectedFile, setSelectedFile] = useState(null); + + useEffect(() => { + setSelectedFile(null); + }, [agentId]); + + useEffect(() => { + if (!query.data?.files) return; + if (selectedFile !== null) return; + const manifest = query.data.files.find(f => f.name === 'manifest.json'); + setSelectedFile(manifest?.name ?? query.data.files[0]?.name ?? null); + }, [query.data?.files]); + + if (query.isLoading) { + return ( +
+ + + +
+ ); + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const data = query.data; + if (!data) return null; + + if (data.reason === 'worktree_missing') { + return

Worktree no longer exists — input files unavailable

; + } + + if (data.reason === 'input_dir_missing') { + return

Input directory not found — this agent may not have received input files

; + } + + const { files } = data; + + if (files.length === 0) { + return

No input files found

; + } + + return ( +
+ {/* File list */} +
+ {files.map(file => ( + + ))} +
+ {/* Content pane */} +
+        {files.find(f => f.name === selectedFile)?.content ?? ''}
+      
+
+ ); +} + +function EffectivePromptSection({ agentId }: { agentId: string }) { + const query = trpc.getAgentPrompt.useQuery({ id: agentId }); + + if (query.isLoading) { + return ; + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const data = query.data; + if (!data) return null; + + if (data.reason === 'prompt_not_written') { + return

Prompt file not available — agent may have been spawned before this feature was added

; + } + + if (data.content) { + return ( +
+        {data.content}
+      
+ ); + } + + return null; +} diff --git a/apps/web/src/routes/agents.tsx b/apps/web/src/routes/agents.tsx index 95ff6ce..d03f7a5 100644 --- a/apps/web/src/routes/agents.tsx +++ b/apps/web/src/routes/agents.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react"; @@ -9,8 +9,9 @@ import { Skeleton } from "@/components/Skeleton"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { AgentOutputViewer } from "@/components/AgentOutputViewer"; +import { AgentDetailsPanel } from "@/components/AgentDetailsPanel"; import { AgentActions } from "@/components/AgentActions"; -import { formatRelativeTime } from "@/lib/utils"; +import { formatRelativeTime, cn } from "@/lib/utils"; import { modeLabel } from "@/lib/labels"; import { StatusDot } from "@/components/StatusDot"; import { useLiveUpdates } from "@/hooks"; @@ -29,7 +30,12 @@ export const Route = createFileRoute("/agents")({ function AgentsPage() { const [selectedAgentId, setSelectedAgentId] = useState(null); + const [activeTab, setActiveTab] = useState<'output' | 'details'>('output'); const { filter } = useSearch({ from: "/agents" }); + + useEffect(() => { + setActiveTab('output'); + }, [selectedAgentId]); const navigate = useNavigate(); // Live updates @@ -308,15 +314,49 @@ function AgentsPage() { )}
- {/* Right: Output Viewer */} + {/* Right: Output/Details Viewer */}
{selectedAgent ? ( - +
+ {/* Tab bar */} +
+ + +
+ {/* Panel content */} +
+ {activeTab === 'output' ? ( + + ) : ( + + )} +
+
) : (
diff --git a/docs/frontend.md b/docs/frontend.md index 523bdbf..f538920 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -44,6 +44,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat |-------|-----------|---------| | `/` | `routes/index.tsx` | Dashboard / initiative list | | `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) | +| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel | | `/settings` | `routes/settings/index.tsx` | Settings page | ## Initiative Detail Tabs @@ -54,7 +55,7 @@ The initiative detail page has three tabs managed via local state (not URL param 2. **Execution Tab** — Pipeline visualization, phase management, task dispatch 3. **Review Tab** — Pending proposals from agents -## Component Inventory (73 components) +## Component Inventory (74 components) ### Core Components (`src/components/`) | Component | Purpose | @@ -66,6 +67,7 @@ The initiative detail page has three tabs managed via local state (not URL param | `StatusBadge` | Colored badge using status tokens | | `TaskRow` | Task list item with status, priority, category | | `QuestionForm` | Agent question form with options | +| `AgentDetailsPanel` | Details tab for agent right-panel: metadata, input files, effective prompt | | `InboxDetailPanel` | Agent message detail + response form | | `ProjectPicker` | Checkbox list for project selection | | `RegisterProjectDialog` | Dialog to register new git project | From 52e238924c93c9bf9e2b24ef168a2ead33f83088 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:22:15 +0100 Subject: [PATCH 78/85] 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 79/85] 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 80/85] 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 81/85] 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 82/85] 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 83/85] 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()` From 0f1c5782692b7264a039027f919b19fb9382342d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 14:08:59 +0100 Subject: [PATCH 84/85] fix: Fail fast when agent worktree creation or branch setup fails Previously, branch computation errors and ensureBranch failures were silently swallowed for all tasks, allowing execution agents to spawn without proper git isolation. This caused alert-pony to commit directly to main instead of its task branch. - manager.ts: Verify each project worktree subdirectory exists after createProjectWorktrees; throw if any are missing. Convert passive cwdVerified log to a hard guard. - dispatch/manager.ts: Make branch computation and ensureBranch errors fatal for execution tasks (execute, verify, merge, review) while keeping them non-fatal for planning tasks. --- apps/server/agent/manager.ts | 17 ++++++++++++++--- apps/server/dispatch/manager.ts | 14 +++++++++++--- docs/agent.md | 2 +- docs/dispatch-events.md | 1 + 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 152ac3c..0d650de 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -239,8 +239,18 @@ export class MultiProviderAgentManager implements AgentManager { log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees'); agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch, branchName); - // Log projects linked to the initiative + // Verify each project worktree subdirectory actually exists const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId); + for (const project of projects) { + const projectWorktreePath = join(agentCwd, project.name); + if (!existsSync(projectWorktreePath)) { + throw new Error( + `Worktree subdirectory missing after createProjectWorktrees: ${projectWorktreePath}. ` + + `Agent ${alias} cannot run without an isolated worktree.` + ); + } + } + log.info({ alias, initiativeId, @@ -255,11 +265,12 @@ export class MultiProviderAgentManager implements AgentManager { } // Verify the final agentCwd exists - const cwdVerified = existsSync(agentCwd); + if (!existsSync(agentCwd)) { + throw new Error(`Agent workdir does not exist after creation: ${agentCwd}`); + } log.info({ alias, agentCwd, - cwdVerified, initiativeBasedAgent: !!initiativeId }, 'agent workdir setup completed'); diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index 4ef2f35..554dff3 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -327,8 +327,13 @@ export class DefaultDispatchManager implements DispatchManager { } } } - } catch { - // Non-fatal: fall back to default branching + } catch (err) { + if (!isPlanningCategory(task.category)) { + // Execution tasks MUST have correct branches — fail loudly + throw new Error(`Failed to compute branches for execution task ${task.id}: ${err}`); + } + // Planning tasks: non-fatal, fall back to default branching + log.debug({ taskId: task.id, err }, 'branch computation skipped for planning task'); } // Ensure branches exist in project clones before spawning worktrees @@ -350,7 +355,10 @@ export class DefaultDispatchManager implements DispatchManager { } } } catch (err) { - log.warn({ taskId: task.id, err }, 'failed to ensure branches for task dispatch'); + if (!isPlanningCategory(task.category)) { + throw new Error(`Failed to ensure branches for execution task ${task.id}: ${err}`); + } + log.warn({ taskId: task.id, err }, 'failed to ensure branches for planning task dispatch'); } } } diff --git a/docs/agent.md b/docs/agent.md index 38f55db..15503bf 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -32,7 +32,7 @@ 1. **tRPC procedure** calls `agentManager.spawn(options)` 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//` +3. `AgentProcessManager.createProjectWorktrees()` — creates git worktrees at `agent-workdirs///`. After creation, each project subdirectory is verified to exist; missing worktrees throw immediately to prevent agents running in the wrong directory. 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()` 6. `spawnDetached()` — launches detached child process with file output redirection diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index d9e336d..7f0ffd6 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -69,6 +69,7 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId } 7. **Summary propagation** — `completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/.md` frontmatter. 8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing. 9. **Retry blocked** — `retryBlockedTask(taskId)` resets a blocked task to pending and re-queues it. Exposed via tRPC `retryBlockedTask` mutation. The UI shows a Retry button in the task slide-over when status is `blocked`. +10. **Branch validation** — Branch computation and `ensureBranch` errors are fatal for execution tasks (execute, verify, merge, review) but non-fatal for planning tasks. This prevents agents from spawning without proper branch isolation. ### DispatchManager Methods From 30b27f8b4a08c7bec39d9bf3645ff7462d2c41b2 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 14:13:42 +0100 Subject: [PATCH 85/85] fix: Conflict agent auto-dismiss fails on page load/remount prevStateRef was initialized with current state, so when the page loaded with an already-idle conflict agent, the transition guard was immediately false and dismiss() never fired. Initialize with null instead. --- apps/web/src/components/review/ConflictResolutionPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ConflictResolutionPanel.tsx b/apps/web/src/components/review/ConflictResolutionPanel.tsx index cc55c06..ea2ff36 100644 --- a/apps/web/src/components/review/ConflictResolutionPanel.tsx +++ b/apps/web/src/components/review/ConflictResolutionPanel.tsx @@ -13,7 +13,7 @@ interface ConflictResolutionPanelProps { export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) { const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId); const [showManual, setShowManual] = useState(false); - const prevStateRef = useRef(state); + const prevStateRef = useRef(null); // Auto-dismiss and re-check mergeability when conflict agent completes useEffect(() => {