mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
5e3d2e4849
commit
a64cc71f6c
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user