Files
buzz/desktop/src/features/agents/managedAgentRuntimeHooks.ts
Will Pfleger a64cc71f6c fix(desktop): clear stale working badges on agent stop/restart (#2803)
When Desktop stops or restarts a managed-agent pair, the harness is
SIGKILLed after 1s and `turn_completed` never lands.
`activeAgentTurnsStore` then sees the "all turns silent at once" pattern
and delays badge cleanup for up to 3 min (the pause that protects live
badges during transient relay-stream gaps). This is unnecessary when
Desktop itself issued the kill — there is no relay-gap ambiguity.

## What changed

**`activeAgentTurnsStore.ts`** — new `clearActiveTurnsForAgent(pubkey)`
- Tombstones every live turn for the agent via `recordTerminal` (blocks
in-flight `turn_liveness` frames from resurrecting them via
`resurrectTurn`)
- Removes the agent's entry from `activeTurnsByAgent`
- Preserves `lastProcessed` (watermark) — full-buffer replay after the
clear is a no-op
- Preserves `clockOffsetByAgent` — still valid, harmless

**`managedAgentRuntimeHooks.ts`** — clearing at the successful-stop
boundary
- New `clearActiveTurnsForAgentOnStop(pubkey, relayUrl?)` — relay-scope
gate: only clears when the stopped pair's relay matches the active
community (pair-scoped), or when an active community is configured
(agent-wide ops)
- New `restartManagedAgentPair(pubkey, relayUrl, stop, clear, start)` —
dependency-injected stop → clear → start sequence; the `restart` branch
of `useManagedAgentRuntimeAction`'s `mutationFn` is a single call into
it. The clear fires after a successful stop and before start begins, so
the badge is gone even when start fails, a failed stop clears nothing,
and no clear can run after the new process is spawned (genuinely-new
turns are never wiped)
- `useManagedAgentRuntimeAction.onSuccess` clears for `stop` actions,
before the query-cache update

**`managedAgentControlActions.ts`** — `onStopped` callback on
`respawnManagedAgentWithRules`, invoked after the stop promise resolves
and before start begins

**`welcomeKickoff.ts`** — same `onStopped` boundary on
`restartWelcomeTeammate`

**Call sites covered (all stop/restart UI paths):**
- `useManagedAgentRuntimeAction` — pair-scoped stop (`onSuccess`) and
restart (`restartManagedAgentPair` in `mutationFn`); Members-sidebar +
settings card
- `useMembersSidebarActions.handleRespawnAll` — via `onStopped`
- `useMembersSidebarActions.handleStopAll` — direct local-stop branch
- `useMembersSidebarActions.handleLifecycleAction` — local-stop fallback
branch
- `useAgentLifecycleActions.handleAgentPrimaryAction` — Agents-tab stop
- `useAgentLifecycleActions.handleAgentRestart` — via `onStopped`
- `useManagedAgentActions.handleStop` / `handleBulkStopRunning` — Agents
screen
- `useAutoRestartPolicy` — inline, between stop and start
- `restartWelcomeTeammate` call site — via `onStopped`

Provider agents are excluded at each site: they go through `!shutdown`
(relay message), not a direct harness kill.

## Tests

Twelve behavior tests across three files:

- `activeAgentTurnsStore.test.mjs` (6) — clear removes the agent's turns
and notifies subscribers, other agents untouched; full-buffer replay
after clear is a no-op (watermark preserved); late `turn_liveness` frame
with timestamp ≤ clear time does not resurrect (tombstone); new
`turn_started` after clear is tracked normally; badge gone when stop
succeeds even if start fails; new frame during start-pending does not
resurrect the cleared badge
- `managedAgentControlActions.test.mjs` (3) — `onStopped` fires on
stop-success/start-failure; does not fire on stop-failure; strict stop →
`onStopped` → start ordering
- `managedAgentRuntimeHooks.test.mjs` (3) — pair-restart seam: clear ran
when start fails (rejection propagates); stop failure invokes neither
clear nor start; strict stop → clear → start ordering

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-25 12:02:39 -04:00

229 lines
7.8 KiB
TypeScript

import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { clearActiveTurnsForAgent } from "@/features/agents/activeAgentTurnsStore";
import {
loadActiveCommunityId,
loadCommunities,
} from "@/features/communities/communityStorage";
import {
listManagedAgentRuntimes,
reconcileManagedAgentRuntimes,
startManagedAgentRuntime,
stopManagedAgentRuntime,
} from "@/shared/api/tauriManagedAgents";
import type { ManagedAgentRuntimeStatus } from "@/shared/api/types";
import { canonicalRelayUrl } from "./managedAgentRuntimeStatus";
export const managedAgentRuntimesQueryKey = ["managed-agent-runtimes"] as const;
export function mergeManagedAgentRuntimeStatuses(
baseline: readonly ManagedAgentRuntimeStatus[] | undefined,
current: readonly ManagedAgentRuntimeStatus[] | undefined,
reconciled: readonly ManagedAgentRuntimeStatus[],
): ManagedAgentRuntimeStatus[] {
const baselineByPair = new Map(
(baseline ?? []).map((runtime) => [runtimePairKey(runtime), runtime]),
);
const currentByPair = new Map(
(current ?? []).map((runtime) => [runtimePairKey(runtime), runtime]),
);
const reconciledPairs = new Set<string>();
const merged = reconciled.map((runtime) => {
const key = runtimePairKey(runtime);
reconciledPairs.add(key);
const currentRuntime = currentByPair.get(key);
const baselineRuntime = baselineByPair.get(key);
// A status event or user action may update this pair while startup
// reconciliation is discovering others. Only preserve cache rows that
// changed after the reconcile began; otherwise its result is newer.
return currentRuntime && currentRuntime !== baselineRuntime
? { ...runtime, ...currentRuntime }
: runtime;
});
for (const runtime of current ?? []) {
if (!reconciledPairs.has(runtimePairKey(runtime))) merged.push(runtime);
}
return merged;
}
function runtimePairKey(runtime: ManagedAgentRuntimeStatus): string {
return JSON.stringify([runtime.pubkey, runtime.relayUrl]);
}
export function cacheReconciledManagedAgentRuntimes(
queryClient: QueryClient,
baseline: readonly ManagedAgentRuntimeStatus[] | undefined,
runtimes: readonly ManagedAgentRuntimeStatus[],
): void {
queryClient.setQueryData<ManagedAgentRuntimeStatus[]>(
managedAgentRuntimesQueryKey,
(current) => mergeManagedAgentRuntimeStatuses(baseline, current, runtimes),
);
}
/**
* Bootstrap runtime pairs in every configured community (fire-and-forget).
*
* Called after an agent create: the create command spawns only the active
* community's pair, and the startup reconcile won't run again until the next
* launch or community switch, so without this kick a brand-new agent stays
* deaf in every other community. Idempotent — live pairs are skipped and
* missing ones spawn lazily (warm socket, no LLM until first mention).
*/
export function bootstrapManagedAgentRuntimePairs(
queryClient: QueryClient,
): void {
const baseline = queryClient.getQueryData<ManagedAgentRuntimeStatus[]>(
managedAgentRuntimesQueryKey,
);
const communities = loadCommunities().map((community) => ({
relayUrl: community.relayUrl,
}));
void reconcileManagedAgentRuntimes(communities)
.then((runtimes) => {
cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes);
})
.catch((error) => {
console.warn(
"[managed-agent-runtimes] post-create reconcile failed:",
error,
);
});
}
export function useManagedAgentRuntimesQuery(options?: { enabled?: boolean }) {
return useQuery({
enabled: options?.enabled ?? true,
queryKey: managedAgentRuntimesQueryKey,
queryFn: listManagedAgentRuntimes,
});
}
/**
* Clear the active community's working badges for an agent when Desktop
* performs an agent-wide stop or restart that does not go through the
* pair-scoped `useManagedAgentRuntimeAction` mutation. Applies the same
* relay-scope gate: only wipes the store when the active community relay
* matches `relayUrl`, or — for agent-wide operations with no known relay —
* when any of the agent's configured pairs is in the active community.
*
* Pass `relayUrl` when a specific pair relay is known (preferred). Omit it
* (pass null/undefined) for agent-wide operations: the function then clears
* whenever the active community is configured, since the agent-wide stop
* affects all pairs, including the one in the active community.
*/
export function clearActiveTurnsForAgentOnStop(
pubkey: string,
relayUrl?: string | null,
): void {
const activeId = loadActiveCommunityId();
if (!activeId) return;
const activeCommunity = loadCommunities().find((c) => c.id === activeId);
if (!activeCommunity) return;
if (relayUrl != null) {
// Pair-scoped: only clear when the stopped pair's relay matches the active
// community. A mismatch means the stop targets a different community's
// store — leave it alone.
const activeCanonical = canonicalRelayUrl(activeCommunity.relayUrl);
const stoppedCanonical = canonicalRelayUrl(relayUrl);
if (
activeCanonical === null ||
stoppedCanonical === null ||
activeCanonical !== stoppedCanonical
) {
return;
}
}
// Agent-wide (relayUrl omitted): active community is confirmed to exist, so
// the stop affects the active pair among others — clear.
clearActiveTurnsForAgent(pubkey);
}
/**
* Execute a pair restart as stop → relay-scoped badge clear → start.
*
* Extracted from `useManagedAgentRuntimeAction`'s `mutationFn` so the
* three-step lifecycle boundary can be tested directly without a hook-render
* harness. All three operations are injected, keeping this function free of
* React and Tauri imports.
*
* Guarantees:
* - Clear fires only when stop succeeds.
* - A failed start occurs after the clear — the badge is already gone.
* - No clear can fire after start begins, so genuinely-new turns are safe.
*/
export async function restartManagedAgentPair(
pubkey: string,
relayUrl: string,
stop: (
pubkey: string,
relayUrl: string,
) => Promise<ManagedAgentRuntimeStatus>,
clear: (pubkey: string, relayUrl: string) => void,
start: (
pubkey: string,
relayUrl: string,
) => Promise<ManagedAgentRuntimeStatus>,
): Promise<ManagedAgentRuntimeStatus> {
await stop(pubkey, relayUrl);
clear(pubkey, relayUrl);
return start(pubkey, relayUrl);
}
export function useManagedAgentRuntimeAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
action,
pubkey,
relayUrl,
}: {
action: "start" | "stop" | "restart";
pubkey: string;
relayUrl: string;
}) => {
if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl);
if (action === "restart") {
return restartManagedAgentPair(
pubkey,
relayUrl,
stopManagedAgentRuntime,
clearActiveTurnsForAgentOnStop,
startManagedAgentRuntime,
);
}
return startManagedAgentRuntime(pubkey, relayUrl);
},
onSuccess: (runtime, { action }) => {
// For stop-only: clear stale working badges immediately. The restart
// path already clears at the stop-success boundary inside mutationFn.
if (action === "stop") {
clearActiveTurnsForAgentOnStop(runtime.pubkey, runtime.relayUrl);
}
queryClient.setQueryData<ManagedAgentRuntimeStatus[]>(
managedAgentRuntimesQueryKey,
(current = []) => {
const index = current.findIndex(
(candidate) =>
candidate.pubkey === runtime.pubkey &&
candidate.relayUrl === runtime.relayUrl,
);
if (index === -1) return [...current, runtime];
return current.map((candidate, candidateIndex) =>
candidateIndex === index ? runtime : candidate,
);
},
);
},
});
}