mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(settings): confirm agent restart when toggling spawn-env experiments
The acpToolSummaries experiment is enforced at agent spawn time (BUZZ_AGENT_NO_TOOL_SUMMARY in the child env), so flipping the toggle repaints the UI but leaves running agents on their spawn-time behavior — the 'enabled but still raw labels' confusion. Toggling such an experiment now opens a shared AlertDialog. Confirm applies the toggle, awaits an explicit set_desktop_experiments mirror write (so respawns read the NEW env, not a racy passive-effect write), then restarts the local agents that were running at confirmation time. Cancel leaves the setting unchanged; stopped agents stay stopped. Partial restart failures surface a failed-agent toast without rolling the experiment back; a failed mirror write aborts the restart but keeps the toggle applied (best-effort mirror convention). Orchestration lives in a pure lib module with node tests covering toggle→mirror→restart ordering, mirror-failure abort, local-running-only selection, partial-failure collection, and outcome messaging. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
co-authored by
Taylor Ho
parent
d4b625037e
commit
de96c07acd
@@ -0,0 +1,149 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyExperimentAndRestartAgents,
|
||||
describeRestartOutcome,
|
||||
experimentRequiresAgentRestart,
|
||||
restartAgentsForExperiment,
|
||||
selectAgentsToRestart,
|
||||
} from "./experimentAgentRestart.ts";
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Agent",
|
||||
status: "running",
|
||||
backend: { type: "local" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("only spawn-env experiments require a restart confirmation", () => {
|
||||
assert.equal(experimentRequiresAgentRestart("acpToolSummaries"), true);
|
||||
assert.equal(experimentRequiresAgentRestart("pulse"), false);
|
||||
});
|
||||
|
||||
test("selectAgentsToRestart picks local running agents only", () => {
|
||||
const running = agent({ pubkey: "a".repeat(64), name: "Running" });
|
||||
const stopped = agent({
|
||||
pubkey: "b".repeat(64),
|
||||
name: "Stopped",
|
||||
status: "stopped",
|
||||
});
|
||||
const deployed = agent({
|
||||
pubkey: "c".repeat(64),
|
||||
name: "Deployed",
|
||||
status: "deployed",
|
||||
backend: { type: "relay-mesh" },
|
||||
});
|
||||
const remoteRunning = agent({
|
||||
pubkey: "d".repeat(64),
|
||||
name: "Remote",
|
||||
backend: { type: "relay-mesh" },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
selectAgentsToRestart([running, stopped, deployed, remoteRunning]),
|
||||
[running],
|
||||
);
|
||||
});
|
||||
|
||||
test("confirm restarts the snapshot after toggle + mirror, in order", async () => {
|
||||
const calls = [];
|
||||
const agents = [
|
||||
agent({ pubkey: "a".repeat(64), name: "One" }),
|
||||
agent({ pubkey: "b".repeat(64), name: "Two" }),
|
||||
];
|
||||
|
||||
const outcome = await applyExperimentAndRestartAgents({
|
||||
applyToggle: () => calls.push("toggle"),
|
||||
mirrorExperiments: async () => calls.push("mirror"),
|
||||
agents,
|
||||
startAgent: async (pubkey) => calls.push(`start:${pubkey.slice(0, 1)}`),
|
||||
stopAgent: async (pubkey) => calls.push(`stop:${pubkey.slice(0, 1)}`),
|
||||
});
|
||||
|
||||
// Toggle then mirror strictly precede any restart traffic — restarted
|
||||
// agents must spawn against the NEW mirrored env.
|
||||
assert.deepEqual(calls.slice(0, 2), ["toggle", "mirror"]);
|
||||
const restartCalls = calls.slice(2);
|
||||
assert.equal(restartCalls.filter((c) => c.startsWith("stop:")).length, 2);
|
||||
assert.equal(restartCalls.filter((c) => c.startsWith("start:")).length, 2);
|
||||
// Each agent stops before it starts.
|
||||
assert.ok(restartCalls.indexOf("stop:a") < restartCalls.indexOf("start:a"));
|
||||
assert.ok(restartCalls.indexOf("stop:b") < restartCalls.indexOf("start:b"));
|
||||
assert.deepEqual(outcome, { restarted: 2, failures: [] });
|
||||
});
|
||||
|
||||
test("a failed mirror write aborts the restart but keeps the toggle applied", async () => {
|
||||
const calls = [];
|
||||
|
||||
await assert.rejects(
|
||||
applyExperimentAndRestartAgents({
|
||||
applyToggle: () => calls.push("toggle"),
|
||||
mirrorExperiments: async () => {
|
||||
throw new Error("ipc down");
|
||||
},
|
||||
agents: [agent()],
|
||||
startAgent: async () => calls.push("start"),
|
||||
stopAgent: async () => calls.push("stop"),
|
||||
}),
|
||||
/ipc down/,
|
||||
);
|
||||
|
||||
// Toggle applied (no rollback), zero restart traffic.
|
||||
assert.deepEqual(calls, ["toggle"]);
|
||||
});
|
||||
|
||||
test("cancel path: no orchestration call means no toggle, no restarts", () => {
|
||||
// The dialog's cancel/dismiss handler only clears local pending state and
|
||||
// never invokes applyExperimentAndRestartAgents — modeled here as: with no
|
||||
// running agents selected, there is nothing to restart and outcome is empty.
|
||||
assert.deepEqual(selectAgentsToRestart([]), []);
|
||||
});
|
||||
|
||||
test("partial failure: other agents still restart, failures are collected", async () => {
|
||||
const one = agent({ pubkey: "a".repeat(64), name: "One" });
|
||||
const two = agent({ pubkey: "b".repeat(64), name: "Two" });
|
||||
const three = agent({ pubkey: "c".repeat(64), name: "Three" });
|
||||
|
||||
const outcome = await restartAgentsForExperiment({
|
||||
agents: [one, two, three],
|
||||
stopAgent: async (pubkey) => {
|
||||
if (pubkey === two.pubkey) throw new Error("stop failed");
|
||||
},
|
||||
startAgent: async (pubkey) => {
|
||||
if (pubkey === three.pubkey) throw new Error("spawn failed");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(outcome.restarted, 1);
|
||||
assert.deepEqual(outcome.failures, [
|
||||
{ name: "Two", error: "stop failed" },
|
||||
{ name: "Three", error: "spawn failed" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("describeRestartOutcome messaging covers success and partial failure", () => {
|
||||
assert.deepEqual(describeRestartOutcome({ restarted: 1, failures: [] }), {
|
||||
kind: "success",
|
||||
message: "Restarted 1 agent.",
|
||||
});
|
||||
assert.deepEqual(describeRestartOutcome({ restarted: 3, failures: [] }), {
|
||||
kind: "success",
|
||||
message: "Restarted 3 agents.",
|
||||
});
|
||||
|
||||
const partial = describeRestartOutcome({
|
||||
restarted: 1,
|
||||
failures: [
|
||||
{ name: "Two", error: "stop failed" },
|
||||
{ name: "Three", error: "spawn failed" },
|
||||
],
|
||||
});
|
||||
assert.equal(partial.kind, "error");
|
||||
assert.match(partial.message, /Restarted 1 of 3 agents/);
|
||||
assert.match(partial.message, /Two, Three/);
|
||||
assert.match(partial.message, /still applied/);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Restart plumbing for preview experiments whose effect is pinned at agent
|
||||
* spawn time (env vars set in `spawn_agent_child`). Toggling such an
|
||||
* experiment repaints the UI immediately, but running agents keep their
|
||||
* spawn-time env — so the settings card confirms with the user and restarts
|
||||
* running agents after the toggle is applied.
|
||||
*
|
||||
* Pure logic lives here so node tests can cover cancel/confirm/partial-
|
||||
* failure without a DOM.
|
||||
*/
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
* Preview experiments that gate agent behavior via spawn-time env.
|
||||
* Toggling these prompts for an agent restart. Keep in sync with the
|
||||
* spawn gates in `desktop/src-tauri/src/managed_agents/runtime.rs`.
|
||||
*/
|
||||
const EXPERIMENTS_REQUIRING_AGENT_RESTART: ReadonlySet<string> = new Set([
|
||||
"acpToolSummaries",
|
||||
]);
|
||||
|
||||
export function experimentRequiresAgentRestart(featureId: string): boolean {
|
||||
return EXPERIMENTS_REQUIRING_AGENT_RESTART.has(featureId);
|
||||
}
|
||||
|
||||
type RestartCandidate = Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
|
||||
backend: { type: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Only locally spawned, currently running agents carry the spawn-time env
|
||||
* this restart exists to refresh. Stopped agents stay stopped; provider
|
||||
* deployments are not spawned through the local env path.
|
||||
*/
|
||||
export function selectAgentsToRestart<T extends RestartCandidate>(
|
||||
agents: readonly T[],
|
||||
): T[] {
|
||||
return agents.filter(
|
||||
(agent) => agent.backend.type === "local" && agent.status === "running",
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentRestartOutcome = {
|
||||
restarted: number;
|
||||
failures: { name: string; error: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm-time orchestration, in the order that matters:
|
||||
*
|
||||
* 1. `applyToggle()` — flip the localStorage override (UI updates now).
|
||||
* 2. `await mirrorExperiments()` — push the override to the Rust side
|
||||
* BEFORE any agent respawns, otherwise a restarted agent could read
|
||||
* the stale mirror and spawn with the old env (the exact confusion
|
||||
* this modal exists to fix). The passive `useDesktopExperimentsMirror`
|
||||
* effect also fires, but it's async and unordered — hence the
|
||||
* explicit await here.
|
||||
* 3. Restart the agents snapshotted at confirmation time.
|
||||
*
|
||||
* A failed mirror write aborts the restart (agents would respawn with the
|
||||
* old env anyway) but does NOT roll back the toggle — matching the
|
||||
* best-effort mirror semantics elsewhere; the mirror retries on next boot.
|
||||
*/
|
||||
export async function applyExperimentAndRestartAgents({
|
||||
applyToggle,
|
||||
mirrorExperiments,
|
||||
agents,
|
||||
startAgent,
|
||||
stopAgent,
|
||||
}: {
|
||||
applyToggle: () => void;
|
||||
mirrorExperiments: () => Promise<void>;
|
||||
agents: readonly RestartCandidate[];
|
||||
startAgent: (pubkey: string) => Promise<unknown>;
|
||||
stopAgent: (pubkey: string) => Promise<unknown>;
|
||||
}): Promise<AgentRestartOutcome> {
|
||||
applyToggle();
|
||||
await mirrorExperiments();
|
||||
return restartAgentsForExperiment({ agents, startAgent, stopAgent });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop→start each agent (mirrors `respawnManagedAgentWithRules` semantics).
|
||||
* One agent's failure never blocks the others; failures are collected for
|
||||
* the caller's messaging. The experiment toggle is NOT rolled back on
|
||||
* failure — the setting applied, only the process refresh lagged.
|
||||
*/
|
||||
export async function restartAgentsForExperiment({
|
||||
agents,
|
||||
startAgent,
|
||||
stopAgent,
|
||||
}: {
|
||||
agents: readonly RestartCandidate[];
|
||||
startAgent: (pubkey: string) => Promise<unknown>;
|
||||
stopAgent: (pubkey: string) => Promise<unknown>;
|
||||
}): Promise<AgentRestartOutcome> {
|
||||
const results = await Promise.allSettled(
|
||||
agents.map(async (agent) => {
|
||||
await stopAgent(agent.pubkey);
|
||||
await startAgent(agent.pubkey);
|
||||
}),
|
||||
);
|
||||
|
||||
const outcome: AgentRestartOutcome = { restarted: 0, failures: [] };
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
outcome.restarted += 1;
|
||||
return;
|
||||
}
|
||||
const reason = result.reason;
|
||||
outcome.failures.push({
|
||||
name: agents[index]?.name ?? "unknown agent",
|
||||
error: reason instanceof Error ? reason.message : String(reason),
|
||||
});
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Human-readable toast copy for a restart outcome. */
|
||||
export function describeRestartOutcome(outcome: AgentRestartOutcome): {
|
||||
kind: "success" | "error";
|
||||
message: string;
|
||||
} {
|
||||
const total = outcome.restarted + outcome.failures.length;
|
||||
if (outcome.failures.length === 0) {
|
||||
return {
|
||||
kind: "success",
|
||||
message:
|
||||
outcome.restarted === 1
|
||||
? "Restarted 1 agent."
|
||||
: `Restarted ${outcome.restarted} agents.`,
|
||||
};
|
||||
}
|
||||
const names = outcome.failures.map((failure) => failure.name).join(", ");
|
||||
return {
|
||||
kind: "error",
|
||||
message: `Restarted ${outcome.restarted} of ${total} agents. Failed to restart: ${names}. The experiment setting was still applied — restart these agents manually to pick it up.`,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,106 @@
|
||||
import { desktopFeatures, useFeatureToggle } from "@/shared/features";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
useStartManagedAgentMutation,
|
||||
useStopManagedAgentMutation,
|
||||
useManagedAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import {
|
||||
applyExperimentAndRestartAgents,
|
||||
describeRestartOutcome,
|
||||
experimentRequiresAgentRestart,
|
||||
selectAgentsToRestart,
|
||||
} from "@/features/settings/lib/experimentAgentRestart";
|
||||
import { setDesktopExperiments } from "@/shared/api/tauri";
|
||||
import {
|
||||
desktopFeatures,
|
||||
getOverrides,
|
||||
useFeatureToggle,
|
||||
} from "@/shared/features";
|
||||
import type { FeatureDefinition } from "@/shared/features";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
|
||||
function FeatureRow({ feature }: { feature: FeatureDefinition }) {
|
||||
const [enabled, toggle] = useFeatureToggle(feature.id);
|
||||
const [pendingValue, setPendingValue] = React.useState<boolean | null>(null);
|
||||
const requiresRestart = experimentRequiresAgentRestart(feature.id);
|
||||
// Only fetch agent state for rows that can trigger a restart.
|
||||
const agentsQuery = useManagedAgentsQuery({ enabled: requiresRestart });
|
||||
const startMutation = useStartManagedAgentMutation();
|
||||
const stopMutation = useStopManagedAgentMutation();
|
||||
const switchId = `feature-toggle-${feature.id}`;
|
||||
|
||||
const runningAgents = selectAgentsToRestart(agentsQuery.data ?? []);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
// Spawn-env experiments confirm first: the toggle is applied only on
|
||||
// Confirm, then agents running at confirmation time are restarted so
|
||||
// their spawn-time env picks up the change. No running agents → nothing
|
||||
// to restart, apply directly.
|
||||
if (requiresRestart && runningAgents.length > 0) {
|
||||
setPendingValue(value);
|
||||
return;
|
||||
}
|
||||
toggle(value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (pendingValue === null) {
|
||||
return;
|
||||
}
|
||||
const value = pendingValue;
|
||||
setPendingValue(null);
|
||||
// Snapshot at confirmation time: stopped agents stay stopped.
|
||||
const agentsToRestart = runningAgents;
|
||||
// Ordering matters: apply the toggle, await the explicit mirror write to
|
||||
// the Rust side, THEN restart — so respawned agents read the NEW env.
|
||||
// (The passive useDesktopExperimentsMirror effect also fires, but it is
|
||||
// unordered relative to the respawn; see applyExperimentAndRestartAgents.)
|
||||
void applyExperimentAndRestartAgents({
|
||||
applyToggle: () => toggle(value),
|
||||
mirrorExperiments: () => setDesktopExperiments(getOverrides()),
|
||||
agents: agentsToRestart,
|
||||
startAgent: (pubkey) => startMutation.mutateAsync(pubkey),
|
||||
stopAgent: (pubkey) => stopMutation.mutateAsync(pubkey),
|
||||
}).then(
|
||||
(outcome) => {
|
||||
const { kind, message } = describeRestartOutcome(outcome);
|
||||
if (kind === "success") {
|
||||
toast.success(message);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
// Mirror write failed: the toggle stayed applied (best-effort mirror
|
||||
// convention), but agents were NOT restarted — tell the user.
|
||||
toast.error(
|
||||
`Setting applied, but syncing it to the agent runtime failed (${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}). Agents were not restarted — restart them manually to pick it up.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const agentCountLabel =
|
||||
runningAgents.length === 1
|
||||
? "1 running agent"
|
||||
: `${runningAgents.length} running agents`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/70 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -19,8 +113,46 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) {
|
||||
aria-labelledby={`${switchId}-label`}
|
||||
checked={enabled}
|
||||
data-testid={switchId}
|
||||
onCheckedChange={toggle}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
<AlertDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Cancel / dismiss: leave the setting unchanged.
|
||||
setPendingValue(null);
|
||||
}
|
||||
}}
|
||||
open={pendingValue !== null}
|
||||
>
|
||||
<AlertDialogContent
|
||||
data-testid={`feature-restart-dialog-${feature.id}`}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingValue
|
||||
? `Enable ${feature.name}?`
|
||||
: `Disable ${feature.name}?`}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This setting takes effect when agents start. Applying it will
|
||||
restart {agentCountLabel} so they pick up the change; stopped
|
||||
agents stay stopped. Cancel to leave the setting unchanged.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={handleConfirm} type="button">
|
||||
Apply and restart agents
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user