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
111 lines
3.0 KiB
TypeScript
111 lines
3.0 KiB
TypeScript
import { useEffect } from "react";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
|
import type { SerializedTask } from "@/components/TaskRow";
|
|
import type { TaskCounts, FlatTaskEntry } from "./ExecutionContext";
|
|
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
|
|
|
interface PhaseWithTasksProps {
|
|
phase: {
|
|
id: string;
|
|
initiativeId: string;
|
|
number: number;
|
|
name: string;
|
|
description: string | null;
|
|
status: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
defaultExpanded: boolean;
|
|
onTaskClick: (taskId: string) => void;
|
|
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
|
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
|
}
|
|
|
|
export function PhaseWithTasks({
|
|
phase,
|
|
defaultExpanded,
|
|
onTaskClick,
|
|
onTaskCounts,
|
|
registerTasks,
|
|
}: PhaseWithTasksProps) {
|
|
const tasksQuery = trpc.listPhaseTasks.useQuery({ phaseId: phase.id });
|
|
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
|
|
|
const tasks = tasksQuery.data ?? [];
|
|
|
|
return (
|
|
<PhaseWithTasksInner
|
|
phase={phase}
|
|
tasks={tasks}
|
|
tasksLoaded={tasksQuery.isSuccess}
|
|
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
|
defaultExpanded={defaultExpanded}
|
|
onTaskClick={onTaskClick}
|
|
onTaskCounts={onTaskCounts}
|
|
registerTasks={registerTasks}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface PhaseWithTasksInnerProps {
|
|
phase: PhaseWithTasksProps["phase"];
|
|
tasks: SerializedTask[];
|
|
tasksLoaded: boolean;
|
|
phaseDependencyIds: string[];
|
|
defaultExpanded: boolean;
|
|
onTaskClick: (taskId: string) => void;
|
|
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
|
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
|
}
|
|
|
|
function PhaseWithTasksInner({
|
|
phase,
|
|
tasks,
|
|
tasksLoaded,
|
|
phaseDependencyIds: _phaseDependencyIds,
|
|
defaultExpanded,
|
|
onTaskClick,
|
|
onTaskCounts,
|
|
registerTasks,
|
|
}: PhaseWithTasksInnerProps) {
|
|
// Propagate task counts and entries
|
|
useEffect(() => {
|
|
const complete = tasks.filter(
|
|
(t) => t.status === "completed",
|
|
).length;
|
|
onTaskCounts(phase.id, { complete, total: tasks.length });
|
|
|
|
const entries: FlatTaskEntry[] = tasks.map((task) => ({
|
|
task,
|
|
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
|
agentName: null,
|
|
blockedBy: [],
|
|
dependents: [],
|
|
}));
|
|
registerTasks(phase.id, entries);
|
|
}, [tasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]);
|
|
|
|
const sortedTasks = sortByPriorityAndQueueTime(tasks);
|
|
const taskEntries = sortedTasks.map((task) => ({
|
|
task,
|
|
agentName: null as string | null,
|
|
blockedBy: [] as Array<{ name: string; status: string }>,
|
|
}));
|
|
|
|
const phaseDeps: Array<{ name: string; status: string }> = [];
|
|
|
|
if (!tasksLoaded) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<PhaseAccordion
|
|
phase={phase}
|
|
tasks={taskEntries}
|
|
defaultExpanded={defaultExpanded}
|
|
phaseDependencies={phaseDeps}
|
|
onTaskClick={onTaskClick}
|
|
/>
|
|
);
|
|
} |