mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
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>
229 lines
7.8 KiB
TypeScript
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,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|