Merge pull request #1654 from paperclipai/pr/pap-795-agent-runtime

fix(runtime): improve agent recovery and heartbeat operations
This commit is contained in:
Dotta 2026-03-23 19:44:51 -05:00 committed by GitHub
commit f2637e6972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1291 additions and 64 deletions

View file

@ -573,9 +573,9 @@ export function AgentDetail() {
});
const { data: allIssues } = useQuery({
queryKey: queryKeys.issues.list(resolvedCompanyId!),
queryFn: () => issuesApi.list(resolvedCompanyId!),
enabled: !!resolvedCompanyId && needsDashboardData,
queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"],
queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData,
});
const { data: allAgents } = useQuery({
@ -593,7 +593,6 @@ export function AgentDetail() {
});
const assignedIssues = (allIssues ?? [])
.filter((i) => i.assigneeAgentId === agent?.id)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
@ -1175,12 +1174,15 @@ function AgentOverview({
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent Issues</h3>
<Link to={`/issues?assignee=${agentId}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
<Link
to={`/issues?participantAgentId=${agentId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
See All &rarr;
</Link>
</div>
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No assigned issues.</p>
<p className="text-sm text-muted-foreground">No recent issues.</p>
) : (
<div className="border border-border rounded-lg">
{assignedIssues.slice(0, 10).map((issue) => (

View file

@ -77,9 +77,64 @@ export function InstanceSettings() {
},
});
const disableAllMutation = useMutation({
mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => {
const enabled = agentRows.filter((a) => a.heartbeatEnabled);
if (enabled.length === 0) return enabled;
const results = await Promise.allSettled(
enabled.map(async (agentRow) => {
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
await agentsApi.update(
agentRow.id,
{
runtimeConfig: {
...runtimeConfig,
heartbeat: { ...heartbeat, enabled: false },
},
},
agentRow.companyId,
);
}),
);
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (failures.length > 0) {
const firstError = failures[0]?.reason;
const detail = firstError instanceof Error ? firstError.message : "Unknown error";
throw new Error(
failures.length === 1
? `Failed to disable 1 timer heartbeat: ${detail}`
: `Failed to disable ${failures.length} of ${enabled.length} timer heartbeats. First error: ${detail}`,
);
}
return enabled;
},
onSuccess: async (updatedRows) => {
setActionError(null);
const companies = new Set(updatedRows.map((row) => row.companyId));
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
...Array.from(companies, (companyId) =>
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }),
),
...updatedRows.map((row) =>
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(row.id) }),
),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats.");
},
});
const agents = heartbeatsQuery.data ?? [];
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
const disabledCount = agents.length - activeCount;
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
const anyEnabled = enabledCount > 0;
const grouped = useMemo(() => {
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
@ -120,10 +175,27 @@ export function InstanceSettings() {
</p>
</div>
<div className="flex gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
{anyEnabled && (
<Button
variant="destructive"
size="sm"
className="ml-auto h-7 text-xs"
disabled={disableAllMutation.isPending}
onClick={() => {
const noun = enabledCount === 1 ? "agent" : "agents";
if (!window.confirm(`Disable timer heartbeats for all ${enabledCount} enabled ${noun}?`)) {
return;
}
disableAllMutation.mutate(agents);
}}
>
{disableAllMutation.isPending ? "Disabling..." : "Disable All"}
</Button>
)}
</div>
{actionError && (

View file

@ -21,6 +21,7 @@ export function Issues() {
const queryClient = useQueryClient();
const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearchChange = useCallback((search: string) => {
clearTimeout(debounceRef.current);
@ -86,8 +87,8 @@ export function Issues() {
}, [setBreadcrumbs]);
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
enabled: !!selectedCompanyId,
});
@ -117,6 +118,7 @@ export function Issues() {
initialSearch={initialSearch}
onSearchChange={handleSearchChange}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
searchFilters={participantAgentId ? { participantAgentId } : undefined}
/>
);
}