Files
buzz/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs
Will Pfleger a64cc71f6c fix(desktop): clear stale working badges on agent stop/restart (#2803)
When Desktop stops or restarts a managed-agent pair, the harness is
SIGKILLed after 1s and `turn_completed` never lands.
`activeAgentTurnsStore` then sees the "all turns silent at once" pattern
and delays badge cleanup for up to 3 min (the pause that protects live
badges during transient relay-stream gaps). This is unnecessary when
Desktop itself issued the kill — there is no relay-gap ambiguity.

## What changed

**`activeAgentTurnsStore.ts`** — new `clearActiveTurnsForAgent(pubkey)`
- Tombstones every live turn for the agent via `recordTerminal` (blocks
in-flight `turn_liveness` frames from resurrecting them via
`resurrectTurn`)
- Removes the agent's entry from `activeTurnsByAgent`
- Preserves `lastProcessed` (watermark) — full-buffer replay after the
clear is a no-op
- Preserves `clockOffsetByAgent` — still valid, harmless

**`managedAgentRuntimeHooks.ts`** — clearing at the successful-stop
boundary
- New `clearActiveTurnsForAgentOnStop(pubkey, relayUrl?)` — relay-scope
gate: only clears when the stopped pair's relay matches the active
community (pair-scoped), or when an active community is configured
(agent-wide ops)
- New `restartManagedAgentPair(pubkey, relayUrl, stop, clear, start)` —
dependency-injected stop → clear → start sequence; the `restart` branch
of `useManagedAgentRuntimeAction`'s `mutationFn` is a single call into
it. The clear fires after a successful stop and before start begins, so
the badge is gone even when start fails, a failed stop clears nothing,
and no clear can run after the new process is spawned (genuinely-new
turns are never wiped)
- `useManagedAgentRuntimeAction.onSuccess` clears for `stop` actions,
before the query-cache update

**`managedAgentControlActions.ts`** — `onStopped` callback on
`respawnManagedAgentWithRules`, invoked after the stop promise resolves
and before start begins

**`welcomeKickoff.ts`** — same `onStopped` boundary on
`restartWelcomeTeammate`

**Call sites covered (all stop/restart UI paths):**
- `useManagedAgentRuntimeAction` — pair-scoped stop (`onSuccess`) and
restart (`restartManagedAgentPair` in `mutationFn`); Members-sidebar +
settings card
- `useMembersSidebarActions.handleRespawnAll` — via `onStopped`
- `useMembersSidebarActions.handleStopAll` — direct local-stop branch
- `useMembersSidebarActions.handleLifecycleAction` — local-stop fallback
branch
- `useAgentLifecycleActions.handleAgentPrimaryAction` — Agents-tab stop
- `useAgentLifecycleActions.handleAgentRestart` — via `onStopped`
- `useManagedAgentActions.handleStop` / `handleBulkStopRunning` — Agents
screen
- `useAutoRestartPolicy` — inline, between stop and start
- `restartWelcomeTeammate` call site — via `onStopped`

Provider agents are excluded at each site: they go through `!shutdown`
(relay message), not a direct harness kill.

## Tests

Twelve behavior tests across three files:

- `activeAgentTurnsStore.test.mjs` (6) — clear removes the agent's turns
and notifies subscribers, other agents untouched; full-buffer replay
after clear is a no-op (watermark preserved); late `turn_liveness` frame
with timestamp ≤ clear time does not resurrect (tombstone); new
`turn_started` after clear is tracked normally; badge gone when stop
succeeds even if start fails; new frame during start-pending does not
resurrect the cleared badge
- `managedAgentControlActions.test.mjs` (3) — `onStopped` fires on
stop-success/start-failure; does not fire on stop-failure; strict stop →
`onStopped` → start ordering
- `managedAgentRuntimeHooks.test.mjs` (3) — pair-restart seam: clear ran
when start fails (rejection propagates); stop failure invokes neither
clear nor start; strict stop → clear → start ordering

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-25 12:02:39 -04:00

111 lines
3.1 KiB
JavaScript

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",
);
});