mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02: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>
214 lines
5.8 KiB
TypeScript
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 {};
|
|
}
|