From e77be50b04194182fc193e33dbe4f9a569a130c4 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:05:47 +0100 Subject: [PATCH 01/18] feat: add Errands to header navigation --- apps/web/src/layouts/AppLayout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 7dbc2eb..58193d0 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -10,6 +10,7 @@ const navItems = [ { label: 'HQ', to: '/hq', badgeKey: null }, { label: 'Initiatives', to: '/initiatives', badgeKey: null }, { label: 'Agents', to: '/agents', badgeKey: 'running' as const }, + { label: 'Errands', to: '/errands', badgeKey: null }, { label: 'Radar', to: '/radar', badgeKey: null }, { label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const }, { label: 'Settings', to: '/settings', badgeKey: null }, From f5b1a3a5b930a6b650298e3358f7f3ceb9e64052 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:13:03 +0100 Subject: [PATCH 02/18] feat: pre-populate retry dialog with crashed agent's original instruction When a refine agent crashes, the Retry dialog now extracts the user_instruction from the agent's stored prompt and pre-fills the textarea, so users can re-run with the same instruction without retyping it. --- apps/web/src/components/RefineSpawnDialog.tsx | 18 ++++++++++++++---- .../src/components/editor/RefineAgentPanel.tsx | 14 +++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/RefineSpawnDialog.tsx b/apps/web/src/components/RefineSpawnDialog.tsx index dc70a23..479c7f5 100644 --- a/apps/web/src/components/RefineSpawnDialog.tsx +++ b/apps/web/src/components/RefineSpawnDialog.tsx @@ -22,6 +22,8 @@ interface RefineSpawnDialogProps { showInstructionInput?: boolean; /** Placeholder text for the instruction textarea */ instructionPlaceholder?: string; + /** Pre-populate the instruction field (e.g. from a crashed agent's original instruction) */ + defaultInstruction?: string; /** Whether the spawn mutation is pending */ isSpawning: boolean; /** Error message if spawn failed */ @@ -38,6 +40,7 @@ export function RefineSpawnDialog({ description, showInstructionInput = true, instructionPlaceholder = "What should the agent focus on? (optional)", + defaultInstruction, isSpawning, error, onSpawn, @@ -53,18 +56,25 @@ export function RefineSpawnDialog({ onSpawn(finalInstruction); }; + const openDialog = () => { + setInstruction(defaultInstruction ?? ""); + setShowDialog(true); + }; + const handleOpenChange = (open: boolean) => { - setShowDialog(open); - if (!open) { + if (open) { + setInstruction(defaultInstruction ?? ""); + } else { setInstruction(""); } + setShowDialog(open); }; const defaultTrigger = ( - - ); - } - - // Serialize agents for InboxList (convert Date to string for wire format) - const serializedAgents = agents.map((a) => ({ - id: a.id, - name: a.name, - status: a.status, - taskId: a.taskId, - updatedAt: String(a.updatedAt), - })); - - // Serialize messages for InboxList - const serializedMessages = messages.map((m) => ({ - id: m.id, - senderId: m.senderId, - content: m.content, - requiresResponse: m.requiresResponse, - status: m.status, - createdAt: String(m.createdAt), - })); - - return ( - -
- {/* Left: Inbox List -- hidden on mobile when agent selected */} - - - - - {/* Right: Detail Panel */} - {selectedAgent && ( - ({ - id: q.id, - question: q.question, - options: q.options, - multiSelect: q.multiSelect, - })) - : null - } - isLoadingQuestions={questionsQuery.isLoading} - questionsError={ - questionsQuery.isError ? questionsQuery.error.message : null - } - onBack={() => setSelectedAgentId(null)} - onSubmitAnswers={handleSubmitAnswers} - onDismissQuestions={handleDismissQuestions} - onDismissMessage={handleDismiss} - isSubmitting={resumeAgentMutation.isPending} - isDismissingQuestions={dismissQuestionsMutation.isPending} - isDismissingMessage={respondToMessageMutation.isPending} - submitError={ - resumeAgentMutation.isError - ? resumeAgentMutation.error.message - : null - } - dismissMessageError={ - respondToMessageMutation.isError - ? respondToMessageMutation.error.message - : null - } - /> - )} - - {/* Empty detail panel placeholder */} - {!selectedAgent && ( -
- -
-

No message selected

-

Select an agent from the inbox to view details

-
-
- )} -
-
- ); -} From 419dda3a1ab001adcbbaf8bc8826289ed4978e92 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:31:10 +0100 Subject: [PATCH 09/18] chore: update TypeScript build info after inbox route replacement --- apps/web/tsconfig.app.tsbuildinfo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tsconfig.app.tsbuildinfo b/apps/web/tsconfig.app.tsbuildinfo index de94090..a48ea07 100644 --- a/apps/web/tsconfig.app.tsbuildinfo +++ b/apps/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/accountcard.tsx","./src/components/actionmenu.tsx","./src/components/addaccountdialog.tsx","./src/components/agentactions.tsx","./src/components/agentdetailspanel.tsx","./src/components/agentoutputviewer.test.tsx","./src/components/agentoutputviewer.tsx","./src/components/browsertitleupdater.tsx","./src/components/changesetbanner.tsx","./src/components/commandpalette.tsx","./src/components/connectionbanner.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencychip.tsx","./src/components/dependencyindicator.tsx","./src/components/emptystate.tsx","./src/components/errorboundary.tsx","./src/components/errorstate.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/healthdot.tsx","./src/components/inboxdetailpanel.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/keyboardshortcuthint.tsx","./src/components/messagecard.tsx","./src/components/navbadge.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/saveindicator.tsx","./src/components/skeleton.tsx","./src/components/skeletoncard.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskrow.tsx","./src/components/themetoggle.tsx","./src/components/updatecredentialsdialog.test.tsx","./src/components/updatecredentialsdialog.tsx","./src/components/chat/changesetinline.tsx","./src/components/chat/chatbubble.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/chatslideover.tsx","./src/components/editor/blockdraghandle.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contenttab.tsx","./src/components/editor/deletesubpagedialog.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkdeletiondetector.ts","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/phasecontenteditor.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasedetailpanel.tsx","./src/components/execution/phasegraph.tsx","./src/components/execution/phasesidebaritem.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plansection.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskgraph.tsx","./src/components/execution/taskslideover.tsx","./src/components/execution/index.ts","./src/components/hq/hqblockedsection.tsx","./src/components/hq/hqemptystate.tsx","./src/components/hq/hqneedsapprovalsection.tsx","./src/components/hq/hqneedsreviewsection.tsx","./src/components/hq/hqsections.test.tsx","./src/components/hq/hqwaitingforinputsection.tsx","./src/components/hq/types.ts","./src/components/pipeline/pipelinegraph.tsx","./src/components/pipeline/pipelinephasegroup.tsx","./src/components/pipeline/pipelinestagecolumn.tsx","./src/components/pipeline/pipelinetab.tsx","./src/components/pipeline/index.ts","./src/components/review/commentform.tsx","./src/components/review/commentthread.tsx","./src/components/review/conflictresolutionpanel.tsx","./src/components/review/diffviewer.tsx","./src/components/review/filecard.tsx","./src/components/review/hunkrows.tsx","./src/components/review/initiativereview.tsx","./src/components/review/linewithcomments.tsx","./src/components/review/previewcontrols.tsx","./src/components/review/reviewheader.tsx","./src/components/review/reviewsidebar.test.tsx","./src/components/review/reviewsidebar.tsx","./src/components/review/reviewtab.tsx","./src/components/review/dummy-data.ts","./src/components/review/index.ts","./src/components/review/parse-diff.ts","./src/components/review/types.ts","./src/components/review/use-syntax-highlight.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usechatsession.ts","./src/hooks/useconflictagent.ts","./src/hooks/useconnectionstatus.ts","./src/hooks/usedebounce.ts","./src/hooks/useglobalkeyboard.ts","./src/hooks/useliveupdates.ts","./src/hooks/useoptimisticmutation.ts","./src/hooks/usephaseautosave.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/category.ts","./src/lib/invalidation.ts","./src/lib/labels.ts","./src/lib/markdown-to-tiptap.ts","./src/lib/parse-agent-output.test.ts","./src/lib/parse-agent-output.ts","./src/lib/theme.tsx","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/hq.test.tsx","./src/routes/hq.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx","./src/routes/settings/projects.tsx"],"errors":true,"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/accountcard.tsx","./src/components/actionmenu.tsx","./src/components/addaccountdialog.tsx","./src/components/agentactions.tsx","./src/components/agentdetailspanel.tsx","./src/components/agentoutputviewer.test.tsx","./src/components/agentoutputviewer.tsx","./src/components/browsertitleupdater.tsx","./src/components/changesetbanner.tsx","./src/components/commandpalette.tsx","./src/components/connectionbanner.tsx","./src/components/createerranddialog.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencychip.tsx","./src/components/dependencyindicator.tsx","./src/components/emptystate.tsx","./src/components/erranddetailpanel.tsx","./src/components/errorboundary.tsx","./src/components/errorstate.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/healthdot.tsx","./src/components/inboxdetailpanel.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/keyboardshortcuthint.tsx","./src/components/messagecard.tsx","./src/components/navbadge.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/saveindicator.tsx","./src/components/skeleton.tsx","./src/components/skeletoncard.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskrow.tsx","./src/components/themetoggle.tsx","./src/components/updatecredentialsdialog.test.tsx","./src/components/updatecredentialsdialog.tsx","./src/components/chat/changesetinline.tsx","./src/components/chat/chatbubble.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/chatslideover.tsx","./src/components/editor/blockdraghandle.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contenttab.tsx","./src/components/editor/deletesubpagedialog.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkdeletiondetector.ts","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/phasecontenteditor.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasedetailpanel.tsx","./src/components/execution/phasegraph.tsx","./src/components/execution/phasesidebaritem.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plansection.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskgraph.tsx","./src/components/execution/taskslideover.tsx","./src/components/execution/index.ts","./src/components/hq/hqblockedsection.tsx","./src/components/hq/hqemptystate.tsx","./src/components/hq/hqneedsapprovalsection.tsx","./src/components/hq/hqneedsreviewsection.tsx","./src/components/hq/hqresolvingconflictssection.tsx","./src/components/hq/hqsections.test.tsx","./src/components/hq/hqwaitingforinputsection.tsx","./src/components/hq/types.ts","./src/components/pipeline/pipelinegraph.tsx","./src/components/pipeline/pipelinephasegroup.tsx","./src/components/pipeline/pipelinestagecolumn.tsx","./src/components/pipeline/pipelinetab.tsx","./src/components/pipeline/index.ts","./src/components/radar/compactioneventsdialog.tsx","./src/components/radar/interagentmessagesdialog.tsx","./src/components/radar/questionsaskeddialog.tsx","./src/components/radar/subagentspawnsdialog.tsx","./src/components/radar/types.ts","./src/components/radar/__tests__/compactioneventsdialog.test.tsx","./src/components/radar/__tests__/interagentmessagesdialog.test.tsx","./src/components/radar/__tests__/questionsaskeddialog.test.tsx","./src/components/radar/__tests__/subagentspawnsdialog.test.tsx","./src/components/review/commentform.tsx","./src/components/review/commentthread.tsx","./src/components/review/conflictresolutionpanel.tsx","./src/components/review/diffviewer.test.tsx","./src/components/review/diffviewer.tsx","./src/components/review/filecard.test.tsx","./src/components/review/filecard.tsx","./src/components/review/hunkrows.tsx","./src/components/review/initiativereview.tsx","./src/components/review/linewithcomments.tsx","./src/components/review/previewcontrols.tsx","./src/components/review/reviewheader.tsx","./src/components/review/reviewsidebar.test.tsx","./src/components/review/reviewsidebar.tsx","./src/components/review/reviewtab.test.tsx","./src/components/review/reviewtab.tsx","./src/components/review/comment-index.test.tsx","./src/components/review/comment-index.ts","./src/components/review/dummy-data.ts","./src/components/review/highlight-worker.ts","./src/components/review/index.ts","./src/components/review/parse-diff.ts","./src/components/review/types.test.ts","./src/components/review/types.ts","./src/components/review/use-syntax-highlight.fallback.test.ts","./src/components/review/use-syntax-highlight.test.ts","./src/components/review/use-syntax-highlight.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usechatsession.ts","./src/hooks/useconflictagent.ts","./src/hooks/useconnectionstatus.ts","./src/hooks/usedebounce.ts","./src/hooks/useglobalkeyboard.ts","./src/hooks/useliveupdates.ts","./src/hooks/useoptimisticmutation.ts","./src/hooks/usephaseautosave.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/category.ts","./src/lib/invalidation.ts","./src/lib/labels.ts","./src/lib/markdown-to-tiptap.ts","./src/lib/parse-agent-output.test.ts","./src/lib/parse-agent-output.ts","./src/lib/theme.tsx","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/hq.test.tsx","./src/routes/hq.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/radar.tsx","./src/routes/settings.tsx","./src/routes/errands/index.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx","./src/routes/settings/projects.tsx"],"errors":true,"version":"5.9.3"} \ No newline at end of file From 0f53930610c861c603dfd8125a90b432fbd5b7d7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:31:20 +0100 Subject: [PATCH 10/18] feat: auto-create Integration phase for multi-leaf initiatives When an initiative has multiple end phases (leaf nodes with no dependents), queueAllPhases now auto-creates an Integration phase that depends on all of them. This catches cross-phase incompatibilities (type mismatches, conflicting exports, broken tests) before review. --- apps/server/trpc/routers/phase-dispatch.ts | 62 +++++++++++++++++++++- docs/dispatch-events.md | 12 +++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/apps/server/trpc/routers/phase-dispatch.ts b/apps/server/trpc/routers/phase-dispatch.ts index 4524390..ede1831 100644 --- a/apps/server/trpc/routers/phase-dispatch.ts +++ b/apps/server/trpc/routers/phase-dispatch.ts @@ -4,10 +4,35 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import type { Task } from '../../db/schema.js'; +import type { Phase, Task } from '../../db/schema.js'; import type { ProcedureBuilder } from '../trpc.js'; import { requirePhaseDispatchManager, requirePhaseRepository, requireTaskRepository } from './_helpers.js'; +const INTEGRATION_PHASE_NAME = 'Integration'; + +const INTEGRATION_TASK_DESCRIPTION = `Verify that all phase branches integrate correctly after merging into the initiative branch. + +Steps: +1. Build the project — fix any compilation errors +2. Run the full test suite — fix any failing tests +3. Run type checking and linting — fix any errors +4. Review cross-phase imports and shared interfaces for compatibility +5. Smoke test key user flows affected by the merged changes + +Only fix integration issues (type mismatches, conflicting exports, broken tests). Do not refactor or improve existing code.`; + +/** + * Find phase IDs that have no dependents (no other phase depends on them). + * These are the "end" / "leaf" phases in the dependency graph. + */ +function findEndPhaseIds( + phases: Phase[], + edges: Array<{ phaseId: string; dependsOnPhaseId: string }>, +): string[] { + const dependedOn = new Set(edges.map((e) => e.dependsOnPhaseId)); + return phases.filter((p) => !dependedOn.has(p.id)).map((p) => p.id); +} + export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) { return { queuePhase: publicProcedure @@ -23,7 +48,40 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) { .mutation(async ({ ctx, input }) => { const phaseDispatchManager = requirePhaseDispatchManager(ctx); const phaseRepo = requirePhaseRepository(ctx); - const phases = await phaseRepo.findByInitiativeId(input.initiativeId); + const taskRepo = requireTaskRepository(ctx); + + let phases = await phaseRepo.findByInitiativeId(input.initiativeId); + const edges = await phaseRepo.findDependenciesByInitiativeId(input.initiativeId); + + // Auto-create Integration phase if multiple end phases exist + const existingIntegration = phases.find((p) => p.name === INTEGRATION_PHASE_NAME); + if (!existingIntegration) { + const endPhaseIds = findEndPhaseIds(phases, edges); + if (endPhaseIds.length > 1) { + const integrationPhase = await phaseRepo.create({ + initiativeId: input.initiativeId, + name: INTEGRATION_PHASE_NAME, + status: 'approved', + }); + + for (const endPhaseId of endPhaseIds) { + await phaseRepo.createDependency(integrationPhase.id, endPhaseId); + } + + await taskRepo.create({ + phaseId: integrationPhase.id, + initiativeId: input.initiativeId, + name: 'Verify integration', + description: INTEGRATION_TASK_DESCRIPTION, + category: 'verify', + status: 'pending', + }); + + // Re-fetch so the new phase gets queued in the loop below + phases = await phaseRepo.findByInitiativeId(input.initiativeId); + } + } + let queued = 0; for (const phase of phases) { if (phase.status === 'approved') { diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 5d1b4e9..9fe59fe 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -93,6 +93,18 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId } 4. **Auto-queue tasks** — When phase starts (branches confirmed), pending execution tasks are queued (planning-category tasks excluded) 5. **Events** — `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked` +### Auto-Integration Phase + +When `queueAllPhases` is called (i.e. the user clicks "Execute"), it auto-creates an **Integration** phase if the initiative has multiple end phases (leaf nodes with no dependents). This catches cross-phase incompatibilities before the initiative reaches review. + +- **Trigger**: `queueAllPhases` in `apps/server/trpc/routers/phase-dispatch.ts` +- **Guard**: Only created when `endPhaseIds.length > 1` and no existing "Integration" phase +- **Status**: Created as `approved` (same pattern as Finalization in orchestrator.ts) +- **Dependencies**: Integration depends on all end phases — dispatched last +- **Task**: A single `verify` category task instructs the agent to build, run tests, check types, and review cross-phase imports +- **Idempotency**: Name-based check prevents duplicates on re-execution +- **Coexistence**: Independent of the Finalization phase (different purpose, different trigger) + ### PhaseDispatchManager Methods | Method | Purpose | From 6eb1f8fc2a6e93618f5b8bb4ab7bebbf4be197b5 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:31:41 +0100 Subject: [PATCH 11/18] feat: add agent_metrics write+read path to LogChunkRepository Wrap insertChunk in a synchronous better-sqlite3 transaction that upserts agent_metrics counters atomically on every chunk insert. Malformed JSON skips the upsert but always preserves the chunk row. Add findMetricsByAgentIds to the interface and Drizzle adapter for efficient bulk metric reads. Add 8-test suite covering all write/read paths and edge cases. Co-Authored-By: Claude Sonnet 4.6 --- .../db/repositories/drizzle/log-chunk.test.ts | 129 ++++++++++++++++++ .../db/repositories/drizzle/log-chunk.ts | 81 +++++++++-- .../db/repositories/log-chunk-repository.ts | 12 ++ 3 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 apps/server/db/repositories/drizzle/log-chunk.test.ts diff --git a/apps/server/db/repositories/drizzle/log-chunk.test.ts b/apps/server/db/repositories/drizzle/log-chunk.test.ts new file mode 100644 index 0000000..0c48d6a --- /dev/null +++ b/apps/server/db/repositories/drizzle/log-chunk.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DrizzleLogChunkRepository } from './log-chunk.js'; +import { createTestDatabase } from './test-helpers.js'; +import type { DrizzleDatabase } from '../../index.js'; + +describe('DrizzleLogChunkRepository', () => { + let db: DrizzleDatabase; + let repo: DrizzleLogChunkRepository; + const testAgentId = 'agent-test-001'; + + beforeEach(() => { + db = createTestDatabase(); + repo = new DrizzleLogChunkRepository(db); + }); + + it('AskUserQuestion chunk — questionsCount upserted correctly', async () => { + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }), + }); + const metrics = await repo.findMetricsByAgentIds([testAgentId]); + expect(metrics).toEqual([{ + agentId: testAgentId, + questionsCount: 2, + subagentsCount: 0, + compactionsCount: 0, + }]); + }); + + it('Agent tool chunk — subagentsCount incremented', async () => { + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'tool_use', name: 'Agent' }), + }); + const metrics = await repo.findMetricsByAgentIds([testAgentId]); + expect(metrics).toEqual([{ + agentId: testAgentId, + questionsCount: 0, + subagentsCount: 1, + compactionsCount: 0, + }]); + }); + + it('Compaction event — compactionsCount incremented', async () => { + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }), + }); + const metrics = await repo.findMetricsByAgentIds([testAgentId]); + expect(metrics).toEqual([{ + agentId: testAgentId, + questionsCount: 0, + subagentsCount: 0, + compactionsCount: 1, + }]); + }); + + it('Irrelevant chunk type — no metrics row created', async () => { + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'text', text: 'hello' }), + }); + const metrics = await repo.findMetricsByAgentIds([testAgentId]); + expect(metrics).toEqual([]); + }); + + it('Malformed JSON chunk — chunk persisted, metrics row absent', async () => { + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: 'not-valid-json', + }); + const chunks = await repo.findByAgentId(testAgentId); + expect(chunks).toHaveLength(1); + const metrics = await repo.findMetricsByAgentIds([testAgentId]); + expect(metrics).toEqual([]); + }); + + it('Multiple inserts, same agent — counts accumulate additively', async () => { + // 3 Agent tool chunks + for (let i = 0; i < 3; i++) { + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'tool_use', name: 'Agent' }), + }); + } + // 1 AskUserQuestion with 2 questions + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }), + }); + const metrics = await repo.findMetricsByAgentIds([testAgentId]); + expect(metrics).toEqual([{ + agentId: testAgentId, + questionsCount: 2, + subagentsCount: 3, + compactionsCount: 0, + }]); + }); + + it('findMetricsByAgentIds with empty array — returns []', async () => { + const metrics = await repo.findMetricsByAgentIds([]); + expect(metrics).toEqual([]); + }); + + it('findMetricsByAgentIds with agentId that has no metrics row — returns []', async () => { + await repo.insertChunk({ + agentId: testAgentId, + agentName: 'test-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'text', text: 'hello' }), + }); + const metrics = await repo.findMetricsByAgentIds([testAgentId]); + expect(metrics).toEqual([]); + }); +}); diff --git a/apps/server/db/repositories/drizzle/log-chunk.ts b/apps/server/db/repositories/drizzle/log-chunk.ts index 9d4632b..5d30c0b 100644 --- a/apps/server/db/repositories/drizzle/log-chunk.ts +++ b/apps/server/db/repositories/drizzle/log-chunk.ts @@ -4,10 +4,10 @@ * Implements LogChunkRepository interface using Drizzle ORM. */ -import { eq, asc, max, inArray } from 'drizzle-orm'; +import { eq, asc, max, inArray, sql } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; -import { agentLogChunks } from '../../schema.js'; +import { agentLogChunks, agentMetrics } from '../../schema.js'; import type { LogChunkRepository } from '../log-chunk-repository.js'; export class DrizzleLogChunkRepository implements LogChunkRepository { @@ -19,13 +19,58 @@ export class DrizzleLogChunkRepository implements LogChunkRepository { sessionNumber: number; content: string; }): Promise { - await this.db.insert(agentLogChunks).values({ - id: nanoid(), - agentId: data.agentId, - agentName: data.agentName, - sessionNumber: data.sessionNumber, - content: data.content, - createdAt: new Date(), + // better-sqlite3 is synchronous — transaction callback must be sync, use .run() not await + this.db.transaction((tx) => { + // 1. Always insert the chunk row first + tx.insert(agentLogChunks).values({ + id: nanoid(), + agentId: data.agentId, + agentName: data.agentName, + sessionNumber: data.sessionNumber, + content: data.content, + createdAt: new Date(), + }).run(); + + // 2. Parse content and determine metric increments + // Wrap only the parse + upsert block — chunk insert is not rolled back on parse failure + try { + const parsed = JSON.parse(data.content); + let deltaQuestions = 0; + let deltaSubagents = 0; + let deltaCompactions = 0; + + if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') { + deltaQuestions = parsed.input?.questions?.length ?? 0; + } else if (parsed.type === 'tool_use' && parsed.name === 'Agent') { + deltaSubagents = 1; + } else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') { + deltaCompactions = 1; + } + + // 3. Only upsert if there is something to increment + if (deltaQuestions > 0 || deltaSubagents > 0 || deltaCompactions > 0) { + tx.insert(agentMetrics) + .values({ + agentId: data.agentId, + questionsCount: deltaQuestions, + subagentsCount: deltaSubagents, + compactionsCount: deltaCompactions, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: agentMetrics.agentId, + set: { + questionsCount: sql`${agentMetrics.questionsCount} + ${deltaQuestions}`, + subagentsCount: sql`${agentMetrics.subagentsCount} + ${deltaSubagents}`, + compactionsCount: sql`${agentMetrics.compactionsCount} + ${deltaCompactions}`, + updatedAt: new Date(), + }, + }) + .run(); + } + } catch { + // Malformed JSON — skip metric upsert, chunk insert already committed within transaction + } }); } @@ -69,4 +114,22 @@ export class DrizzleLogChunkRepository implements LogChunkRepository { return result[0]?.maxSession ?? 0; } + + async findMetricsByAgentIds(agentIds: string[]): Promise<{ + agentId: string; + questionsCount: number; + subagentsCount: number; + compactionsCount: number; + }[]> { + if (agentIds.length === 0) return []; + return this.db + .select({ + agentId: agentMetrics.agentId, + questionsCount: agentMetrics.questionsCount, + subagentsCount: agentMetrics.subagentsCount, + compactionsCount: agentMetrics.compactionsCount, + }) + .from(agentMetrics) + .where(inArray(agentMetrics.agentId, agentIds)); + } } diff --git a/apps/server/db/repositories/log-chunk-repository.ts b/apps/server/db/repositories/log-chunk-repository.ts index 0283a0b..1e1f8a2 100644 --- a/apps/server/db/repositories/log-chunk-repository.ts +++ b/apps/server/db/repositories/log-chunk-repository.ts @@ -27,4 +27,16 @@ export interface LogChunkRepository { deleteByAgentId(agentId: string): Promise; getSessionCount(agentId: string): Promise; + + /** + * Batch-fetch pre-computed metrics for multiple agent IDs. + * Returns one row per agent that has metrics. Agents with no + * matching row in agent_metrics are omitted (not returned as zeros). + */ + findMetricsByAgentIds(agentIds: string[]): Promise<{ + agentId: string; + questionsCount: number; + subagentsCount: number; + compactionsCount: number; + }[]>; } From 4a9f38c4e1f5ad95294327382f5db488533c6131 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:35:29 +0100 Subject: [PATCH 12/18] =?UTF-8?q?perf:=20replace=20O(N=C2=B7chunks)=20list?= =?UTF-8?q?ForRadar=20read=20path=20with=20O(N=C2=B7agents)=20metrics=20lo?= =?UTF-8?q?okup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listForRadar previously called findByAgentIds() and JSON-parsed every chunk to compute questionsCount, subagentsCount, and compactionsCount. Switch to findMetricsByAgentIds() which reads the pre-computed agent_metrics table, eliminating the chunk scan and per-row JSON.parse entirely. Add two new test cases: agent with no metrics row returns zero counts, and listForRadar response rows never carry chunk content. Co-Authored-By: Claude Sonnet 4.6 --- .../server/test/unit/radar-procedures.test.ts | 41 +++++++++++++++++++ apps/server/trpc/routers/agent.ts | 37 ++++------------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/apps/server/test/unit/radar-procedures.test.ts b/apps/server/test/unit/radar-procedures.test.ts index d7acba6..10eb878 100644 --- a/apps/server/test/unit/radar-procedures.test.ts +++ b/apps/server/test/unit/radar-procedures.test.ts @@ -325,6 +325,47 @@ describe('agent.listForRadar', () => { expect(row!.subagentsCount).toBe(0); expect(row!.compactionsCount).toBe(0); }); + + it('returns zero counts for agent with no metrics row', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + + const now = new Date(); + // Agent with no log chunks at all — no agent_metrics row will exist + agents.addAgent({ id: 'agent-no-chunks', name: 'no-chunks-agent', status: 'running', createdAt: now }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all' }); + + const row = result.find(r => r.id === 'agent-no-chunks'); + expect(row).toBeDefined(); + expect(row!.questionsCount).toBe(0); + expect(row!.subagentsCount).toBe(0); + expect(row!.compactionsCount).toBe(0); + }); + + it('listForRadar response does not contain chunk content field', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + const now = new Date(); + agents.addAgent({ id: 'agent-content', name: 'content-agent', status: 'running', createdAt: now }); + + await logChunkRepo.insertChunk({ + agentId: 'agent-content', + agentName: 'content-agent', + sessionNumber: 1, + content: JSON.stringify({ type: 'tool_use', name: 'Agent', input: { description: 'do stuff', prompt: 'some prompt' } }), + }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all' }); + + for (const row of result) { + expect(row).not.toHaveProperty('content'); + } + }); }); // ============================================================================= diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 644b814..82d397d 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -475,8 +475,8 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])]; const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])]; - const [chunks, messageCounts, taskResults, initiativeResults] = await Promise.all([ - logChunkRepo.findByAgentIds(matchingIds), + const [metrics, messageCounts, taskResults, initiativeResults] = await Promise.all([ + logChunkRepo.findMetricsByAgentIds(matchingIds), conversationRepo.countByFromAgentIds(matchingIds), Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))), Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))), @@ -486,37 +486,14 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name])); const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name])); const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count])); - - // Group chunks by agentId - const chunksByAgent = new Map(); - for (const chunk of chunks) { - const existing = chunksByAgent.get(chunk.agentId); - if (existing) { - existing.push(chunk); - } else { - chunksByAgent.set(chunk.agentId, [chunk]); - } - } + const metricsMap = new Map(metrics.map(m => [m.agentId, m])); // Build result rows return filteredAgents.map(agent => { - const agentChunks = chunksByAgent.get(agent.id) ?? []; - let questionsCount = 0; - let subagentsCount = 0; - let compactionsCount = 0; - - for (const chunk of agentChunks) { - try { - const parsed = JSON.parse(chunk.content); - if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') { - questionsCount += parsed.input?.questions?.length ?? 0; - } else if (parsed.type === 'tool_use' && parsed.name === 'Agent') { - subagentsCount++; - } else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') { - compactionsCount++; - } - } catch { /* skip malformed */ } - } + const agentMetrics = metricsMap.get(agent.id); + const questionsCount = agentMetrics?.questionsCount ?? 0; + const subagentsCount = agentMetrics?.subagentsCount ?? 0; + const compactionsCount = agentMetrics?.compactionsCount ?? 0; return { id: agent.id, From 1fd3a1ae4a547bcc789bbc71460fe5c3456e86a3 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:35:30 +0100 Subject: [PATCH 13/18] fix: make errand.list input optional so frontend query works without args --- apps/server/trpc/routers/errand.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts index 39b144c..3185f75 100644 --- a/apps/server/trpc/routers/errand.ts +++ b/apps/server/trpc/routers/errand.ts @@ -184,11 +184,11 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) { .input(z.object({ projectId: z.string().optional(), status: z.enum(ErrandStatusValues).optional(), - })) + }).optional()) .query(async ({ ctx, input }) => { return requireErrandRepository(ctx).findAll({ - projectId: input.projectId, - status: input.status, + projectId: input?.projectId, + status: input?.status, }); }), From db2196f1d158f2de4e0867b5497172d4ed6d3ec2 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:36:08 +0100 Subject: [PATCH 14/18] feat: add backfill-metrics script and cw backfill-metrics CLI command Populates the agent_metrics table from existing agent_log_chunks data after the schema migration. Reads chunks in batches of 500, accumulates per-agent counts in memory, then upserts with additive ON CONFLICT DO UPDATE to match the ongoing insertChunk write-path behavior. - apps/server/scripts/backfill-metrics.ts: core backfillMetrics(db) + CLI wrapper backfillMetricsFromPath(dbPath) - apps/server/scripts/backfill-metrics.test.ts: 8 tests covering all chunk types, malformed JSON, isolation, empty DB, and re-run double-count behavior - apps/server/cli/index.ts: new top-level `cw backfill-metrics [--db ]` command - docs/database-migrations.md: Post-migration backfill scripts section documenting when and how to run the script Co-Authored-By: Claude Sonnet 4.6 --- apps/server/cli/index.ts | 18 +++ apps/server/scripts/backfill-metrics.test.ts | 131 +++++++++++++++++++ apps/server/scripts/backfill-metrics.ts | 128 ++++++++++++++++++ docs/database-migrations.md | 24 ++++ 4 files changed, 301 insertions(+) create mode 100644 apps/server/scripts/backfill-metrics.test.ts create mode 100644 apps/server/scripts/backfill-metrics.ts diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 8fc0425..2c4f658 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -13,6 +13,8 @@ import { createDefaultTrpcClient } from './trpc-client.js'; import { createContainer } from '../container.js'; import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js'; import { createModuleLogger } from '../logger/index.js'; +import { backfillMetricsFromPath } from '../scripts/backfill-metrics.js'; +import { getDbPath } from '../db/index.js'; /** Environment variable for custom port */ const CW_PORT_ENV = 'CW_PORT'; @@ -134,6 +136,22 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com } }); + // Backfill metrics command (standalone — no server, no tRPC) + program + .command('backfill-metrics') + .description('Populate agent_metrics table from existing agent_log_chunks (run once after upgrading)') + .option('--db ', 'Path to the SQLite database file (defaults to configured DB path)') + .action(async (options: { db?: string }) => { + const dbPath = options.db ?? getDbPath(); + console.log(`Backfilling metrics from ${dbPath}...`); + try { + await backfillMetricsFromPath(dbPath); + } catch (error) { + console.error('Backfill failed:', (error as Error).message); + process.exit(1); + } + }); + // Agent command group const agentCommand = program .command('agent') diff --git a/apps/server/scripts/backfill-metrics.test.ts b/apps/server/scripts/backfill-metrics.test.ts new file mode 100644 index 0000000..1dbb966 --- /dev/null +++ b/apps/server/scripts/backfill-metrics.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for the backfill-metrics script. + * + * Uses an in-memory test database to verify that backfillMetrics correctly + * accumulates counts from agent_log_chunks and upserts into agent_metrics. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; +import type { DrizzleDatabase } from '../db/index.js'; +import { agentLogChunks, agentMetrics } from '../db/index.js'; +import { backfillMetrics } from './backfill-metrics.js'; +import { nanoid } from 'nanoid'; +import { eq } from 'drizzle-orm'; + +async function insertChunk(db: DrizzleDatabase, agentId: string, content: object | string) { + await db.insert(agentLogChunks).values({ + id: nanoid(), + agentId, + agentName: 'test-agent', + sessionNumber: 1, + content: typeof content === 'string' ? content : JSON.stringify(content), + createdAt: new Date(), + }); +} + +describe('backfillMetrics', () => { + let db: DrizzleDatabase; + + beforeEach(() => { + db = createTestDatabase(); + }); + + it('AskUserQuestion chunks — questionsCount correct', async () => { + await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }); + await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}] } }); + + await backfillMetrics(db); + + const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-a')); + expect(rows).toHaveLength(1); + expect(rows[0].questionsCount).toBe(3); + expect(rows[0].subagentsCount).toBe(0); + expect(rows[0].compactionsCount).toBe(0); + }); + + it('Agent tool chunks — subagentsCount correct', async () => { + await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' }); + await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' }); + + await backfillMetrics(db); + + const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-b')); + expect(rows).toHaveLength(1); + expect(rows[0].questionsCount).toBe(0); + expect(rows[0].subagentsCount).toBe(2); + expect(rows[0].compactionsCount).toBe(0); + }); + + it('Compaction chunks — compactionsCount correct', async () => { + await insertChunk(db, 'agent-c', { type: 'system', subtype: 'init', source: 'compact' }); + + await backfillMetrics(db); + + const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-c')); + expect(rows).toHaveLength(1); + expect(rows[0].questionsCount).toBe(0); + expect(rows[0].subagentsCount).toBe(0); + expect(rows[0].compactionsCount).toBe(1); + }); + + it('Irrelevant chunk type — no metrics row created', async () => { + await insertChunk(db, 'agent-d', { type: 'text', text: 'hello' }); + + await backfillMetrics(db); + + const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-d')); + expect(rows).toEqual([]); + }); + + it('Malformed JSON chunk — skipped, no crash', async () => { + await insertChunk(db, 'agent-e', 'not-valid-json'); + await insertChunk(db, 'agent-e', { type: 'tool_use', name: 'Agent' }); + + await expect(backfillMetrics(db)).resolves.not.toThrow(); + + const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-e')); + expect(rows).toHaveLength(1); + expect(rows[0].subagentsCount).toBe(1); + }); + + it('Multiple agents — counts isolated per agent', async () => { + await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } }); + await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } }); + await insertChunk(db, 'agent-g', { type: 'tool_use', name: 'Agent' }); + + await backfillMetrics(db); + + const rowsF = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-f')); + expect(rowsF).toHaveLength(1); + expect(rowsF[0].questionsCount).toBe(6); + expect(rowsF[0].subagentsCount).toBe(0); + expect(rowsF[0].compactionsCount).toBe(0); + + const rowsG = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-g')); + expect(rowsG).toHaveLength(1); + expect(rowsG[0].questionsCount).toBe(0); + expect(rowsG[0].subagentsCount).toBe(1); + expect(rowsG[0].compactionsCount).toBe(0); + }); + + it('Empty database — completes without error', async () => { + await expect(backfillMetrics(db)).resolves.not.toThrow(); + + const rows = await db.select().from(agentMetrics); + expect(rows).toEqual([]); + }); + + it('Re-run idempotency note — second run doubles counts', async () => { + // Documented behavior: run only once against a fresh agent_metrics table + await insertChunk(db, 'agent-h', { type: 'tool_use', name: 'Agent' }); + + await backfillMetrics(db); + const rowsAfterFirst = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h')); + expect(rowsAfterFirst[0].subagentsCount).toBe(1); + + await backfillMetrics(db); + const rowsAfterSecond = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h')); + expect(rowsAfterSecond[0].subagentsCount).toBe(2); + }); +}); diff --git a/apps/server/scripts/backfill-metrics.ts b/apps/server/scripts/backfill-metrics.ts new file mode 100644 index 0000000..42dfe25 --- /dev/null +++ b/apps/server/scripts/backfill-metrics.ts @@ -0,0 +1,128 @@ +/** + * Backfill script for agent_metrics table. + * + * Reads all existing agent_log_chunks rows and populates agent_metrics with + * accumulated counts of questions, subagent spawns, and compaction events. + * + * Intended to be run once per production database after applying the migration + * that introduces the agent_metrics table. + * + * Idempotency note: Uses ON CONFLICT DO UPDATE with additive increments to match + * the ongoing insertChunk write-path behavior. Running against an empty + * agent_metrics table is fully safe. Running a second time will double-count — + * only run this script once per database, immediately after applying the migration. + */ + +import { asc, sql } from 'drizzle-orm'; +import { createDatabase, DrizzleDatabase, agentLogChunks, agentMetrics } from '../db/index.js'; + +const BATCH_SIZE = 500; +const LOG_EVERY = 1000; + +/** + * Core backfill function. Accepts a DrizzleDatabase for testability. + */ +export async function backfillMetrics(db: DrizzleDatabase): Promise { + const accumulator = new Map(); + let offset = 0; + let totalChunks = 0; + let malformedCount = 0; + + while (true) { + const batch = await db + .select({ agentId: agentLogChunks.agentId, content: agentLogChunks.content }) + .from(agentLogChunks) + .orderBy(asc(agentLogChunks.createdAt)) + .limit(BATCH_SIZE) + .offset(offset); + + if (batch.length === 0) break; + + for (const chunk of batch) { + let parsed: unknown; + try { + parsed = JSON.parse(chunk.content); + } catch { + malformedCount++; + totalChunks++; + if (totalChunks % LOG_EVERY === 0) { + console.log(`Processed ${totalChunks} chunks...`); + } + continue; + } + + if (typeof parsed !== 'object' || parsed === null) { + totalChunks++; + if (totalChunks % LOG_EVERY === 0) { + console.log(`Processed ${totalChunks} chunks...`); + } + continue; + } + + const obj = parsed as Record; + const type = obj['type']; + const name = obj['name']; + + if (type === 'tool_use' && name === 'AskUserQuestion') { + const input = obj['input'] as Record | undefined; + const questions = input?.['questions']; + const count = Array.isArray(questions) ? questions.length : 0; + if (count > 0) { + const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 }; + entry.questionsCount += count; + accumulator.set(chunk.agentId, entry); + } + } else if (type === 'tool_use' && name === 'Agent') { + const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 }; + entry.subagentsCount += 1; + accumulator.set(chunk.agentId, entry); + } else if (type === 'system' && obj['subtype'] === 'init' && obj['source'] === 'compact') { + const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 }; + entry.compactionsCount += 1; + accumulator.set(chunk.agentId, entry); + } + + totalChunks++; + if (totalChunks % LOG_EVERY === 0) { + console.log(`Processed ${totalChunks} chunks...`); + } + } + + offset += BATCH_SIZE; + } + + // Upsert accumulated counts into agent_metrics. + // Uses additive ON CONFLICT DO UPDATE to match the ongoing insertChunk behavior. + for (const [agentId, counts] of accumulator) { + await db + .insert(agentMetrics) + .values({ + agentId, + questionsCount: counts.questionsCount, + subagentsCount: counts.subagentsCount, + compactionsCount: counts.compactionsCount, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: agentMetrics.agentId, + set: { + questionsCount: sql`${agentMetrics.questionsCount} + ${counts.questionsCount}`, + subagentsCount: sql`${agentMetrics.subagentsCount} + ${counts.subagentsCount}`, + compactionsCount: sql`${agentMetrics.compactionsCount} + ${counts.compactionsCount}`, + updatedAt: new Date(), + }, + }); + } + + console.log( + `Backfill complete: ${accumulator.size} agents updated, ${totalChunks} chunks processed, ${malformedCount} malformed chunks skipped` + ); +} + +/** + * CLI wrapper — opens a database from a path, then delegates to backfillMetrics. + */ +export async function backfillMetricsFromPath(dbPath: string): Promise { + const db = createDatabase(dbPath); + await backfillMetrics(db); +} diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 603d49e..3e6d3ae 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -55,3 +55,27 @@ Migrations 0000–0007 were generated by `drizzle-kit generate`. Migrations 0008 - **Migration files are immutable.** Once committed, never edit them. Make a new migration instead. - **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. + +## Post-migration backfill scripts + +Some schema additions require a one-time data backfill because SQLite migrations cannot execute Node.js logic (e.g., JSON parsing). In these cases, the migration creates the table structure, and a separate Node.js script populates it from existing data. + +### agent_metrics backfill + +**When to run:** After deploying the migration that creates the `agent_metrics` table (introduced in the Radar Screen Performance initiative). Run this once per production database after upgrading. + +**Command:** +```sh +cw backfill-metrics +# Or with a custom DB path: +cw backfill-metrics --db /path/to/codewalkers.db +``` + +**What it does:** +- Reads all existing `agent_log_chunks` rows in batches of 500 (ordered by `createdAt ASC`) +- Parses each chunk's `content` JSON to count `AskUserQuestion` tool calls, `Agent` spawns, and compaction events +- Upserts the accumulated counts into `agent_metrics` using additive conflict resolution + +**Idempotency:** The script uses `ON CONFLICT DO UPDATE` with additive increments, matching the ongoing write-path behavior. Running it against an empty `agent_metrics` table is fully safe. Running it a second time will double-count — only run it once per database, immediately after applying the migration. + +**Batch size:** 500 rows per query, to avoid loading the full `agent_log_chunks` table into memory. Progress is logged every 1,000 chunks. From ee8c7097dba955e077644c25b988814911ceeced Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:42:26 +0100 Subject: [PATCH 15/18] fix: use String() instead of .toISOString() for errand timestamps tRPC without superjson serializes Date objects as plain strings/numbers over the wire. The .toISOString() calls crashed because the values aren't Date instances on the client. Matches the existing pattern used elsewhere (e.g. agents page). --- apps/web/src/components/ErrandDetailPanel.tsx | 4 ++-- apps/web/src/routes/errands/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ErrandDetailPanel.tsx b/apps/web/src/components/ErrandDetailPanel.tsx index 04182a8..fe74258 100644 --- a/apps/web/src/components/ErrandDetailPanel.tsx +++ b/apps/web/src/components/ErrandDetailPanel.tsx @@ -331,8 +331,8 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) {/* Info line */}
{errand.status === 'merged' - ? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}` - : `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`} + ? `Merged into ${errand.baseBranch} · ${formatRelativeTime(String(errand.updatedAt))}` + : `Abandoned · ${formatRelativeTime(String(errand.updatedAt))}`}
{/* Read-only diff */} diff --git a/apps/web/src/routes/errands/index.tsx b/apps/web/src/routes/errands/index.tsx index ef83b5e..9a7df0a 100644 --- a/apps/web/src/routes/errands/index.tsx +++ b/apps/web/src/routes/errands/index.tsx @@ -103,7 +103,7 @@ function ErrandsPage() { {e.agentAlias ?? '—'} - {formatRelativeTime(e.createdAt.toISOString())} + {formatRelativeTime(String(e.createdAt))} ))} From 346d62ef8daa9aa695e347d495a24e533af44ad5 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:44:26 +0100 Subject: [PATCH 16/18] fix: prevent stale duplicate planning tasks from blocking phase completion Three fixes for phases getting stuck when a detail task crashes and is retried: 1. detailPhase mutation (architect.ts): clean up orphaned pending/in_progress detail tasks before creating new ones, preventing duplicates at the source 2. orchestrator recovery: detect and complete stale duplicate planning tasks (same category+phase, one completed, one pending) 3. ensureBranch: catch "already exists" TOCTOU race instead of blocking phase --- apps/server/execution/orchestrator.ts | 19 ++++++++++++++++++- apps/server/git/simple-git-branch-manager.ts | 13 +++++++++++-- apps/server/trpc/routers/architect.ts | 8 ++++++++ docs/dispatch-events.md | 3 +++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 138aaa3..e87e9d4 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -20,7 +20,7 @@ import type { ProjectRepository } from '../db/repositories/project-repository.js import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js'; -import { phaseBranchName, taskBranchName } from '../git/branch-naming.js'; +import { phaseBranchName, taskBranchName, isPlanningCategory } from '../git/branch-naming.js'; import { ensureProjectClone } from '../git/project-clones.js'; import { createModuleLogger } from '../logger/index.js'; import { phaseMetaCache, fileDiffCache } from '../review/diff-cache.js'; @@ -637,6 +637,23 @@ export class ExecutionOrchestrator { } } + // Clean up stale duplicate planning tasks (e.g. a crashed detail task + // that was reset to pending, then a new detail task was created and completed). + const tasksAfterRecovery = await this.taskRepository.findByPhaseId(phase.id); + const completedPlanningNames = new Set(); + for (const t of tasksAfterRecovery) { + if (isPlanningCategory(t.category) && t.status === 'completed') { + completedPlanningNames.add(`${t.category}:${t.phaseId}`); + } + } + for (const t of tasksAfterRecovery) { + if (isPlanningCategory(t.category) && t.status === 'pending' && completedPlanningNames.has(`${t.category}:${t.phaseId}`)) { + await this.taskRepository.update(t.id, { status: 'completed', summary: 'Superseded by retry' }); + tasksRecovered++; + log.info({ taskId: t.id, category: t.category }, 'recovered stale duplicate planning task'); + } + } + // Re-read tasks after recovery updates and check if phase is now fully done const updatedTasks = await this.taskRepository.findByPhaseId(phase.id); const allDone = updatedTasks.every((t) => t.status === 'completed'); diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index bfe969d..841b18c 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -46,8 +46,17 @@ export class SimpleGitBranchManager implements BranchManager { return; } - await git.branch([branch, baseBranch]); - log.info({ repoPath, branch, baseBranch }, 'branch created'); + try { + await git.branch([branch, baseBranch]); + log.info({ repoPath, branch, baseBranch }, 'branch created'); + } catch (err) { + // Handle TOCTOU race: branch may have been created between the exists check and now + if (err instanceof Error && err.message.includes('already exists')) { + log.debug({ repoPath, branch }, 'branch created by concurrent process'); + return; + } + throw err; + } } async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise { diff --git a/apps/server/trpc/routers/architect.ts b/apps/server/trpc/routers/architect.ts index 24bc3e9..b1f0354 100644 --- a/apps/server/trpc/routers/architect.ts +++ b/apps/server/trpc/routers/architect.ts @@ -337,6 +337,14 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) { }); } + // Clean up orphaned pending/in_progress detail tasks from previous failed attempts + const phaseTasks = await taskRepo.findByPhaseId(input.phaseId); + for (const t of phaseTasks) { + if (t.category === 'detail' && (t.status === 'pending' || t.status === 'in_progress')) { + await taskRepo.update(t.id, { status: 'completed', summary: 'Superseded by retry' }); + } + } + const detailTaskName = input.taskName ?? `Detail: ${phase.name}`; const task = await taskRepo.create({ phaseId: phase.id, diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 9fe59fe..3ce7d08 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -149,8 +149,11 @@ When an agent crashes (`agent:crashed` event), the orchestrator automatically re On server restart, `recoverDispatchQueues()` also recovers: - Stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`) — reset to `pending` and re-queued - Erroneously `blocked` tasks whose agents completed successfully (status is `idle` or `stopped`) — marked `completed` so the phase can progress. This handles the legacy case where conflict resolution incorrectly blocked already-completed tasks. +- Stale duplicate planning tasks — if a phase has both a completed and a pending task of the same planning category (e.g. two `detail` tasks from a crash-and-retry), the pending one is marked `completed` with summary "Superseded by retry" - Fully-completed `in_progress` phases — after task recovery, if all tasks in an `in_progress` phase are completed, triggers `handlePhaseAllTasksDone` to complete/review the phase +The `detailPhase` mutation in `architect.ts` also cleans up orphaned pending/in_progress detail tasks before creating new ones, preventing duplicates at the source. + Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries. ### Coalesced Scheduling From 9c468f17cb1d9a0c548409b1b6ad06152a2e1ba1 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:44:55 +0100 Subject: [PATCH 17/18] chore: update generated route tree --- apps/web/src/routeTree.gen.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 411612e..1c12f5f 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as AgentsRouteImport } from './routes/agents' import { Route as IndexRouteImport } from './routes/index' import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index' +import { Route as ErrandsIndexRouteImport } from './routes/errands/index' import { Route as SettingsProjectsRouteImport } from './routes/settings/projects' import { Route as SettingsHealthRouteImport } from './routes/settings/health' import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id' @@ -61,6 +62,11 @@ const InitiativesIndexRoute = InitiativesIndexRouteImport.update({ path: '/initiatives/', getParentRoute: () => rootRouteImport, } as any) +const ErrandsIndexRoute = ErrandsIndexRouteImport.update({ + id: '/errands/', + path: '/errands/', + getParentRoute: () => rootRouteImport, +} as any) const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ id: '/projects', path: '/projects', @@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { '/initiatives/$id': typeof InitiativesIdRoute '/settings/health': typeof SettingsHealthRoute '/settings/projects': typeof SettingsProjectsRoute + '/errands/': typeof ErrandsIndexRoute '/initiatives/': typeof InitiativesIndexRoute '/settings/': typeof SettingsIndexRoute } @@ -99,6 +106,7 @@ export interface FileRoutesByTo { '/initiatives/$id': typeof InitiativesIdRoute '/settings/health': typeof SettingsHealthRoute '/settings/projects': typeof SettingsProjectsRoute + '/errands': typeof ErrandsIndexRoute '/initiatives': typeof InitiativesIndexRoute '/settings': typeof SettingsIndexRoute } @@ -113,6 +121,7 @@ export interface FileRoutesById { '/initiatives/$id': typeof InitiativesIdRoute '/settings/health': typeof SettingsHealthRoute '/settings/projects': typeof SettingsProjectsRoute + '/errands/': typeof ErrandsIndexRoute '/initiatives/': typeof InitiativesIndexRoute '/settings/': typeof SettingsIndexRoute } @@ -128,6 +137,7 @@ export interface FileRouteTypes { | '/initiatives/$id' | '/settings/health' | '/settings/projects' + | '/errands/' | '/initiatives/' | '/settings/' fileRoutesByTo: FileRoutesByTo @@ -140,6 +150,7 @@ export interface FileRouteTypes { | '/initiatives/$id' | '/settings/health' | '/settings/projects' + | '/errands' | '/initiatives' | '/settings' id: @@ -153,6 +164,7 @@ export interface FileRouteTypes { | '/initiatives/$id' | '/settings/health' | '/settings/projects' + | '/errands/' | '/initiatives/' | '/settings/' fileRoutesById: FileRoutesById @@ -165,6 +177,7 @@ export interface RootRouteChildren { RadarRoute: typeof RadarRoute SettingsRoute: typeof SettingsRouteWithChildren InitiativesIdRoute: typeof InitiativesIdRoute + ErrandsIndexRoute: typeof ErrandsIndexRoute InitiativesIndexRoute: typeof InitiativesIndexRoute } @@ -226,6 +239,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InitiativesIndexRouteImport parentRoute: typeof rootRouteImport } + '/errands/': { + id: '/errands/' + path: '/errands' + fullPath: '/errands/' + preLoaderRoute: typeof ErrandsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/settings/projects': { id: '/settings/projects' path: '/projects' @@ -274,6 +294,7 @@ const rootRouteChildren: RootRouteChildren = { RadarRoute: RadarRoute, SettingsRoute: SettingsRouteWithChildren, InitiativesIdRoute: InitiativesIdRoute, + ErrandsIndexRoute: ErrandsIndexRoute, InitiativesIndexRoute: InitiativesIndexRoute, } export const routeTree = rootRouteImport From e8d332e04bfc06bccc29b4ef452ebdad969d94ff Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 21:45:26 +0100 Subject: [PATCH 18/18] feat: embed InboxList + InboxDetailPanel inline on HQ page Replaces HQWaitingForInputSection with a two-column inline panel that lets users answer agent questions directly from HQ without navigating to /inbox. Adds SSE invalidation for listWaitingAgents/listMessages, useState for agent selection, and all required mutations. Co-Authored-By: Claude Sonnet 4.6 --- .../hq/HQWaitingForInputSection.tsx | 65 ------- apps/web/src/routes/hq.test.tsx | 112 ++++++++++- apps/web/src/routes/hq.tsx | 176 +++++++++++++++++- 3 files changed, 276 insertions(+), 77 deletions(-) delete mode 100644 apps/web/src/components/hq/HQWaitingForInputSection.tsx diff --git a/apps/web/src/components/hq/HQWaitingForInputSection.tsx b/apps/web/src/components/hq/HQWaitingForInputSection.tsx deleted file mode 100644 index 6a23e33..0000000 --- a/apps/web/src/components/hq/HQWaitingForInputSection.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useNavigate } from '@tanstack/react-router' -import { Card } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { - Tooltip, - TooltipTrigger, - TooltipContent, - TooltipProvider, -} from '@/components/ui/tooltip' -import { formatRelativeTime } from '@/lib/utils' -import type { WaitingForInputItem } from './types' - -interface Props { - items: WaitingForInputItem[] -} - -export function HQWaitingForInputSection({ items }: Props) { - const navigate = useNavigate() - - return ( -
-

