Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export default defineConfig({
"**/identity-lost.spec.ts",
"**/global-agent-config-screenshots.spec.ts",
"**/doctor-states.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
10 changes: 9 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ const overrides = new Map([
// global-agent-config: resolve_deploy_model_provider + visibility exports
// add ~40 lines on top of the 1A.1 ratchet. Queued to split.
["src-tauri/src/commands/agents.rs", 1340],
// agent-lifecycle-fixes: cascade-delete in delete_persona restructured into
// 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
// retry-safety. Load-bearing reviewer-required change; queued to split.
// +23: collect_remote_deployed pre-flight guard (provider-deployed cascade
// targets refuse the delete before any destructive work).
["src-tauri/src/commands/personas/mod.rs", 1116],
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
// const + build_thread_replies_filter helper, mirroring the channel sibling so
// the two p-gate filters can't drift) plus two guard unit tests. The file was
Expand Down Expand Up @@ -189,7 +195,9 @@ const overrides = new Map([
// codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line).
// doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/
// loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature.
["src/shared/api/types.ts", 1016],
// agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with
// failed_restart_count (+2 lines). Queued to split with the rest of this list.
["src/shared/api/types.ts", 1030],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with
Expand Down
11 changes: 8 additions & 3 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ pub(super) fn retain_managed_agent_pending(
/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a
/// failure is logged and swallowed so a retention hiccup never blocks the
/// disk-authoritative delete.
fn tombstone_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) {
pub(super) fn tombstone_managed_agent_pending(
app: &AppHandle,
state: &AppState,
agent_pubkey: &str,
) {
use crate::managed_agents::{
agent_events::build_agent_delete,
retention::{
Expand Down Expand Up @@ -258,8 +262,10 @@ pub(super) async fn start_local_agent_with_preflight(
// runtime). This clears the "out of date" drift badge without requiring a
// delete+recreate. See `apply_persona_snapshot` for the precedence and
// env-override self-heal rules.
// Load personas once: used for snapshot application below and summary build
// at the end — avoids a second disk read for the same file in the same call.
let personas = load_personas(app).unwrap_or_default();
if let Some(persona_id) = record.persona_id.clone() {
let personas = load_personas(app).unwrap_or_default();
if let Some(persona) = personas.iter().find(|p| p.id == persona_id) {
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
record.updated_at = crate::util::now_iso();
Expand All @@ -274,7 +280,6 @@ pub(super) async fn start_local_agent_with_preflight(
.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)
}

Expand Down
317 changes: 255 additions & 62 deletions desktop/src-tauri/src/commands/global_agent_config.rs

Large diffs are not rendered by default.

203 changes: 203 additions & 0 deletions desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
//! Tests for cascade-delete filtering in `delete_persona`.
//!
//! `delete_persona` deletes all managed-agent records whose `persona_id`
//! matches the persona being deleted, mirroring the cleanup done by
//! `delete_managed_agent`. These tests verify the `collect_cascade_pubkeys`
//! helper that identifies the agents to cascade-delete, using plain
//! in-memory data structures (no `AppHandle` required).

use super::{collect_cascade_pubkeys, collect_remote_deployed, commit_cascade_agents};
use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo};
use std::collections::BTreeMap;
use std::collections::HashSet;

fn make_agent(
pubkey: &str,
persona_id: Option<&str>,
runtime_pid: Option<u32>,
) -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: pubkey.to_string(),
name: "Test Agent".to_string(),
persona_id: persona_id.map(str::to_string),
private_key_nsec: "".to_string(),
auth_tag: None,
relay_url: "ws://localhost:3000".to_string(),
avatar_url: None,
acp_command: "buzz-acp".to_string(),
agent_command: "buzz-agent".to_string(),
agent_command_override: None,
agent_args: vec![],
mcp_command: "".to_string(),
turn_timeout_seconds: 300,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 1,
system_prompt: None,
model: None,
provider: None,
persona_source_version: None,
mcp_toolsets: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
runtime_pid,
backend: BackendKind::Local,
backend_agent_id: None,
provider_binary_path: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
last_error_code: None,
respond_to: RespondTo::OwnerOnly,
respond_to_allowlist: vec![],
display_name: None,
slug: None,
runtime: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
relay_mesh: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
definition_mcp_toolsets: None,
definition_parallelism: None,
}
}

const PERSONA_ID: &str = "custom:test-persona";

/// Deleting a persona with two linked agents (one running) returns both their
/// pubkeys and leaves unlinked agents out of the cascade set.
#[test]
fn cascade_includes_linked_agents_and_excludes_others() {
let agents = vec![
make_agent("agent-a", Some(PERSONA_ID), Some(12345)), // running, linked
make_agent("agent-b", Some(PERSONA_ID), None), // stopped, linked
make_agent("agent-c", Some("custom:other"), None), // different persona
make_agent("agent-d", None, None), // no persona
];

let pubkeys = collect_cascade_pubkeys(&agents, PERSONA_ID);

assert_eq!(
pubkeys.len(),
2,
"exactly two agents linked to this persona"
);
assert!(
pubkeys.contains(&"agent-a".to_string()),
"running linked agent included"
);
assert!(
pubkeys.contains(&"agent-b".to_string()),
"stopped linked agent included"
);
assert!(
!pubkeys.contains(&"agent-c".to_string()),
"different-persona agent excluded"
);
assert!(
!pubkeys.contains(&"agent-d".to_string()),
"persona-less agent excluded"
);
}

/// Deleting a persona with no linked agents returns an empty list (no cascade).
#[test]
fn cascade_empty_when_no_linked_agents() {
let agents = vec![
make_agent("agent-x", Some("custom:other"), None),
make_agent("agent-y", None, None),
];

let pubkeys = collect_cascade_pubkeys(&agents, PERSONA_ID);

assert!(pubkeys.is_empty(), "no agents to cascade-delete");
}

/// Cascade targets all agents linked to the persona — not just stopped ones.
/// A running agent (runtime_pid set) must appear in the cascade set so the
/// command can stop it before removing the record.
#[test]
fn cascade_includes_running_agent() {
let agents = vec![make_agent("running-agent", Some(PERSONA_ID), Some(99999))];

let pubkeys = collect_cascade_pubkeys(&agents, PERSONA_ID);

assert_eq!(pubkeys, vec!["running-agent".to_string()]);
}

/// A failing agent-store save in Phase 3 must be retry-safe: the error
/// propagates before any keyring deletion or tombstone at the call site
/// (by construction — those side effects appear after the `?` in
/// `delete_persona`). Persona records and agent records are therefore
/// untouched on disk, so the command can be retried with no cleanup.
#[test]
fn failing_save_is_retry_safe() {
let mut agents = vec![
make_agent("pk-a", Some(PERSONA_ID), None),
make_agent("pk-b", Some(PERSONA_ID), None),
make_agent("pk-c", Some("custom:other"), None),
];
let cascade: HashSet<String> = ["pk-a".to_string(), "pk-b".to_string()].into();

let result = commit_cascade_agents(&mut agents, &cascade, |_| {
Err("simulated disk failure".to_string())
});

assert!(
result.is_err(),
"commit must propagate the save error so callers can react"
);
// By construction: commit_cascade_agents returns Err before reaching the
// keyring deletions and tombstones at the delete_persona call site.
// Retrying delete_persona re-runs the full cascade cleanly from scratch.
}

/// A provider-deployed cascade target (non-local backend with a live
/// `backend_agent_id`) must be detected by the pre-flight so `delete_persona`
/// refuses the cascade before any destructive work. Local agents and
/// never-deployed provider agents must not block.
#[test]
fn remote_deployed_cascade_target_blocks_delete() {
let mut deployed = make_agent("pk-deployed", Some(PERSONA_ID), None);
deployed.name = "Deployed Agent".to_string();
deployed.backend = BackendKind::Provider {
id: "blox".to_string(),
config: serde_json::Value::Null,
};
deployed.backend_agent_id = Some("backend-1".to_string());

// Provider backend but never deployed (no backend_agent_id) — not a blocker.
let mut undeployed = make_agent("pk-undeployed", Some(PERSONA_ID), None);
undeployed.backend = BackendKind::Provider {
id: "blox".to_string(),
config: serde_json::Value::Null,
};

let agents = vec![
make_agent("pk-local", Some(PERSONA_ID), None),
deployed,
undeployed,
];
let cascade: HashSet<String> = collect_cascade_pubkeys(&agents, PERSONA_ID)
.into_iter()
.collect();
assert_eq!(cascade.len(), 3, "all three agents are cascade targets");

let blockers = collect_remote_deployed(&agents, &cascade);

assert_eq!(
blockers,
vec!["Deployed Agent".to_string()],
"only the deployed provider agent blocks the cascade"
);
}
Loading
Loading