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:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-07 14:26:03 -07:00
co-authored by Taylor Ho
parent d4b625037e
commit de96c07acd
3 changed files with 422 additions and 2 deletions
@@ -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>
);
}