- Waiting for Input -

-
- {items.map((item) => { - const truncated = - item.questionText.slice(0, 120) + - (item.questionText.length > 120 ? '…' : '') - - return ( - -
-

- {item.agentName} - {item.initiativeName && ( - · {item.initiativeName} - )} -

- - - -

{truncated}

-
- {item.questionText} -
-
-

- waiting {formatRelativeTime(item.waitingSince)} -

-
- -
- ) - })} -
-
- ) -} diff --git a/apps/web/src/routes/hq.test.tsx b/apps/web/src/routes/hq.test.tsx index 066cd0d..c9f3ae2 100644 --- a/apps/web/src/routes/hq.test.tsx +++ b/apps/web/src/routes/hq.test.tsx @@ -4,9 +4,24 @@ import { render, screen, fireEvent } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' const mockUseQuery = vi.hoisted(() => vi.fn()) +const mockListWaitingAgentsQuery = vi.hoisted(() => vi.fn()) +const mockListMessagesQuery = vi.hoisted(() => vi.fn()) +const mockGetAgentQuestionsQuery = vi.hoisted(() => vi.fn()) +const mockResumeAgentMutation = vi.hoisted(() => vi.fn()) +const mockStopAgentMutation = vi.hoisted(() => vi.fn()) +const mockRespondToMessageMutation = vi.hoisted(() => vi.fn()) +const mockUseUtils = vi.hoisted(() => vi.fn()) + vi.mock('@/lib/trpc', () => ({ trpc: { getHeadquartersDashboard: { useQuery: mockUseQuery }, + listWaitingAgents: { useQuery: mockListWaitingAgentsQuery }, + listMessages: { useQuery: mockListMessagesQuery }, + getAgentQuestions: { useQuery: mockGetAgentQuestionsQuery }, + resumeAgent: { useMutation: mockResumeAgentMutation }, + stopAgent: { useMutation: mockStopAgentMutation }, + respondToMessage: { useMutation: mockRespondToMessageMutation }, + useUtils: mockUseUtils, }, })) @@ -15,8 +30,33 @@ vi.mock('@/hooks', () => ({ LiveUpdateRule: undefined, })) -vi.mock('@/components/hq/HQWaitingForInputSection', () => ({ - HQWaitingForInputSection: ({ items }: any) =>
{items.length}
, +vi.mock('@/components/InboxList', () => ({ + InboxList: ({ agents, selectedAgentId, onSelectAgent }: any) => ( +
+ {agents.map((a: any) => ( + + ))} +
+ ), +})) + +vi.mock('@/components/InboxDetailPanel', () => ({ + InboxDetailPanel: ({ agent, onSubmitAnswers, onDismissQuestions, onDismissMessage, onBack }: any) => ( +
+ {agent.name} + + + + +
+ ), })) vi.mock('@/components/hq/HQNeedsReviewSection', () => ({ @@ -56,6 +96,16 @@ const emptyData = { describe('HeadquartersPage', () => { beforeEach(() => { vi.clearAllMocks() + mockListWaitingAgentsQuery.mockReturnValue({ data: [], isLoading: false }) + mockListMessagesQuery.mockReturnValue({ data: [], isLoading: false }) + mockGetAgentQuestionsQuery.mockReturnValue({ data: null, isLoading: false, isError: false }) + mockResumeAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false }) + mockStopAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false }) + mockRespondToMessageMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false }) + mockUseUtils.mockReturnValue({ + listWaitingAgents: { invalidate: vi.fn() }, + listMessages: { invalidate: vi.fn() }, + }) }) it('renders skeleton loading state', () => { @@ -68,7 +118,7 @@ describe('HeadquartersPage', () => { const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]') expect(skeletons.length).toBeGreaterThan(0) // No section components - expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() @@ -93,24 +143,25 @@ describe('HeadquartersPage', () => { render() expect(screen.getByTestId('empty-state')).toBeInTheDocument() - expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() }) - it('renders WaitingForInput section when items exist', () => { + it('renders InboxList when waitingForInput items exist', () => { mockUseQuery.mockReturnValue({ isLoading: false, isError: false, data: { ...emptyData, waitingForInput: [{ id: '1' }] }, }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) render() - expect(screen.getByTestId('waiting')).toBeInTheDocument() - expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() - expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() - expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + expect(screen.getByTestId('inbox-list')).toBeInTheDocument() expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() }) @@ -127,9 +178,13 @@ describe('HeadquartersPage', () => { blockedPhases: [{ id: '6' }], }, }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) render() - expect(screen.getByTestId('waiting')).toBeInTheDocument() + expect(screen.getByTestId('inbox-list')).toBeInTheDocument() expect(screen.getByTestId('needs-review')).toBeInTheDocument() expect(screen.getByTestId('needs-approval')).toBeInTheDocument() expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument() @@ -160,4 +215,41 @@ describe('HeadquartersPage', () => { expect(screen.getByTestId('needs-review')).toBeInTheDocument() expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() }) + + it('shows InboxDetailPanel when an agent is selected from InboxList', async () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, waitingForInput: [{ id: '1' }] }, + }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) + render() + + fireEvent.click(screen.getByTestId('agent-a1')) + + expect(await screen.findByTestId('inbox-detail')).toBeInTheDocument() + expect(screen.getByTestId('selected-agent-name')).toHaveTextContent('Agent 1') + }) + + it('clears selection when Back is clicked in InboxDetailPanel', async () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, waitingForInput: [{ id: '1' }] }, + }) + mockListWaitingAgentsQuery.mockReturnValue({ + data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }], + isLoading: false, + }) + render() + + fireEvent.click(screen.getByTestId('agent-a1')) + expect(screen.getByTestId('inbox-detail')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /back/i })) + expect(screen.queryByTestId('inbox-detail')).not.toBeInTheDocument() + }) }) diff --git a/apps/web/src/routes/hq.tsx b/apps/web/src/routes/hq.tsx index d1f881e..368e6bf 100644 --- a/apps/web/src/routes/hq.tsx +++ b/apps/web/src/routes/hq.tsx @@ -1,10 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; import { motion } from "motion/react"; +import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { useLiveUpdates, type LiveUpdateRule } from "@/hooks"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; -import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection"; +import { InboxList } from "@/components/InboxList"; +import { InboxDetailPanel } from "@/components/InboxDetailPanel"; import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection"; import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection"; import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection"; @@ -19,12 +22,108 @@ const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [ { prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] }, { prefix: "phase:", invalidate: ["getHeadquartersDashboard"] }, { prefix: "agent:", invalidate: ["getHeadquartersDashboard"] }, + { prefix: "agent:", invalidate: ["listWaitingAgents", "listMessages"] }, ]; export function HeadquartersPage() { useLiveUpdates(HQ_LIVE_UPDATE_RULES); const query = trpc.getHeadquartersDashboard.useQuery(); + const [selectedAgentId, setSelectedAgentId] = useState(null); + + const utils = trpc.useUtils(); + const agentsQuery = trpc.listWaitingAgents.useQuery(); + const messagesQuery = trpc.listMessages.useQuery({}); + const questionsQuery = trpc.getAgentQuestions.useQuery( + { id: selectedAgentId! }, + { enabled: !!selectedAgentId } + ); + + const resumeAgentMutation = trpc.resumeAgent.useMutation({ + onSuccess: () => { + setSelectedAgentId(null); + toast.success("Answer submitted"); + }, + onError: () => { + toast.error("Failed to submit answer"); + }, + }); + + const dismissQuestionsMutation = trpc.stopAgent.useMutation({ + onSuccess: () => { + setSelectedAgentId(null); + toast.success("Questions dismissed"); + }, + onError: () => { + toast.error("Failed to dismiss questions"); + }, + }); + + const respondToMessageMutation = trpc.respondToMessage.useMutation({ + onSuccess: () => { + setSelectedAgentId(null); + toast.success("Response sent"); + }, + onError: () => { + toast.error("Failed to send response"); + }, + }); + + const agents = agentsQuery.data ?? []; + const messages = messagesQuery.data ?? []; + const selectedAgent = selectedAgentId + ? agents.find((a) => a.id === selectedAgentId) ?? null + : null; + const selectedMessage = selectedAgentId + ? messages + .filter((m) => m.senderId === selectedAgentId) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + )[0] ?? null + : null; + const pendingQuestions = questionsQuery.data ?? null; + + function handleRefresh() { + void utils.listWaitingAgents.invalidate(); + void utils.listMessages.invalidate(); + } + + function handleSubmitAnswers(answers: Record) { + if (!selectedAgentId) return; + resumeAgentMutation.mutate({ id: selectedAgentId, answers }); + } + + function handleDismissQuestions() { + if (!selectedAgentId) return; + dismissQuestionsMutation.mutate({ id: selectedAgentId }); + } + + function handleDismiss() { + if (!selectedMessage) return; + respondToMessageMutation.mutate({ + id: selectedMessage.id, + response: "Acknowledged", + }); + } + + const serializedAgents = agents.map((a) => ({ + id: a.id, + name: a.name, + status: a.status, + taskId: a.taskId ?? "", + updatedAt: String(a.updatedAt), + })); + + const serializedMessages = messages.map((m) => ({ + id: m.id, + senderId: m.senderId, + content: m.content, + requiresResponse: m.requiresResponse, + status: m.status, + createdAt: String(m.createdAt), + })); + if (query.isLoading) { return ( {data.waitingForInput.length > 0 && ( - +
+

+ Waiting for Input +

+
+ {/* Left: agent list — hidden on mobile when an agent is selected */} +
+ +
+ {/* Right: detail panel */} + {selectedAgent ? ( + ({ + id: q.id, + question: q.question, + options: q.options, + multiSelect: q.multiSelect, + })) + : null + } + isLoadingQuestions={questionsQuery.isLoading} + questionsError={ + questionsQuery.isError ? questionsQuery.error.message : null + } + onBack={() => setSelectedAgentId(null)} + onSubmitAnswers={handleSubmitAnswers} + onDismissQuestions={handleDismissQuestions} + onDismissMessage={handleDismiss} + isSubmitting={resumeAgentMutation.isPending} + isDismissingQuestions={dismissQuestionsMutation.isPending} + isDismissingMessage={respondToMessageMutation.isPending} + submitError={ + resumeAgentMutation.isError + ? resumeAgentMutation.error.message + : null + } + dismissMessageError={ + respondToMessageMutation.isError + ? respondToMessageMutation.error.message + : null + } + /> + ) : ( +
+

No agent selected

+

Select an agent from the list to answer their questions

+
+ )} +
+
)} {(data.pendingReviewInitiatives.length > 0 || data.pendingReviewPhases.length > 0) && (