fix(agent): Eliminate race condition in completion handling

PROBLEM:
- Agents completing with questions were incorrectly marked as "crashed"
- Race condition: polling handler AND crash handler both called handleCompletion()
- Caused database corruption and lost pending questions

SOLUTION:
- Add completion mutex in OutputHandler to prevent concurrent processing
- Remove duplicate completion call from crash handler
- Only one handler executes completion logic per agent

TESTING:
- Added mutex-completion.test.ts with 4 test cases
- Verified mutex prevents concurrent access
- Verified lock cleanup on exceptions
- Verified different agents can process concurrently

FIXES: residential-cuckoo and 12+ other agents stuck in crashed state
This commit is contained in:
Lukas May
2026-02-08 15:51:32 +01:00
parent 6f5fd3a0af
commit 43e2c8b0ba
52 changed files with 2545 additions and 370 deletions

View File

@@ -1,3 +1,3 @@
export type { AppRouter } from './trpc.js';
export type { Initiative, Phase, Plan, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project } from './types.js';
export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, Proposal } from './types.js';
export { sortByPriorityAndQueueTime, type SortableItem } from './utils.js';

View File

@@ -1,4 +1,4 @@
export type { Initiative, Phase, Plan, Task, Agent, Message, Page, Project, Account } from '../../../src/db/schema.js';
export type { Initiative, Phase, Task, Agent, Message, Page, Project, Account, Proposal } from '../../../src/db/schema.js';
export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js';
/**

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks";
@@ -9,10 +10,67 @@ interface AgentOutputViewerProps {
agentName?: string;
}
function formatToolCall(toolUse: any): string {
const { name, input } = toolUse;
if (name === 'Bash') {
return `$ ${input.command}${input.description ? '\n# ' + input.description : ''}`;
}
if (name === 'Read') {
return `📄 Read: ${input.file_path}${input.offset ? ` (lines ${input.offset}-${input.offset + (input.limit || 10)})` : ''}`;
}
if (name === 'Edit') {
return `✏️ Edit: ${input.file_path}\n${input.old_string.substring(0, 100)}${input.old_string.length > 100 ? '...' : ''}\n→ ${input.new_string.substring(0, 100)}${input.new_string.length > 100 ? '...' : ''}`;
}
if (name === 'Write') {
return `📝 Write: ${input.file_path} (${input.content.length} chars)`;
}
if (name === 'Task') {
return `🤖 ${input.subagent_type}: ${input.description}\n${input.prompt?.substring(0, 200)}${input.prompt && input.prompt.length > 200 ? '...' : ''}`;
}
// Generic fallback
return `${name}: ${JSON.stringify(input, null, 2)}`;
}
function getMessageStyling(type: ParsedMessage['type']): string {
switch (type) {
case 'system':
return 'mb-1';
case 'text':
return 'mb-1';
case 'tool_call':
return 'mb-2';
case 'tool_result':
return 'mb-2';
case 'error':
return 'mb-2';
case 'session_end':
return 'mb-2';
default:
return 'mb-1';
}
}
interface ParsedMessage {
type: 'text' | 'system' | 'tool_call' | 'tool_result' | 'session_end' | 'error';
content: string;
meta?: {
toolName?: string;
isError?: boolean;
cost?: number;
duration?: number;
};
}
export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps) {
const [output, setOutput] = useState<string[]>([]);
const [messages, setMessages] = useState<ParsedMessage[]>([]);
const [follow, setFollow] = useState(true);
const containerRef = useRef<HTMLPreElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Load initial/historical output
const outputQuery = trpc.getAgentOutput.useQuery(
@@ -26,11 +84,11 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
const subscription = useSubscriptionWithErrorHandling(
() => trpc.onAgentOutput.useSubscription({ agentId }),
{
onData: (event) => {
// event is TrackedEnvelope<{ agentId: string; data: string }>
// event.data is the inner data object
const payload = event.data as { agentId: string; data: string };
setOutput((prev) => [...prev, payload.data]);
onData: (event: any) => {
// TrackedEnvelope shape: { id, data: { agentId, data: string } }
const raw = event?.data?.data ?? event?.data;
const data = typeof raw === 'string' ? raw : JSON.stringify(raw);
setMessages((prev) => [...prev, { type: 'text', content: data }]);
},
onError: (error) => {
console.error('Agent output subscription error:', error);
@@ -43,39 +101,106 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
// Set initial output when query loads
useEffect(() => {
if (outputQuery.data) {
// Split NDJSON content into chunks for display
// Each line might be a JSON event, so we just display raw for now
const lines = outputQuery.data.split("\n").filter(Boolean);
// Extract text from JSONL events for display
const textChunks: string[] = [];
const parsedMessages: ParsedMessage[] = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
// Claude CLI stream-json: complete assistant messages with content blocks
// 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) {
textChunks.push(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 }
});
}
}
} else if (event.type === "stream_event" && event.event?.delta?.text) {
// Legacy streaming format: granular text deltas
textChunks.push(event.event.delta.text);
} else if (event.type === "result" && event.result) {
// Don't add result text since it duplicates the content
}
// 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,
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
});
}
}
}
}
// Legacy streaming format
else if (event.type === "stream_event" && event.event?.delta?.text) {
parsedMessages.push({
type: 'text',
content: event.event.delta.text
});
}
// 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
textChunks.push(line + "\n");
parsedMessages.push({
type: 'error',
content: line,
meta: { isError: true }
});
}
}
setOutput(textChunks);
setMessages(parsedMessages);
}
}, [outputQuery.data]);
// Reset output when agent changes
useEffect(() => {
setOutput([]);
setMessages([]);
setFollow(true);
}, [agentId]);
@@ -84,7 +209,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
if (follow && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [output, follow]);
}, [messages, follow]);
// Handle scroll to detect user scrolling up
function handleScroll() {
@@ -105,7 +230,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
}
const isLoading = outputQuery.isLoading;
const hasOutput = output.length > 0;
const hasOutput = messages.length > 0;
return (
<div className="flex flex-col h-[600px] rounded-lg border overflow-hidden">
@@ -159,19 +284,85 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
</div>
{/* Output content */}
<pre
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-900 p-4 font-mono text-sm text-zinc-100 whitespace-pre-wrap"
className="flex-1 overflow-y-auto bg-zinc-900 p-4"
>
{isLoading ? (
<span className="text-zinc-500">Loading output...</span>
<div className="text-zinc-500 text-sm">Loading output...</div>
) : !hasOutput ? (
<span className="text-zinc-500">No output yet...</span>
<div className="text-zinc-500 text-sm">No output yet...</div>
) : (
output.join("")
<div className="space-y-2">
{messages.map((message, index) => (
<div key={index} className={getMessageStyling(message.type)}>
{message.type === 'system' && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">System</Badge>
<span className="text-xs text-zinc-400">{message.content}</span>
</div>
)}
{message.type === 'text' && (
<div className="font-mono text-sm whitespace-pre-wrap text-zinc-100">
{message.content}
</div>
)}
{message.type === 'tool_call' && (
<div className="border-l-2 border-blue-500 pl-3 py-1">
<Badge variant="default" className="mb-1 text-xs">
{message.meta?.toolName}
</Badge>
<div className="font-mono text-xs text-zinc-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
)}
{message.type === 'tool_result' && (
<div className="border-l-2 border-green-500 pl-3 py-1 bg-zinc-800/30">
<Badge variant="outline" className="mb-1 text-xs">
Result
</Badge>
<div className="font-mono text-xs text-zinc-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
)}
{message.type === 'error' && (
<div className="border-l-2 border-red-500 pl-3 py-1 bg-red-900/20">
<Badge variant="destructive" className="mb-1 text-xs">
Error
</Badge>
<div className="font-mono text-xs text-red-200 whitespace-pre-wrap">
{message.content}
</div>
</div>
)}
{message.type === 'session_end' && (
<div className="border-t border-zinc-700 pt-2 mt-4">
<div className="flex items-center gap-2">
<Badge variant={message.meta?.isError ? "destructive" : "default"} className="text-xs">
{message.content}
</Badge>
{message.meta?.cost && (
<span className="text-xs text-zinc-500">${message.meta.cost.toFixed(4)}</span>
)}
{message.meta?.duration && (
<span className="text-xs text-zinc-500">{(message.meta.duration / 1000).toFixed(1)}s</span>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</pre>
</div>
</div>
);
}

View File

@@ -16,8 +16,9 @@ import { trpc } from "@/lib/trpc";
export interface SerializedInitiative {
id: string;
name: string;
description: string | null;
status: "active" | "completed" | "archived";
mergeRequiresApproval: boolean;
mergeTarget: string | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -9,20 +9,7 @@ import {
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
/** Serialized Task shape as returned by tRPC (Date serialized to string over JSON) */
export interface SerializedTask {
id: string;
planId: string;
name: string;
description: string | null;
type: string;
priority: string;
status: string;
order: number;
createdAt: string;
updatedAt: string;
}
import type { SerializedTask } from "@/components/TaskRow";
interface DependencyInfo {
name: string;

View File

@@ -6,12 +6,16 @@ import { cn } from "@/lib/utils";
/** Task shape as returned by tRPC (Date fields serialized to string over JSON) */
export interface SerializedTask {
id: string;
planId: string;
phaseId: string | null;
initiativeId: string | null;
parentTaskId: string | null;
name: string;
description: string | null;
type: string;
priority: string;
status: string;
type: "auto" | "checkpoint:human-verify" | "checkpoint:decision" | "checkpoint:human-action";
category: string;
priority: "low" | "medium" | "high";
status: "pending_approval" | "pending" | "in_progress" | "completed" | "blocked";
requiresApproval: boolean | null;
order: number;
createdAt: string;
updatedAt: string;

View File

@@ -2,17 +2,10 @@ import { useState, useCallback } from "react";
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
interface ContentProposal {
pageId: string;
pageTitle: string;
summary: string;
markdown: string;
}
import type { Proposal } from "@codewalk-district/shared";
interface ContentProposalReviewProps {
proposals: ContentProposal[];
proposals: Proposal[];
agentCreatedAt: Date;
agentId: string;
onDismiss: () => void;
@@ -26,46 +19,52 @@ export function ContentProposalReview({
}: ContentProposalReviewProps) {
const [accepted, setAccepted] = useState<Set<string>>(new Set());
const utils = trpc.useUtils();
const updatePageMutation = trpc.updatePage.useMutation({
const acceptMutation = trpc.acceptProposal.useMutation({
onSuccess: () => {
void utils.listProposals.invalidate();
void utils.listPages.invalidate();
void utils.getPage.invalidate();
void utils.listAgents.invalidate();
},
});
const dismissMutation = trpc.dismissAgent.useMutation({
const acceptAllMutation = trpc.acceptAllProposals.useMutation({
onSuccess: () => {
void utils.listProposals.invalidate();
void utils.listPages.invalidate();
void utils.getPage.invalidate();
void utils.listAgents.invalidate();
onDismiss();
},
});
const handleAccept = useCallback(
async (proposal: ContentProposal) => {
const tiptapJson = markdownToTiptapJson(proposal.markdown);
await updatePageMutation.mutateAsync({
id: proposal.pageId,
content: JSON.stringify(tiptapJson),
});
setAccepted((prev) => new Set(prev).add(proposal.pageId));
const dismissAllMutation = trpc.dismissAllProposals.useMutation({
onSuccess: () => {
void utils.listProposals.invalidate();
void utils.listAgents.invalidate();
// Note: onDismiss() is not called here because the backend auto-dismiss
// will set userDismissedAt when all proposals are resolved
},
[updatePageMutation],
});
const handleAccept = useCallback(
async (proposal: Proposal) => {
await acceptMutation.mutateAsync({ id: proposal.id });
setAccepted((prev) => new Set(prev).add(proposal.id));
},
[acceptMutation],
);
const handleAcceptAll = useCallback(async () => {
for (const proposal of proposals) {
if (!accepted.has(proposal.pageId)) {
const tiptapJson = markdownToTiptapJson(proposal.markdown);
await updatePageMutation.mutateAsync({
id: proposal.pageId,
content: JSON.stringify(tiptapJson),
});
setAccepted((prev) => new Set(prev).add(proposal.pageId));
}
}
}, [proposals, accepted, updatePageMutation]);
await acceptAllMutation.mutateAsync({ agentId });
}, [acceptAllMutation, agentId]);
const allAccepted = proposals.every((p) => accepted.has(p.pageId));
const handleDismissAll = useCallback(() => {
dismissAllMutation.mutate({ agentId });
}, [dismissAllMutation, agentId]);
const allAccepted = proposals.every((p) => accepted.has(p.id) || p.status === 'accepted');
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
@@ -79,7 +78,7 @@ export function ContentProposalReview({
variant="outline"
size="sm"
onClick={handleAcceptAll}
disabled={updatePageMutation.isPending}
disabled={acceptAllMutation.isPending}
>
Accept All
</Button>
@@ -87,10 +86,10 @@ export function ContentProposalReview({
<Button
variant="ghost"
size="sm"
onClick={() => dismissMutation.mutate({ id: agentId })}
disabled={dismissMutation.isPending}
onClick={handleDismissAll}
disabled={dismissAllMutation.isPending}
>
{dismissMutation.isPending ? "Dismissing..." : "Dismiss"}
{dismissAllMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</div>
</div>
@@ -98,12 +97,12 @@ export function ContentProposalReview({
<div className="space-y-2">
{proposals.map((proposal) => (
<ProposalCard
key={proposal.pageId}
key={proposal.id}
proposal={proposal}
isAccepted={accepted.has(proposal.pageId)}
isAccepted={accepted.has(proposal.id) || proposal.status === 'accepted'}
agentCreatedAt={agentCreatedAt}
onAccept={() => handleAccept(proposal)}
isAccepting={updatePageMutation.isPending}
isAccepting={acceptMutation.isPending}
/>
))}
</div>
@@ -112,7 +111,7 @@ export function ContentProposalReview({
}
interface ProposalCardProps {
proposal: ContentProposal;
proposal: Proposal;
isAccepted: boolean;
agentCreatedAt: Date;
onAccept: () => void;
@@ -128,10 +127,14 @@ function ProposalCard({
}: ProposalCardProps) {
const [expanded, setExpanded] = useState(false);
// Check if page was modified since agent started
const pageQuery = trpc.getPage.useQuery({ id: proposal.pageId });
// Check if target page was modified since agent started (page proposals only)
const pageQuery = trpc.getPage.useQuery(
{ id: proposal.targetId ?? '' },
{ enabled: proposal.targetType === 'page' && !!proposal.targetId },
);
const pageUpdatedAt = pageQuery.data?.updatedAt;
const isStale =
proposal.targetType === 'page' &&
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;
return (
@@ -147,11 +150,13 @@ function ProposalCard({
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
{proposal.pageTitle}
{proposal.title}
</button>
<p className="text-xs text-muted-foreground mt-0.5 pl-5">
{proposal.summary}
</p>
{proposal.summary && (
<p className="text-xs text-muted-foreground mt-0.5 pl-5">
{proposal.summary}
</p>
)}
</div>
{isAccepted ? (
@@ -179,10 +184,10 @@ function ProposalCard({
</div>
)}
{expanded && (
{expanded && proposal.content && (
<div className="pl-5 pt-1">
<div className="prose prose-sm max-w-none rounded bg-muted/50 p-3 text-xs overflow-auto max-h-64">
<pre className="whitespace-pre-wrap text-xs">{proposal.markdown}</pre>
<pre className="whitespace-pre-wrap text-xs">{proposal.content}</pre>
</div>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm";
@@ -12,7 +12,7 @@ interface RefineAgentPanelProps {
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
// All agent logic is now encapsulated in the hook
const { state, agent, questions, proposals, spawn, resume, refresh } = useRefineAgent(initiativeId);
const { state, agent, questions, proposals, spawn, resume, dismiss, refresh } = useRefineAgent(initiativeId);
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
// so these callbacks won't change on every render.
@@ -31,8 +31,21 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
);
const handleDismiss = useCallback(() => {
refresh();
}, [refresh]);
dismiss();
}, [dismiss]);
// Cmd+Enter (Mac) / Ctrl+Enter (Windows) dismisses when completed
useEffect(() => {
if (state !== "completed") return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleDismiss();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [state, handleDismiss]);
// No active agent — show spawn button
if (state === "none") {

View File

@@ -1,7 +1,6 @@
import { useState, useCallback, useEffect } from "react";
import { useEffect } from "react";
import { trpc } from "@/lib/trpc";
import { PhaseAccordion } from "@/components/PhaseAccordion";
import { PlanTasksFetcher } from "./PlanTasksFetcher";
import type { SerializedTask } from "@/components/TaskRow";
import type { TaskCounts, FlatTaskEntry } from "./ExecutionContext";
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
@@ -30,17 +29,16 @@ export function PhaseWithTasks({
onTaskCounts,
registerTasks,
}: PhaseWithTasksProps) {
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
const tasksQuery = trpc.listPhaseTasks.useQuery({ phaseId: phase.id });
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
const plans = plansQuery.data ?? [];
const planIds = plans.map((p) => p.id);
const tasks = tasksQuery.data ?? [];
return (
<PhaseWithTasksInner
phase={phase}
planIds={planIds}
plansLoaded={plansQuery.isSuccess}
tasks={tasks}
tasksLoaded={tasksQuery.isSuccess}
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
defaultExpanded={defaultExpanded}
onTaskClick={onTaskClick}
@@ -52,8 +50,8 @@ export function PhaseWithTasks({
interface PhaseWithTasksInnerProps {
phase: PhaseWithTasksProps["phase"];
planIds: string[];
plansLoaded: boolean;
tasks: SerializedTask[];
tasksLoaded: boolean;
phaseDependencyIds: string[];
defaultExpanded: boolean;
onTaskClick: (taskId: string) => void;
@@ -63,38 +61,22 @@ interface PhaseWithTasksInnerProps {
function PhaseWithTasksInner({
phase,
planIds,
plansLoaded,
tasks,
tasksLoaded,
phaseDependencyIds: _phaseDependencyIds,
defaultExpanded,
onTaskClick,
onTaskCounts,
registerTasks,
}: PhaseWithTasksInnerProps) {
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
{},
);
const handlePlanTasks = useCallback(
(planId: string, tasks: SerializedTask[]) => {
setPlanTasks((prev) => {
if (prev[planId] === tasks) return prev;
return { ...prev, [planId]: tasks };
});
},
[],
);
// Propagate derived counts and entries outside the setState updater
// to avoid synchronous setState-inside-setState cascades.
// Propagate task counts and entries
useEffect(() => {
const allTasks = Object.values(planTasks).flat();
const complete = allTasks.filter(
const complete = tasks.filter(
(t) => t.status === "completed",
).length;
onTaskCounts(phase.id, { complete, total: allTasks.length });
onTaskCounts(phase.id, { complete, total: tasks.length });
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
const entries: FlatTaskEntry[] = tasks.map((task) => ({
task,
phaseName: `Phase ${phase.number}: ${phase.name}`,
agentName: null,
@@ -102,10 +84,9 @@ function PhaseWithTasksInner({
dependents: [],
}));
registerTasks(phase.id, entries);
}, [planTasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]);
}, [tasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]);
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
const sortedTasks = sortByPriorityAndQueueTime(allTasks);
const sortedTasks = sortByPriorityAndQueueTime(tasks);
const taskEntries = sortedTasks.map((task) => ({
task,
agentName: null as string | null,
@@ -114,24 +95,17 @@ function PhaseWithTasksInner({
const phaseDeps: Array<{ name: string; status: string }> = [];
return (
<>
{plansLoaded &&
planIds.map((planId) => (
<PlanTasksFetcher
key={planId}
planId={planId}
onTasks={handlePlanTasks}
/>
))}
if (!tasksLoaded) {
return null;
}
<PhaseAccordion
phase={phase}
tasks={taskEntries}
defaultExpanded={defaultExpanded}
phaseDependencies={phaseDeps}
onTaskClick={onTaskClick}
/>
</>
return (
<PhaseAccordion
phase={phase}
tasks={taskEntries}
defaultExpanded={defaultExpanded}
phaseDependencies={phaseDeps}
onTaskClick={onTaskClick}
/>
);
}

View File

@@ -1,20 +0,0 @@
import { useEffect } from "react";
import { trpc } from "@/lib/trpc";
import type { SerializedTask } from "@/components/TaskRow";
interface PlanTasksFetcherProps {
planId: string;
onTasks: (planId: string, tasks: SerializedTask[]) => void;
}
export function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
const tasksQuery = trpc.listTasks.useQuery({ planId });
useEffect(() => {
if (tasksQuery.data) {
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
}
}, [tasksQuery.data, planId, onTasks]);
return null;
}

View File

@@ -3,7 +3,6 @@ export { BreakdownSection } from "./BreakdownSection";
export { PhaseActions } from "./PhaseActions";
export { PhasesList } from "./PhasesList";
export { PhaseWithTasks } from "./PhaseWithTasks";
export { PlanTasksFetcher } from "./PlanTasksFetcher";
export { ProgressSidebar } from "./ProgressSidebar";
export { TaskModal } from "./TaskModal";
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";

View File

@@ -12,7 +12,6 @@ export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHand
export type {
RefineAgentState,
ContentProposal,
SpawnRefineAgentOptions,
UseRefineAgentResult,
} from './useRefineAgent.js';

View File

@@ -1,16 +1,9 @@
import { useMemo, useCallback, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import type { Agent, PendingQuestions } from '@codewalk-district/shared';
import type { Agent, PendingQuestions, Proposal } from '@codewalk-district/shared';
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
export interface ContentProposal {
pageId: string;
pageTitle: string;
summary: string;
markdown: string;
}
export interface SpawnRefineAgentOptions {
initiativeId: string;
instruction?: string;
@@ -23,8 +16,8 @@ export interface UseRefineAgentResult {
state: RefineAgentState;
/** Questions from the agent (when state is 'waiting') */
questions: PendingQuestions | null;
/** Parsed content proposals (when state is 'completed') */
proposals: ContentProposal[] | null;
/** Proposal rows from the DB (when state is 'completed') */
proposals: Proposal[] | null;
/** Raw result message (when state is 'completed') */
result: string | null;
/** Mutation for spawning a new refine agent */
@@ -39,6 +32,8 @@ export interface UseRefineAgentResult {
isPending: boolean;
error: Error | null;
};
/** Dismiss the current agent (sets userDismissedAt so it disappears) */
dismiss: () => void;
/** Whether any queries are loading */
isLoading: boolean;
/** Function to refresh agent data */
@@ -50,55 +45,6 @@ export interface UseRefineAgentResult {
*
* Encapsulates the logic for finding, spawning, and interacting with refine agents
* that analyze and suggest improvements to initiative content.
*
* @param initiativeId - The ID of the initiative to manage refine agents for
* @returns Object with agent state, mutations, and helper functions
*
* @example
* ```tsx
* function RefineSection({ initiativeId }: { initiativeId: string }) {
* const {
* state,
* agent,
* questions,
* proposals,
* spawn,
* resume,
* refresh
* } = useRefineAgent(initiativeId);
*
* const handleSpawn = () => {
* spawn.mutate({
* initiativeId,
* instruction: 'Focus on clarity and structure'
* });
* };
*
* if (state === 'none') {
* return (
* <button onClick={handleSpawn} disabled={spawn.isPending}>
* Start Refine Agent
* </button>
* );
* }
*
* if (state === 'waiting' && questions) {
* return (
* <QuestionForm
* questions={questions.questions}
* onSubmit={(answers) => resume.mutate(answers)}
* isSubmitting={resume.isPending}
* />
* );
* }
*
* if (state === 'completed' && proposals) {
* return <ProposalReview proposals={proposals} onDismiss={refresh} />;
* }
*
* return <div>Agent is {state}...</div>;
* }
* ```
*/
export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
const utils = trpc.useUtils();
@@ -146,38 +92,28 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
{ enabled: state === 'waiting' && !!agent },
);
// Fetch proposals from DB when completed
const proposalsQuery = trpc.listProposals.useQuery(
{ agentId: agent?.id ?? '' },
{ enabled: state === 'completed' && !!agent },
);
// Fetch result when completed
const resultQuery = trpc.getAgentResult.useQuery(
{ id: agent?.id ?? '' },
{ enabled: state === 'completed' && !!agent },
);
// Parse proposals from result
const { proposals, result } = useMemo(() => {
if (!resultQuery.data?.success || !resultQuery.data.message) {
return { proposals: null, result: null };
}
// Filter to only pending proposals
const proposals = useMemo(() => {
if (!proposalsQuery.data || proposalsQuery.data.length === 0) return null;
const pending = proposalsQuery.data.filter((p) => p.status === 'pending');
return pending.length > 0 ? pending : null;
}, [proposalsQuery.data]);
const message = resultQuery.data.message;
try {
const parsed = JSON.parse(message);
if (parsed.proposals && Array.isArray(parsed.proposals)) {
const proposals: ContentProposal[] = parsed.proposals.map(
(p: { pageId: string; title?: string; pageTitle?: string; summary: string; body?: string; markdown?: string }) => ({
pageId: p.pageId,
pageTitle: p.pageTitle ?? p.title ?? '',
summary: p.summary,
markdown: p.markdown ?? p.body ?? '',
}),
);
return { proposals, result: message };
}
} catch {
// Not JSON — treat as regular result
}
return { proposals: null, result: message };
const result = useMemo(() => {
if (!resultQuery.data?.success || !resultQuery.data.message) return null;
return resultQuery.data.message;
}, [resultQuery.data]);
// Spawn mutation
@@ -194,16 +130,26 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
},
});
// Dismiss mutation — sets userDismissedAt so agent disappears from the list
const dismissMutation = trpc.dismissAgent.useMutation({
onSuccess: () => {
// Force immediate refetch of agents to update UI
void utils.listAgents.invalidate();
void utils.listAgents.refetch();
void utils.listProposals.invalidate();
},
});
// Keep mutation functions in refs so the returned spawn/resume objects are
// stable across renders. tRPC mutation objects change identity every render,
// which cascades into unstable callbacks → unstable props → Radix Dialog
// re-renders that trigger the React 19 compose-refs infinite loop.
// stable across renders.
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 dismissMutateRef = useRef(dismissMutation.mutate);
dismissMutateRef.current = dismissMutation.mutate;
const spawnFn = useCallback(({ initiativeId, instruction }: SpawnRefineAgentOptions) => {
spawnMutateRef.current({
@@ -231,13 +177,21 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
error: resumeMutation.error,
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
const dismiss = useCallback(() => {
const a = agentRef.current;
if (a) {
dismissMutateRef.current({ id: a.id });
}
}, []);
const refresh = useCallback(() => {
void utils.listAgents.invalidate();
void utils.listProposals.invalidate();
}, [utils]);
const isLoading = agentsQuery.isLoading ||
(state === 'waiting' && questionsQuery.isLoading) ||
(state === 'completed' && resultQuery.isLoading);
(state === 'completed' && (resultQuery.isLoading || proposalsQuery.isLoading));
return {
agent,
@@ -247,7 +201,8 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
result,
spawn,
resume,
dismiss,
isLoading,
refresh,
};
}
}

View File

@@ -31,7 +31,6 @@ function InitiativeDetailPage() {
onData: () => {
void utils.listPhases.invalidate();
void utils.listTasks.invalidate();
void utils.listPlans.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {