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>
This commit is contained in:
Will Pfleger
2026-07-25 12:02:39 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 5e3d2e4849
commit a64cc71f6c
11 changed files with 582 additions and 13 deletions
@@ -11,6 +11,7 @@ import {
saveActiveAgentTurnsForCommunity,
restoreActiveAgentTurnsForCommunity,
clearSavedCommunitySnapshot,
clearActiveTurnsForAgent,
} from "./activeAgentTurnsStore.ts";
import {
injectObserverEventsForE2E,
@@ -1737,3 +1738,216 @@ describe("community-switch save / restore", () => {
);
});
});
describe("clearActiveTurnsForAgent", () => {
const EPOCH = Date.parse("2024-01-01T00:00:00Z");
const at = (ms) => new Date(EPOCH + ms).toISOString();
beforeEach(() => {
resetActiveAgentTurnsStore();
});
it("clear removes the agent turns and notifies subscribers; other agents untouched", () => {
// Give AGENT two turns and AGENT_2 one turn.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
makeEvent({ seq: 2, turnId: "t2", channelId: "c2" }),
]);
syncAgentTurnsFromEvents(AGENT_2, [
makeEvent({ seq: 1, turnId: "t3", channelId: "c3" }),
]);
let notified = 0;
const unsub = subscribeActiveAgentTurns(() => {
notified++;
});
clearActiveTurnsForAgent(AGENT);
unsub();
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
"cleared agent must have no turns",
);
assert.equal(notified, 1, "must notify listeners exactly once");
// AGENT_2 is unaffected.
const a2channels = channelIdsOf(getActiveTurnsForAgent(AGENT_2));
assert.ok(a2channels.has("c3"), "other agent's turns must survive clear");
});
it("full-buffer replay after clear is a no-op (watermark preserved — badge stays gone)", () => {
// Process initial events to set the watermark at seq 2.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }),
makeEvent({
seq: 2,
turnId: "t2",
channelId: "c2",
timestamp: at(1_000),
}),
]);
clearActiveTurnsForAgent(AGENT);
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
"should be empty after clear",
);
// Replay the identical buffer — every event is at or below the watermark.
let notified = 0;
const unsub = subscribeActiveAgentTurns(() => {
notified++;
});
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }),
makeEvent({
seq: 2,
turnId: "t2",
channelId: "c2",
timestamp: at(1_000),
}),
]);
unsub();
assert.equal(
notified,
0,
"replay must not notify — watermark must be preserved",
);
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
"badge must stay gone",
);
});
it("late turn_liveness frame with timestamp ≤ clear time does not resurrect (tombstone)", () => {
mock.timers.enable({ apis: ["Date"], now: EPOCH });
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }),
]);
// Clear at EPOCH (t=0 in agent-host clock).
clearActiveTurnsForAgent(AGENT);
assert.equal(getActiveTurnsForAgent(AGENT).length, 0);
// A liveness frame whose timestamp is at or before the clear time must not
// resurrect the badge (tombstone blocks it). Advance seq past the
// watermark by using a higher seq than the initial turn_started.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 2,
kind: "turn_liveness",
turnId: "t1",
channelId: "c1",
timestamp: at(0), // equal to clear time — must NOT resurrect
}),
]);
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
"liveness at or before clear time must not resurrect the cleared turn",
);
mock.timers.reset();
});
it("new turn_started after clear (restart picked up new work) is tracked normally", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }),
]);
clearActiveTurnsForAgent(AGENT);
assert.equal(getActiveTurnsForAgent(AGENT).length, 0);
// A genuinely new turn arrives after the clear with a later timestamp and
// a new turnId — it must be tracked normally.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 2,
turnId: "t2",
channelId: "c1",
timestamp: at(5_000), // strictly newer than the cleared turn's timestamp
}),
]);
const turns = getActiveTurnsForAgent(AGENT);
assert.equal(turns.length, 1, "new turn after clear must be tracked");
assert.ok(channelIdsOf(turns).has("c1"), "new turn must surface c1");
});
it("badge is gone when stop succeeds even if start subsequently fails (stop-boundary clear)", () => {
// Arrange: agent has an active turn.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }),
]);
assert.equal(
getActiveTurnsForAgent(AGENT).length,
1,
"turn must be active before stop",
);
// Act: simulate what onStopped does — clear at the stop-success boundary,
// before start is called. Start fails (not called here).
clearActiveTurnsForAgent(AGENT);
// Assert: the badge is gone regardless of what happens to start.
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
"badge must clear at stop-success boundary, not waiting for start to resolve",
);
});
it("new frame arriving while start is pending does not resurrect the cleared badge (tombstone boundary)", () => {
// Simulate: agent was active, stop succeeded and clear ran (onStopped
// fired), start is now in-flight. A stale liveness frame for the OLD
// turn arrives on the wire during the start-pending window. It must NOT
// resurrect the badge — the clear tombstoned it.
mock.timers.enable({ apis: ["Date"], now: EPOCH });
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }),
]);
// onStopped fires: clear at stop boundary (agent-host clock = EPOCH).
clearActiveTurnsForAgent(AGENT);
// Stale liveness for t1 arrives with timestamp ≤ clear time (on-wire
// frame from before the kill). Must be blocked by the tombstone.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 2,
kind: "turn_liveness",
turnId: "t1",
channelId: "c1",
timestamp: at(0),
}),
]);
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
"stale liveness during start-pending must not resurrect the cleared badge",
);
// Genuine new turn from the restarted agent arrives later with a new id
// and strictly newer timestamp — must be tracked normally.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 3,
turnId: "t2-new",
channelId: "c1",
timestamp: at(3_000),
}),
]);
assert.equal(
getActiveTurnsForAgent(AGENT).length,
1,
"genuine new turn from restarted agent must be tracked",
);
mock.timers.reset();
});
});
@@ -574,6 +574,36 @@ export function useActiveAgentTurnsBridge(
}, [agents]);
}
/**
* Immediately clear all active turns for a specific agent — called when
* Desktop itself stops or restarts the agent, so the turn store doesn't
* have to wait for the 3-minute prune-pause backstop.
*
* Preserves `lastProcessed` (the watermark) so a full-buffer replay after
* the clear is still a no-op — without the watermark a replayed
* `turn_started` would immediately resurrect the badge. Preserves
* `clockOffsetByAgent` — the offset remains valid and harmless.
*
* Tombstones every cleared turn (C) so an in-flight `turn_liveness` frame
* already on the wire at kill time cannot resurrect the badge via
* `resurrectTurn`. A restarted agent's genuinely new turns carry new
* turnIds / newer timestamps, so the tombstones don't block them.
*/
export function clearActiveTurnsForAgent(agentPubkey: string): void {
const key = normalizePubkey(agentPubkey);
const agentTurns = activeTurnsByAgent.get(key);
if (!agentTurns || agentTurns.size === 0) return;
const agentClockNow = Date.now() - (clockOffsetByAgent.get(key) ?? 0);
for (const turnId of agentTurns.keys()) {
recordTerminal(key, turnId, agentClockNow);
}
activeTurnsByAgent.delete(key);
invalidateCache(key);
notifyListeners();
}
/**
* Clears all live turn state (active turns, offsets, watermarks, tombstones).
* Intentionally preserves `savedByCommunity` — community-switch snapshots
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { startManagedAgentWithRules } from "./managedAgentControlActions.ts";
import {
startManagedAgentWithRules,
respawnManagedAgentWithRules,
} from "./managedAgentControlActions.ts";
function agent(overrides = {}) {
return {
@@ -77,3 +80,89 @@ test("ordinary local agents still start normally", async () => {
});
assert.equal(calledWith, "deadbeef".repeat(8));
});
// --- respawnManagedAgentWithRules: stop→clear→start boundary tests -----------
test("test_respawn_stop_success_start_failure_onStopped_still_fires", async () => {
// Prove: onStopped fires at the stop-success boundary even when start later
// throws. This is the key discriminator: on round-1 code the clear only
// ran after the full respawn, so a failed start left the badge intact.
const runningAgent = agent({ status: "running" });
let onStoppedFired = false;
await assert.rejects(
respawnManagedAgentWithRules({
agent: runningAgent,
stopManagedAgent: async () => {
/* stop succeeds */
},
startManagedAgent: async () => {
throw new Error("start failed");
},
onStopped: () => {
onStoppedFired = true;
},
}),
/start failed/,
);
assert.ok(
onStoppedFired,
"onStopped must fire at stop-success boundary even when start subsequently fails",
);
});
test("test_respawn_stop_failure_onStopped_not_called", async () => {
// Prove: onStopped does NOT fire when stop itself throws. Clearing on a
// failed stop would remove a badge that is still legitimately active.
const runningAgent = agent({ status: "running" });
let onStoppedFired = false;
await assert.rejects(
respawnManagedAgentWithRules({
agent: runningAgent,
stopManagedAgent: async () => {
throw new Error("stop failed");
},
startManagedAgent: async () => {
/* should not be reached */
},
onStopped: () => {
onStoppedFired = true;
},
}),
/stop failed/,
);
assert.ok(
!onStoppedFired,
"onStopped must NOT fire when stop itself fails — badge is still active",
);
});
test("test_respawn_onStopped_fires_before_start_resolves", async () => {
// Prove: onStopped fires strictly between stop resolution and start
// invocation. A clear that fires after start begins can tombstone genuine
// new turns from the freshly spawned process.
const runningAgent = agent({ status: "running" });
const events = [];
await respawnManagedAgentWithRules({
agent: runningAgent,
stopManagedAgent: async () => {
events.push("stop");
},
startManagedAgent: async () => {
events.push("start");
},
onStopped: () => {
events.push("onStopped");
},
});
assert.deepEqual(
events,
["stop", "onStopped", "start"],
"onStopped must fire after stop resolves and before start is called",
);
});
@@ -92,13 +92,18 @@ 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);
@@ -5,6 +5,7 @@ import {
managedAgentsQueryKey,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
import {
startManagedAgent,
stopManagedAgent,
@@ -109,6 +110,7 @@ export function useAutoRestartPolicy() {
return;
}
await stopManagedAgent(agent.pubkey);
clearActiveTurnsForAgentOnStop(agent.pubkey);
await startManagedAgent(agent.pubkey);
} catch {
// Failed attempt: edge stays consumed — badge-only until the
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import test from "node:test";
import { restartManagedAgentPair } from "./managedAgentRuntimeHooks.ts";
// ---------------------------------------------------------------------------
// restartManagedAgentPair: discriminating regression tests for the pair
// restart lifecycle boundary (stop → relay-scoped clear → start).
//
// These tests exercise the exact function called by useManagedAgentRuntimeAction's
// mutationFn restart branch, so reverting to the old combined Rust command
// (which cleared only in onSuccess, after the new process was already running)
// would make tests (a) and (c) fail.
// ---------------------------------------------------------------------------
const PUBKEY = "deadbeef".repeat(8);
const RELAY = "wss://relay.example";
/** Returns a resolved-status stub sufficient for the return-type assertion. */
function makeStatus() {
return {
pubkey: PUBKEY,
relayUrl: RELAY,
localSetup: true,
lifecycle: "running",
};
}
test("test_pair_restart_stop_success_start_failure_clear_still_ran", async () => {
// Stop succeeds, start throws. The clear must have fired — badge is gone
// regardless of the start failure. On the old combined-command approach,
// a rejected command meant onSuccess never ran and the badge survived.
let clearFired = false;
await assert.rejects(
restartManagedAgentPair(
PUBKEY,
RELAY,
async () => makeStatus(), // stop succeeds
(_pubkey, _relayUrl) => {
clearFired = true;
},
async () => {
throw new Error("start failed");
},
),
/start failed/,
);
assert.ok(
clearFired,
"clear must fire at stop-success boundary even when start subsequently fails",
);
});
test("test_pair_restart_stop_failure_neither_clear_nor_start_called", async () => {
// Stop throws. Neither clear nor start should run — clearing on a failed
// stop would remove a badge that is still legitimately active.
let clearFired = false;
let startCalled = false;
await assert.rejects(
restartManagedAgentPair(
PUBKEY,
RELAY,
async () => {
throw new Error("stop failed");
},
(_pubkey, _relayUrl) => {
clearFired = true;
},
async () => {
startCalled = true;
return makeStatus();
},
),
/stop failed/,
);
assert.ok(!clearFired, "clear must NOT fire when stop itself fails");
assert.ok(!startCalled, "start must NOT be called when stop fails");
});
test("test_pair_restart_strict_stop_clear_start_ordering", async () => {
// Verify the operations fire in the guaranteed order: stop → clear → start.
// A clear that fires after start begins can tombstone genuine new turns.
const events = [];
await restartManagedAgentPair(
PUBKEY,
RELAY,
async () => {
events.push("stop");
return makeStatus();
},
(_pubkey, _relayUrl) => {
events.push("clear");
},
async () => {
events.push("start");
return makeStatus();
},
);
assert.deepEqual(
events,
["stop", "clear", "start"],
"operations must fire in stop → clear → start order",
);
});
@@ -5,15 +5,19 @@ import {
type QueryClient,
} from "@tanstack/react-query";
import { loadCommunities } from "@/features/communities/communityStorage";
import { clearActiveTurnsForAgent } from "@/features/agents/activeAgentTurnsStore";
import {
loadActiveCommunityId,
loadCommunities,
} from "@/features/communities/communityStorage";
import {
listManagedAgentRuntimes,
reconcileManagedAgentRuntimes,
restartManagedAgentRuntime,
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;
@@ -101,6 +105,79 @@ export function useManagedAgentRuntimesQuery(options?: { enabled?: boolean }) {
});
}
/**
* 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({
@@ -115,11 +192,23 @@ export function useManagedAgentRuntimeAction() {
}) => {
if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl);
if (action === "restart") {
return restartManagedAgentRuntime(pubkey, relayUrl);
return restartManagedAgentPair(
pubkey,
relayUrl,
stopManagedAgentRuntime,
clearActiveTurnsForAgentOnStop,
startManagedAgentRuntime,
);
}
return startManagedAgentRuntime(pubkey, relayUrl);
},
onSuccess: (runtime) => {
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 = []) => {
@@ -29,6 +29,7 @@ import {
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "../lib/managedAgentControlActions";
import { clearActiveTurnsForAgentOnStop } from "../managedAgentRuntimeHooks";
import {
availableRuntimesForStart,
buildInstanceInputForDefinition,
@@ -248,6 +249,9 @@ export function useManagedAgentActions() {
relayAgents: relayAgentsQuery.data ?? [],
stopManagedAgent: stopMutation.mutateAsync,
});
if (agent.backend.type === "local") {
clearActiveTurnsForAgentOnStop(pubkey);
}
if (result.noticeMessage) {
setActionNoticeMessage(result.noticeMessage);
}
@@ -368,13 +372,17 @@ export function useManagedAgentActions() {
managedAgents.filter((a) => isManagedAgentActive(a)),
"Stop",
"stop",
(a) =>
stopManagedAgentWithRules({
async (a) => {
await stopManagedAgentWithRules({
agent: a,
channels: channelsQuery.data ?? [],
relayAgents: relayAgentsQuery.data ?? [],
stopManagedAgent: stopMutation.mutateAsync,
}),
});
if (a.backend.type === "local") {
clearActiveTurnsForAgentOnStop(a.pubkey);
}
},
);
}
@@ -11,7 +11,10 @@ import {
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "@/features/agents/lib/managedAgentControlActions";
import { useManagedAgentRuntimeAction } from "@/features/agents/managedAgentRuntimeHooks";
import {
clearActiveTurnsForAgentOnStop,
useManagedAgentRuntimeAction,
} from "@/features/agents/managedAgentRuntimeHooks";
import { managedAgentPairAction } from "@/features/agents/managedAgentRuntimeStatus";
import {
channelsQueryKey,
@@ -174,6 +177,9 @@ export function useMembersSidebarActions({
preferredChannelId: channelId,
stopManagedAgent: stopManagedAgentMutation.mutateAsync,
});
if (agent.backend.type === "local") {
clearActiveTurnsForAgentOnStop(agent.pubkey);
}
setActionNoticeMessage(
agent.backend.type === "provider"
? `Shutdown command sent to ${agent.name}.`
@@ -203,6 +209,7 @@ export function useMembersSidebarActions({
agent,
startManagedAgent: startManagedAgentMutation.mutateAsync,
stopManagedAgent: stopManagedAgentMutation.mutateAsync,
onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey),
});
return undefined;
},
@@ -216,13 +223,18 @@ export function useMembersSidebarActions({
async function handleStopAll() {
await runBulkAgentAction({
action: (agent) =>
stopManagedAgentWithRules({
action: async (agent) => {
const result = await stopManagedAgentWithRules({
agent,
...EMPTY_AGENT_CONTEXT,
preferredChannelId: channelId,
stopManagedAgent: stopManagedAgentMutation.mutateAsync,
}),
});
if (agent.backend.type === "local") {
clearActiveTurnsForAgentOnStop(agent.pubkey);
}
return result;
},
actionKey: "bulk-stop",
agents: stoppableManagedBots,
failureMessage: "Failed to stop agent.",
@@ -6,6 +6,7 @@ import {
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
import { useCommunities } from "@/features/communities/useCommunities";
import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding";
import { resolveAgentReadiness } from "@/features/onboarding/ui/agentReadiness";
@@ -450,12 +451,14 @@ export async function restartWelcomeTeammate(
options: {
stopAgent?: typeof stopManagedAgent;
startAgent?: typeof startManagedAgent;
onStopped?: () => void;
} = {},
) {
const stopAgent = options.stopAgent ?? stopManagedAgent;
const startAgent = options.startAgent ?? startManagedAgent;
if (agent.status === "running") {
await stopAgent(agent.pubkey);
options.onStopped?.();
}
return startAgent(agent.pubkey);
}
@@ -609,7 +612,9 @@ export function useWelcomeKickoff(
isTeammate &&
welcomeTeammateNeedsRestart(agent, resolvedAgentSet.lead.pubkey)
) {
return restartWelcomeTeammate(agent);
return restartWelcomeTeammate(agent, {
onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey),
});
}
return agent.status === "running" || agent.status === "deployed"
? Promise.resolve(agent)
@@ -7,6 +7,7 @@ import {
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "@/features/agents/lib/managedAgentControlActions";
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types";
export function useAgentLifecycleActions({
@@ -33,6 +34,9 @@ export function useAgentLifecycleActions({
relayAgents: relayAgents ?? [],
stopManagedAgent,
});
if (managedAgent.backend.type === "local") {
clearActiveTurnsForAgentOnStop(managedAgent.pubkey);
}
toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`);
return;
}
@@ -67,6 +71,7 @@ export function useAgentLifecycleActions({
agent: managedAgent,
startManagedAgent,
stopManagedAgent,
onStopped: () => clearActiveTurnsForAgentOnStop(managedAgent.pubkey),
});
toast.success(`Restarted ${managedAgent.name}.`);
} catch (error) {