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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -12,7 +12,6 @@ export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHand
|
||||
|
||||
export type {
|
||||
RefineAgentState,
|
||||
ContentProposal,
|
||||
SpawnRefineAgentOptions,
|
||||
UseRefineAgentResult,
|
||||
} from './useRefineAgent.js';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.", {
|
||||
|
||||
Reference in New Issue
Block a user