mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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, ¤t_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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user