Files
Codewalkers/packages/web/src/components/execution/PhaseWithTasks.tsx
Lukas May 43e2c8b0ba 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
2026-02-08 15:51:32 +01:00

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}
/>
);
}