feat(desktop): auto-restart agents on config change (Chunk F) (#1649)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-08 12:09:41 -07:00
committed by GitHub
co-authored by Brain
parent 2cc0eb5396
commit a415f0f310
26 changed files with 521 additions and 0 deletions
@@ -502,6 +502,7 @@ mod tests {
mcp_toolsets: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: BackendKind::Local,
backend_agent_id: None,
@@ -54,3 +54,44 @@ pub async fn set_managed_agent_start_on_app_launch(
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub fn set_managed_agent_auto_restart(
pubkey: String,
auto_restart_on_config_change: bool,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ManagedAgentSummary, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_session_cache(pubkey);
}
{
let record = find_managed_agent_mut(&mut records, &pubkey)?;
record.auto_restart_on_config_change = auto_restart_on_config_change;
record.updated_at = now_iso();
}
save_managed_agents(&app, &records)?;
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
let personas = load_personas(&app).unwrap_or_default();
build_managed_agent_summary(&app, record, &runtimes, &personas)
}
+1
View File
@@ -823,6 +823,7 @@ pub async fn create_managed_agent(
} else {
input.start_on_app_launch
},
auto_restart_on_config_change: true,
runtime_pid: None,
backend: input.backend.clone(),
backend_agent_id: None,
@@ -144,6 +144,7 @@ fn local_agent() -> ManagedAgentRecord {
mcp_toolsets: Some("local".to_string()),
env_vars: BTreeMap::from([("API_KEY".to_string(), "localsecret".to_string())]),
start_on_app_launch: true,
auto_restart_on_config_change: true,
runtime_pid: Some(1234),
backend: crate::managed_agents::BackendKind::Provider {
id: "buzz-backend".to_string(),
+1
View File
@@ -531,6 +531,7 @@ pub fn run() {
start_managed_agent,
stop_managed_agent,
set_managed_agent_start_on_app_launch,
set_managed_agent_auto_restart,
delete_managed_agent,
get_managed_agent_log,
get_agent_models,
@@ -159,6 +159,7 @@ mod tests {
mcp_toolsets: Some("default".to_string()),
env_vars: BTreeMap::from([("OPENAI_API_KEY".to_string(), "sk-secret".to_string())]),
start_on_app_launch: true,
auto_restart_on_config_change: true,
runtime_pid: Some(4242),
backend: super::super::BackendKind::Provider {
id: "buzz-backend-x".to_string(),
@@ -61,6 +61,7 @@ fn test_record() -> ManagedAgentRecord {
mcp_toolsets: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: crate::managed_agents::types::BackendKind::Local,
backend_agent_id: None,
@@ -246,6 +246,7 @@ fn record_with(
persona_source_version: None,
mcp_toolsets: None,
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
@@ -454,6 +454,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
persona_source_version: None,
mcp_toolsets: None,
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: BackendKind::default(),
backend_agent_id: None,
@@ -976,6 +976,7 @@ mod tests {
mcp_toolsets: None,
env_vars,
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
@@ -1410,6 +1410,7 @@ pub fn build_managed_agent_summary(
last_exit_code: record.last_exit_code,
last_error: record.last_error.clone(),
start_on_app_launch: record.start_on_app_launch,
auto_restart_on_config_change: record.auto_restart_on_config_change,
log_path,
respond_to: record.respond_to,
respond_to_allowlist: record.respond_to_allowlist.clone(),
@@ -146,6 +146,7 @@ fn fixture(
mcp_toolsets: None,
env_vars: std::collections::BTreeMap::new(),
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
@@ -27,6 +27,7 @@ fn record() -> ManagedAgentRecord {
mcp_toolsets: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
@@ -294,6 +294,7 @@ mod tests {
persona_source_version: None,
mcp_toolsets: None,
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
@@ -97,6 +97,7 @@ impl PersonaRecord {
mcp_toolsets: None,
env_vars: self.env_vars,
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: BackendKind::default(),
backend_agent_id: None,
@@ -255,6 +256,11 @@ pub struct ManagedAgentRecord {
pub env_vars: BTreeMap<String, String>,
#[serde(default = "default_start_on_app_launch")]
pub start_on_app_launch: bool,
/// Auto-restart this agent when its effective spawn config drifts from
/// the running process (Chunk F). Default ON; the policy loop in the
/// frontend only fires when the agent is idle, connected, and local.
#[serde(default = "default_auto_restart_on_config_change")]
pub auto_restart_on_config_change: bool,
#[serde(default)]
pub runtime_pid: Option<u32>,
#[serde(default)]
@@ -435,6 +441,7 @@ pub struct ManagedAgentSummary {
pub last_exit_code: Option<i32>,
pub last_error: Option<String>,
pub start_on_app_launch: bool,
pub auto_restart_on_config_change: bool,
pub log_path: String,
pub respond_to: RespondTo,
pub respond_to_allowlist: Vec<String>,
@@ -776,6 +783,10 @@ fn default_start_on_app_launch() -> bool {
true
}
fn default_auto_restart_on_config_change() -> bool {
true
}
fn default_record_active() -> bool {
true
}
+3
View File
@@ -38,6 +38,7 @@ import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh";
import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy";
import { usePersonaSync } from "@/features/agents/lib/usePersonaSync";
import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion";
import {
@@ -151,6 +152,8 @@ export function AppShell() {
);
usePersonaSync(identityQuery.data?.pubkey);
useAgentsDataRefresh();
// Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON).
useAutoRestartPolicy();
// Owner-global observer ingestion: receives + decrypts agent observer
// frames and keeps derived active-turn liveness in sync app-wide, so no
// individual screen/panel has to mount its own bridge for ingestion.
+18
View File
@@ -24,6 +24,7 @@ import {
updateManagedAgent,
} from "@/shared/api/tauri";
import {
setManagedAgentAutoRestart,
setManagedAgentStartOnAppLaunch,
startManagedAgent,
stopManagedAgent,
@@ -437,6 +438,23 @@ export function useStopManagedAgentMutation() {
});
}
export function useSetManagedAgentAutoRestartMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
pubkey,
autoRestartOnConfigChange,
}: {
pubkey: string;
autoRestartOnConfigChange: boolean;
}) => setManagedAgentAutoRestart(pubkey, autoRestartOnConfigChange),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
},
});
}
export function useSetManagedAgentStartOnAppLaunchMutation() {
const queryClient = useQueryClient();
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AUTO_RESTART_QUIESCENCE_MS,
decideAutoRestart,
nextEdgeState,
} from "./autoRestartPolicy.ts";
// ── Chunk F policy matrix ────────────────────────────────────────────────────
//
// SAFETY-CRITICAL: stop is SIGTERM → ≤1s → SIGKILL with no in-process drain,
// so a wrong "fire" here kills a mid-turn agent. The never-fire rows below
// are exhaustive over every gate; each row flips exactly one input away from
// the all-green baseline to prove that gate alone holds the line.
/** All-green inputs: every gate open, window satisfied — the ONLY fire case. */
function greenInputs(overrides = {}) {
return {
autoRestartEnabled: true,
needsRestart: true,
working: false,
workingSource: "none",
connected: true,
isLocalBackend: true,
isRunning: true,
edgeConsumed: false,
quiescentForMs: AUTO_RESTART_QUIESCENCE_MS,
...overrides,
};
}
test("fires only when every gate is green and the window has elapsed", () => {
assert.equal(decideAutoRestart(greenInputs()), "fire");
});
// ── never-fire rows: one gate red at a time ─────────────────────────────────
const NEVER_FIRE_ROWS = [
["opt-out toggle off", { autoRestartEnabled: false }],
["no config drift", { needsRestart: false }],
[
"agent mid-turn (working, observer)",
{ working: true, workingSource: "observer" },
],
[
"typing source counts as working",
{ working: true, workingSource: "typing" },
],
["working flag alone defers (defensive)", { working: true }],
[
"source alone defers even if working flag lies (defensive)",
{ workingSource: "observer" },
],
["typing source alone defers", { workingSource: "typing" }],
["observer relay not connected", { connected: false }],
["remote backend", { isLocalBackend: false }],
["agent not running", { isRunning: false }],
[
"edge already consumed (one attempt per rising edge)",
{ edgeConsumed: true },
],
];
for (const [label, overrides] of NEVER_FIRE_ROWS) {
test(`never fires: ${label}`, () => {
assert.equal(
decideAutoRestart(greenInputs(overrides)),
"hold",
`${label} must hold — a fire here is a kill`,
);
});
}
// ── the quiescence window ───────────────────────────────────────────────────
test("arms (does not fire) before the window elapses", () => {
assert.equal(decideAutoRestart(greenInputs({ quiescentForMs: 0 })), "arm");
assert.equal(
decideAutoRestart(
greenInputs({ quiescentForMs: AUTO_RESTART_QUIESCENCE_MS - 1 }),
),
"arm",
);
});
test("window is minutes-scale — far beyond the 25s turn-store prune", () => {
// A relay hiccup makes a mid-turn agent look idle after 25s; the window
// must dwarf that so the flicker resets it long before firing.
assert.ok(AUTO_RESTART_QUIESCENCE_MS >= 2 * 60 * 1000);
});
// ── edge-trigger state machine ───────────────────────────────────────────────
test("falling needsRestart edge re-arms a consumed edge", () => {
const consumed = { consumed: true, armedAt: null };
const next = nextEdgeState(consumed, {
needsRestart: false,
isRunning: true,
});
assert.deepEqual(next, { consumed: false, armedAt: null });
});
test("agent stop re-arms a consumed edge (manual stop/start cycle can auto-fire again)", () => {
const consumed = { consumed: true, armedAt: null };
const next = nextEdgeState(consumed, {
needsRestart: true,
isRunning: false,
});
assert.deepEqual(next, { consumed: false, armedAt: null });
});
test("a held rising edge preserves consumed state (failed attempt badges only)", () => {
const consumed = { consumed: true, armedAt: null };
const next = nextEdgeState(consumed, { needsRestart: true, isRunning: true });
assert.equal(next.consumed, true, "no retry until the edge cycles");
});
test("undefined prior state initializes un-consumed and un-armed", () => {
assert.deepEqual(
nextEdgeState(undefined, { needsRestart: true, isRunning: true }),
{ consumed: false, armedAt: null },
);
});
@@ -0,0 +1,105 @@
import type { AgentWorkingSource } from "../agentWorkingSignal";
/**
* Chunk F auto-restart policy — the pure decision core.
*
* SAFETY-CRITICAL: `stop_managed_agent_process` is SIGTERM → ≤1s → SIGKILL
* with no in-process drain, so this predicate is the ONLY thing standing
* between the policy loop and killing a mid-turn agent. Every never-fire
* condition below is load-bearing; the test matrix enumerates them
* exhaustively.
*
* Decisions:
* - "fire": restart now (all gates green, continuity window satisfied).
* - "arm": conditions are green but the quiescence window is still
* accumulating — keep the timer running.
* - "hold": some gate is red — reset any accumulated quiescence and show
* the badge only.
*/
export type AutoRestartDecision = "fire" | "arm" | "hold";
export type AutoRestartInputs = {
/** Per-agent opt-out toggle (record field, default ON). */
autoRestartEnabled: boolean;
/** Config drift detected by the summary poll (`needsRestart`). */
needsRestart: boolean;
/** Unified working signal for this agent (any channel). */
working: boolean;
/** Strongest working-signal source; "none" is ambiguous (idle OR absent
* observer stream) and therefore never sufficient to fire on its own —
* the connected gate plus the continuity window carry that risk. */
workingSource: AgentWorkingSource;
/** Observer relay connection state; anything but "connected" inhibits. */
connected: boolean;
/** Only local agents can be restarted by this loop. */
isLocalBackend: boolean;
/** Agent process status from the summary ("running" required). */
isRunning: boolean;
/** Edge-trigger state: true when this needsRestart rising edge has
* already consumed its one attempt (failed or in flight). */
edgeConsumed: boolean;
/** Milliseconds the fire-conditions have held continuously. */
quiescentForMs: number;
};
/** Continuity window: fire-conditions must hold this long uninterrupted.
* 3 minutes = 18× the 10s turn-liveness cadence — comfortably beyond any
* relay hiccup that could make a mid-turn agent look idle (the turn store
* prunes after only 25s, which is why this window is minutes-scale). */
export const AUTO_RESTART_QUIESCENCE_MS = 3 * 60 * 1000;
export function decideAutoRestart(
inputs: AutoRestartInputs,
): AutoRestartDecision {
const {
autoRestartEnabled,
needsRestart,
working,
workingSource,
connected,
isLocalBackend,
isRunning,
edgeConsumed,
quiescentForMs,
} = inputs;
// Never-fire gates. Each resets the continuity window ("hold").
if (!autoRestartEnabled) return "hold";
if (!needsRestart) return "hold";
if (!isLocalBackend) return "hold";
if (!isRunning) return "hold";
if (!connected) return "hold";
// Any working signal — observer OR typing — defers. `working` and
// `workingSource` travel together, but check both so a partial reader
// can never slip through.
if (working || workingSource !== "none") return "hold";
// One attempt per rising edge: a consumed edge badges until it cycles.
if (edgeConsumed) return "hold";
return quiescentForMs >= AUTO_RESTART_QUIESCENCE_MS ? "fire" : "arm";
}
/**
* Per-agent edge-trigger state, keyed by pubkey in the policy hook.
*
* Rearm rule (Pinky's review row): the edge resets when `needsRestart`
* falls OR when the agent stops — a manual stop/start cycle re-arms the
* edge so a subsequently drifting agent auto-fires again.
*/
export type AutoRestartEdgeState = {
consumed: boolean;
/** Wall-clock ms when fire-conditions began holding; null = not armed. */
armedAt: number | null;
};
export function nextEdgeState(
previous: AutoRestartEdgeState | undefined,
inputs: { needsRestart: boolean; isRunning: boolean },
): AutoRestartEdgeState {
const prior = previous ?? { consumed: false, armedAt: null };
// Falling edge or a stopped agent re-arms.
if (!inputs.needsRestart || !inputs.isRunning) {
return { consumed: false, armedAt: null };
}
return prior;
}
@@ -0,0 +1,132 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
managedAgentsQueryKey,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import {
startManagedAgent,
stopManagedAgent,
} from "@/shared/api/tauriManagedAgents";
import { listManagedAgents } from "@/shared/api/tauri";
import type { ManagedAgent } from "@/shared/api/types";
import { getAgentObserverSnapshot } from "../observerRelayStore";
import { getAgentWorkingState } from "../agentWorkingSignal";
import {
decideAutoRestart,
nextEdgeState,
type AutoRestartEdgeState,
} from "./autoRestartPolicy";
/** How often the policy re-evaluates between summary refetches. Keeps the
* continuity clock honest without waiting for the next 5s poll. */
const POLICY_TICK_MS = 15_000;
/**
* Chunk F policy loop: watches managed-agent summaries and auto-restarts
* drifted, idle, connected, local agents (per-agent opt-out, default ON).
*
* All decision logic lives in `decideAutoRestart` (pure, exhaustively
* tested). This hook only wires inputs, owns per-pubkey edge state, and
* calls the existing stop/start commands — both idempotent and serialized
* on the backend store lock, so a cross-window double-fire is benign (and
* further shrunk by the pre-fire summary re-fetch).
*/
export function useAutoRestartPolicy() {
const queryClient = useQueryClient();
const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data;
const edgesRef = React.useRef(new Map<string, AutoRestartEdgeState>());
const inFlightRef = React.useRef(new Set<string>());
const [, setTick] = React.useState(0);
// Re-evaluate on an interval so the quiescence clock advances even when
// summaries and observer stores are quiet.
React.useEffect(() => {
const timer = setInterval(() => setTick((t) => t + 1), POLICY_TICK_MS);
return () => clearInterval(timer);
}, []);
// No dependency array by design: the tick pattern re-runs this effect
// every render so it reads live store state; all mutation is ref-local.
React.useEffect(() => {
if (!agents) return;
const now = Date.now();
const edges = edgesRef.current;
for (const agent of agents) {
const isRunning = agent.status === "running";
const edge = nextEdgeState(edges.get(agent.pubkey), {
needsRestart: agent.needsRestart,
isRunning,
});
const working = getAgentWorkingState(agent.pubkey);
const observer = getAgentObserverSnapshot(agent.pubkey, true);
const decision = decideAutoRestart({
autoRestartEnabled: agent.autoRestartOnConfigChange,
needsRestart: agent.needsRestart,
working: working.working,
workingSource: working.source,
connected: observer.connectionState === "open",
isLocalBackend: agent.backend.type === "local",
isRunning,
edgeConsumed: edge.consumed,
quiescentForMs: edge.armedAt === null ? 0 : now - edge.armedAt,
});
if (decision === "hold") {
edges.set(agent.pubkey, { ...edge, armedAt: null });
continue;
}
if (decision === "arm") {
edges.set(agent.pubkey, {
...edge,
armedAt: edge.armedAt ?? now,
});
continue;
}
// decision === "fire"
if (inFlightRef.current.has(agent.pubkey)) continue;
inFlightRef.current.add(agent.pubkey);
// Consume the edge BEFORE the attempt: a failed restart badges only
// until needsRestart cycles (edge-triggered debounce, no retry loops).
edges.set(agent.pubkey, { consumed: true, armedAt: null });
void (async () => {
try {
// Pre-fire re-fetch: shrink the stale-decision window to ~0.
const fresh = await listManagedAgents();
const current = fresh.find((a) => a.pubkey === agent.pubkey);
if (
!current ||
!current.needsRestart ||
!current.autoRestartOnConfigChange ||
current.status !== "running" ||
getAgentWorkingState(agent.pubkey).source !== "none"
) {
return;
}
await stopManagedAgent(agent.pubkey);
await startManagedAgent(agent.pubkey);
} catch {
// Failed attempt: edge stays consumed — badge-only until the
// needsRestart edge cycles. No retry loops by design.
} finally {
inFlightRef.current.delete(agent.pubkey);
void queryClient.invalidateQueries({
queryKey: managedAgentsQueryKey,
});
}
})();
}
// Drop edge state for agents that no longer exist.
const known = new Set(agents.map((a) => a.pubkey));
for (const pubkey of edges.keys()) {
if (!known.has(pubkey)) edges.delete(pubkey);
}
});
}
@@ -18,6 +18,7 @@ import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
import {
AUTO_MODEL_DROPDOWN_VALUE,
@@ -107,6 +108,8 @@ export function AgentInstanceEditDialog({
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] =
React.useState(agent.autoRestartOnConfigChange);
const personasQuery = usePersonasQuery();
const linkedPersona = React.useMemo(
() =>
@@ -157,6 +160,7 @@ export function AgentInstanceEditDialog({
setProvider(agent.provider ?? "");
setIsCustomProviderEditing(false);
setEnvVars(agent.envVars);
setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange);
setRespondTo(agent.respondTo);
setRespondToAllowlist(agent.respondToAllowlist);
setAvatarUrl(agent.avatarUrl ?? "");
@@ -581,6 +585,14 @@ export function AgentInstanceEditDialog({
};
const result = await updateMutation.mutateAsync(input);
if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) {
// Standalone setter (mirrors start-on-app-launch) — not part of
// UpdateManagedAgentInput, so the frozen update shape stays frozen.
await setManagedAgentAutoRestart(
agent.pubkey,
autoRestartOnConfigChange,
);
}
if (result.profileSyncError) {
console.warn("Relay profile sync failed:", result.profileSyncError);
}
@@ -896,6 +908,7 @@ export function AgentInstanceEditDialog({
acpCommand={acpCommand}
agentArgs={agentArgs}
agentCommand={agentCommand}
autoRestartOnConfigChange={autoRestartOnConfigChange}
disabled={updateMutation.isPending}
envVars={envVars}
fileSatisfiedEnvKeys={fileSatisfiedEnvKeys}
@@ -913,6 +926,7 @@ export function AgentInstanceEditDialog({
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAgentCommandChange={setAgentCommand}
onAutoRestartChange={setAutoRestartOnConfigChange}
onEnvVarsChange={setEnvVars}
onInheritHarnessChange={setInheritHarness}
onMcpCommandChange={setMcpCommand}
@@ -13,6 +13,7 @@ export function EditAgentAdvancedFields({
acpCommand,
agentArgs,
agentCommand,
autoRestartOnConfigChange,
disabled,
envVars,
fileSatisfiedEnvKeys,
@@ -36,12 +37,14 @@ export function EditAgentAdvancedFields({
onMcpToolsetsChange,
onParallelismChange,
onRelayUrlChange,
onAutoRestartChange,
onSystemPromptChange,
onTurnTimeoutChange,
}: {
acpCommand: string;
agentArgs: string;
agentCommand: string;
autoRestartOnConfigChange: boolean;
disabled: boolean;
envVars: EnvVarsValue;
fileSatisfiedEnvKeys: readonly string[];
@@ -65,6 +68,7 @@ export function EditAgentAdvancedFields({
onMcpToolsetsChange: (value: string) => void;
onParallelismChange: (value: string) => void;
onRelayUrlChange: (value: string) => void;
onAutoRestartChange: (value: boolean) => void;
onSystemPromptChange: (value: string) => void;
onTurnTimeoutChange: (value: string) => void;
}) {
@@ -95,6 +99,27 @@ export function EditAgentAdvancedFields({
</div>
) : null}
{/* Auto-restart on config change (Chunk F) */}
<div className="space-y-1.5">
<label
className="flex items-center gap-2 text-sm font-medium"
htmlFor="edit-agent-auto-restart"
>
<input
checked={autoRestartOnConfigChange}
id="edit-agent-auto-restart"
onChange={(event) => onAutoRestartChange(event.target.checked)}
type="checkbox"
/>
Auto-restart on config change
</label>
<p className="text-xs text-muted-foreground">
{autoRestartOnConfigChange
? "Restarts this agent automatically when its configuration changes, once it is idle and connected."
: "Configuration changes only show the restart badge; restart manually to apply them."}
</p>
</div>
{/* Custom agent command (when custom runtime) */}
{selectedRuntimeId === "custom" && !inheritHarness ? (
<div className="space-y-1.5">
+2
View File
@@ -228,6 +228,7 @@ export type RawManagedAgent = {
last_error: string | null;
log_path: string;
start_on_app_launch: boolean;
auto_restart_on_config_change?: boolean;
backend: ManagedAgentBackend;
backend_agent_id: string | null;
// Optional: pre-feature mock fixtures may omit these. Mapped to
@@ -1005,6 +1006,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
lastError: agent.last_error,
logPath: agent.log_path,
startOnAppLaunch: agent.start_on_app_launch,
autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true,
backend: agent.backend,
backendAgentId: agent.backend_agent_id,
// Fallbacks for pre-feature mocks/fixtures that don't carry these fields.
@@ -32,3 +32,17 @@ export async function setManagedAgentStartOnAppLaunch(
);
return fromRawManagedAgent(response);
}
export async function setManagedAgentAutoRestart(
pubkey: string,
autoRestartOnConfigChange: boolean,
): Promise<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>(
"set_managed_agent_auto_restart",
{
pubkey,
autoRestartOnConfigChange,
},
);
return fromRawManagedAgent(response);
}
+1
View File
@@ -418,6 +418,7 @@ export type ManagedAgent = {
lastError: string | null;
logPath: string;
startOnAppLaunch: boolean;
autoRestartOnConfigChange: boolean;
backend: ManagedAgentBackend;
backendAgentId: string | null;
/** Who the agent should respond to. Maps to `buzz-acp --respond-to`. */
+18
View File
@@ -462,6 +462,7 @@ type RawManagedAgent = {
last_error: string | null;
log_path: string;
start_on_app_launch: boolean;
auto_restart_on_config_change?: boolean;
backend:
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
@@ -1043,6 +1044,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
last_error: agent.last_error,
log_path: agent.log_path,
start_on_app_launch: agent.start_on_app_launch,
auto_restart_on_config_change: agent.auto_restart_on_config_change ?? true,
backend: agent.backend ?? { type: "local" as const },
backend_agent_id: agent.backend_agent_id ?? null,
respond_to: agent.respond_to ?? "owner-only",
@@ -1557,6 +1559,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
last_error: seed.lastError ?? null,
log_path: `/tmp/mock-agent-${seed.pubkey}.log`,
start_on_app_launch: true,
auto_restart_on_config_change: true,
backend: seed.backend ?? { type: "local" },
backend_agent_id: null,
respond_to: seed.respondTo ?? "owner-only",
@@ -6627,6 +6630,7 @@ async function handleCreateManagedAgent(
last_error: null,
log_path: `/tmp/mock-agent-${pubkey}.log`,
start_on_app_launch: args.input.startOnAppLaunch ?? true,
auto_restart_on_config_change: true,
backend: args.input.backend ?? { type: "local" as const },
backend_agent_id: null,
respond_to: args.input.respondTo ?? "owner-only",
@@ -6776,6 +6780,16 @@ async function handleSetManagedAgentStartOnAppLaunch(args: {
return cloneManagedAgent(agent);
}
async function handleSetManagedAgentAutoRestart(args: {
pubkey: string;
autoRestartOnConfigChange: boolean;
}): Promise<RawManagedAgent> {
const agent = getMockManagedAgent(args.pubkey);
agent.auto_restart_on_config_change = args.autoRestartOnConfigChange;
agent.updated_at = new Date().toISOString();
return cloneManagedAgent(agent);
}
async function handleGetManagedAgentLog(args: {
pubkey: string;
lineCount?: number;
@@ -8487,6 +8501,10 @@ export function maybeInstallE2eTauriMocks() {
return handleStopManagedAgent(
payload as Parameters<typeof handleStopManagedAgent>[0],
);
case "set_managed_agent_auto_restart":
return handleSetManagedAgentAutoRestart(
payload as Parameters<typeof handleSetManagedAgentAutoRestart>[0],
);
case "set_managed_agent_start_on_app_launch":
return handleSetManagedAgentStartOnAppLaunch(
payload as Parameters<