Files
buzz/desktop/src/features/agents/lib/managedAgentControlActions.ts
T
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

214 lines
5.8 KiB
TypeScript

import { sendChannelMessage } from "@/shared/api/tauri";
import type {
Channel,
ManagedAgent,
PresenceLookup,
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
type DeleteManagedAgentInput = {
pubkey: string;
forceRemoteDelete?: boolean;
};
type StartManagedAgent = (pubkey: string) => Promise<unknown>;
type StopManagedAgent = (pubkey: string) => Promise<unknown>;
type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise<unknown>;
type ManagedAgentChannelContext = {
channels: readonly Channel[];
preferredChannelId?: string | null;
relayAgents: readonly RelayAgent[];
};
type ManagedAgentActionContext = ManagedAgentChannelContext & {
presenceLookup?: PresenceLookup | null;
};
export type ManagedAgentActionResult = {
cancelled?: boolean;
noticeMessage?: string;
};
export function isManagedAgentActive(agent: Pick<ManagedAgent, "status">) {
return agent.status === "running" || agent.status === "deployed";
}
export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) {
if (agent.backend.type === "provider") {
return isManagedAgentActive(agent) ? "Shutdown" : "Deploy";
}
if (isManagedAgentActive(agent)) {
return "Stop";
}
return agent.status === "stopped" ? "Respawn" : "Spawn";
}
export function resolveManagedAgentChannelId(
agent: Pick<ManagedAgent, "pubkey">,
context: ManagedAgentChannelContext,
) {
if (context.preferredChannelId) {
return context.preferredChannelId;
}
const relayAgent = context.relayAgents.find(
(candidate) =>
normalizePubkey(candidate.pubkey) === normalizePubkey(agent.pubkey),
);
if (relayAgent?.channelIds?.length) {
return relayAgent.channelIds[0];
}
const channelName = relayAgent?.channels?.[0];
if (!channelName) {
return null;
}
const matches = context.channels.filter(
(channel) => channel.name === channelName,
);
return matches.length === 1 ? matches[0].id : null;
}
export async function startManagedAgentWithRules({
agent,
startManagedAgent,
}: {
agent: ManagedAgent;
startManagedAgent: StartManagedAgent;
}) {
// Relay-mesh agents are no longer blocked here: the backend start preflight
// (ensure_relay_mesh_for_record) re-resolves a live serve target and dials
// it, failing with an actionable error when no peer serves the model.
await startManagedAgent(agent.pubkey);
}
export async function respawnManagedAgentWithRules({
agent,
startManagedAgent,
stopManagedAgent,
onStopped,
}: {
agent: ManagedAgent;
startManagedAgent: StartManagedAgent;
stopManagedAgent: StopManagedAgent;
/** Called after a successful stop and before start begins — use this to
* clear stale working badges at the right boundary. */
onStopped?: () => void;
}) {
if (agent.backend.type === "local" && isManagedAgentActive(agent)) {
await stopManagedAgent(agent.pubkey);
onStopped?.();
}
await startManagedAgent(agent.pubkey);
}
export async function stopManagedAgentWithRules({
agent,
channels,
preferredChannelId,
relayAgents,
stopManagedAgent,
}: {
agent: ManagedAgent;
stopManagedAgent: StopManagedAgent;
} & ManagedAgentChannelContext): Promise<ManagedAgentActionResult> {
if (agent.backend.type === "provider") {
const channelId = resolveManagedAgentChannelId(agent, {
channels,
preferredChannelId,
relayAgents,
});
if (!channelId) {
throw new Error("Cannot stop: agent is not in any channel");
}
await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [
agent.pubkey,
]);
return {
noticeMessage: "Shutdown command sent. Agent will stop shortly.",
};
}
await stopManagedAgent(agent.pubkey);
return {};
}
export async function deleteManagedAgentWithRules({
agent,
channels,
deleteManagedAgent,
preferredChannelId,
presenceLookup,
relayAgents,
skipRemoteDeleteConfirm = false,
}: {
agent: ManagedAgent;
deleteManagedAgent: DeleteManagedAgent;
skipRemoteDeleteConfirm?: boolean;
} & ManagedAgentActionContext): Promise<ManagedAgentActionResult> {
if (agent.backend.type === "provider" && agent.backendAgentId) {
const presence = presenceLookup?.[normalizePubkey(agent.pubkey)];
const channelId = resolveManagedAgentChannelId(agent, {
channels,
preferredChannelId,
relayAgents,
});
if (channelId) {
if (presence === "online" || presence === "away") {
await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [
agent.pubkey,
]);
if (!skipRemoteDeleteConfirm) {
const confirmed = window.confirm(
"Shutdown command sent, but the agent may still be running. " +
"Deleting now removes the local record — the remote deployment " +
"will be orphaned if shutdown hasn't completed. Continue?",
);
if (!confirmed) {
return { cancelled: true };
}
}
} else {
if (!skipRemoteDeleteConfirm) {
const confirmed = window.confirm(
"This agent is offline but the remote deployment may still exist. " +
"Deleting removes the local management record. Continue?",
);
if (!confirmed) {
return { cancelled: true };
}
}
}
} else {
if (!skipRemoteDeleteConfirm) {
const confirmed = window.confirm(
"This agent is deployed but not in any channel. " +
"Deleting will orphan the remote deployment (it will keep running). Continue?",
);
if (!confirmed) {
return { cancelled: true };
}
}
}
}
const isDeployedRemote =
agent.backend.type === "provider" && agent.backendAgentId;
await deleteManagedAgent({
pubkey: agent.pubkey,
forceRemoteDelete: isDeployedRemote ? true : undefined,
});
return {};
}