diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index c0e0b5c01d5..f182b7b5f78 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -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(); + }); +}); diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 07ad4fa6b8d..b281929503b 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -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 diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index f2c42b94e12..e6926b36d2e 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index cdde263004e..dbaaaba8035 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -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); diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts index 7e309309003..e8e149ccd1f 100644 --- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts @@ -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 diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs new file mode 100644 index 00000000000..3e961b58544 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index aef9c8c1e8f..96a3abc78d4 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -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, + clear: (pubkey: string, relayUrl: string) => void, + start: ( + pubkey: string, + relayUrl: string, + ) => Promise, +): Promise { + 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( managedAgentRuntimesQueryKey, (current = []) => { diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 4eb9b6ed039..e1c2e9c9fc1 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -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); + } + }, ); } diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index 2586f792181..cc8f4062210 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -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.", diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index fd5dac3947e..57352b5294c 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -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) diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index f84e149c111..62d0d3c7ad6 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -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) {