Support concurrent heartbeat runs with maxConcurrentRuns policy
Add per-agent maxConcurrentRuns (1-10) controlling how many runs execute simultaneously. Implements agent-level start lock, optimistic claim-then-execute flow, atomic token accounting via SQL expressions, and proper status resolution when parallel runs finish. Updates UI config form, live run count display, and SSE invalidation to avoid unnecessary refetches on run event streams. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,7 +155,6 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(companyId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const runs = liveRuns ?? [];
|
||||
|
||||
@@ -112,6 +112,7 @@ export function NewAgentDialog() {
|
||||
intervalSec: configValues.intervalSec,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
budgetMonthlyCents: 0,
|
||||
|
||||
@@ -154,6 +154,7 @@ export function OnboardingWizard() {
|
||||
intervalSec: 300,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export const help: Record<string, string> = {
|
||||
graceSec: "Seconds to wait after sending interrupt before force-killing the process.",
|
||||
wakeOnDemand: "Allow this agent to be woken by assignments, API calls, UI actions, or automated systems.",
|
||||
cooldownSec: "Minimum seconds between consecutive heartbeat runs.",
|
||||
maxConcurrentRuns: "Maximum number of heartbeat runs that can execute simultaneously for this agent.",
|
||||
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ function invalidateHeartbeatQueries(
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||
@@ -100,11 +101,15 @@ function handleLiveEvent(
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status" || event.type === "heartbeat.run.event") {
|
||||
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") {
|
||||
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "heartbeat.run.event") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent.status") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(expectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(expectedCompanyId) });
|
||||
|
||||
@@ -510,7 +510,14 @@ export function AgentDetail() {
|
||||
const hb = (agent.runtimeConfig as Record<string, unknown>).heartbeat as Record<string, unknown>;
|
||||
if (!hb.enabled) return <span className="text-muted-foreground">Disabled</span>;
|
||||
const sec = Number(hb.intervalSec) || 300;
|
||||
return <span>Every {sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`}</span>;
|
||||
const maxConcurrentRuns = Math.max(1, Math.floor(Number(hb.maxConcurrentRuns) || 1));
|
||||
const intervalLabel = sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`;
|
||||
return (
|
||||
<span>
|
||||
Every {intervalLabel}
|
||||
{maxConcurrentRuns > 1 ? ` (max ${maxConcurrentRuns} concurrent)` : ""}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
: <span className="text-muted-foreground">Not configured</span>
|
||||
}
|
||||
|
||||
@@ -90,13 +90,17 @@ export function Agents() {
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
// Map agentId -> first live run (running or queued)
|
||||
// Map agentId -> first live run + live run count
|
||||
const liveRunByAgent = useMemo(() => {
|
||||
const map = new Map<string, { runId: string }>();
|
||||
const map = new Map<string, { runId: string; liveCount: number }>();
|
||||
for (const r of runs ?? []) {
|
||||
if ((r.status === "running" || r.status === "queued") && !map.has(r.agentId)) {
|
||||
map.set(r.agentId, { runId: r.id });
|
||||
if (r.status !== "running" && r.status !== "queued") continue;
|
||||
const existing = map.get(r.agentId);
|
||||
if (existing) {
|
||||
existing.liveCount += 1;
|
||||
continue;
|
||||
}
|
||||
map.set(r.agentId, { runId: r.id, liveCount: 1 });
|
||||
}
|
||||
return map;
|
||||
}, [runs]);
|
||||
@@ -246,6 +250,7 @@ export function Agents() {
|
||||
<LiveRunIndicator
|
||||
agentId={agent.id}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
) : (
|
||||
@@ -257,6 +262,7 @@ export function Agents() {
|
||||
<LiveRunIndicator
|
||||
agentId={agent.id}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
@@ -319,7 +325,7 @@ function OrgTreeNode({
|
||||
depth: number;
|
||||
navigate: (path: string) => void;
|
||||
agentMap: Map<string, Agent>;
|
||||
liveRunByAgent: Map<string, { runId: string }>;
|
||||
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
|
||||
}) {
|
||||
const agent = agentMap.get(node.id);
|
||||
|
||||
@@ -358,6 +364,7 @@ function OrgTreeNode({
|
||||
<LiveRunIndicator
|
||||
agentId={node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
) : (
|
||||
@@ -369,6 +376,7 @@ function OrgTreeNode({
|
||||
<LiveRunIndicator
|
||||
agentId={node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
@@ -402,10 +410,12 @@ function OrgTreeNode({
|
||||
function LiveRunIndicator({
|
||||
agentId,
|
||||
runId,
|
||||
liveCount,
|
||||
navigate,
|
||||
}: {
|
||||
agentId: string;
|
||||
runId: string;
|
||||
liveCount: number;
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -420,7 +430,9 @@ function LiveRunIndicator({
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">Live</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">
|
||||
Live{liveCount > 1 ? ` (${liveCount})` : ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user