diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/message_tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/message_tests.rs index c3495caa03..2d0fc10ca2 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/message_tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/message_tests.rs @@ -10,6 +10,8 @@ use rusqlite::params; fn sandbox_with_inbox_schema() -> test_helpers::test_env::SandboxGuard { let sandbox = test_helpers::test_env::sandbox(); let conn = get_connection().expect("open sandbox database"); + crate::coordination::agent_org_runs::init_schema(&conn) + .expect("initialize Agent Org run schema"); init_schema(&conn).expect("initialize agent inbox schema"); sandbox } @@ -55,9 +57,12 @@ fn seed_minimal_running_run_for_delivery_resolution(run_id: &str) { ) .expect("seed coordinator session"); conn.execute( - "INSERT INTO agent_org_runtime_runs (id, status, org_snapshot_json, root_session_id) - VALUES (?1, 'running', NULL, ?2)", - params![run_id, &root_session_id], + "INSERT INTO agent_org_runtime_runs ( + id,org_id,coordinator_agent_id,status,org_snapshot_json, + root_session_id,entry_mode,created_at,updated_at + ) VALUES (?1,'delivery-repair-org','coordinator','running',NULL,?2, + 'standalone_session',?3,?3)", + params![run_id, &root_session_id, chrono::Utc::now().to_rfc3339()], ) .expect("seed running run"); } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs index 736118abaf..a64c77f846 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; -use rusqlite::{params, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension}; use database::db::{get_connection, with_sessions_writer}; @@ -15,6 +15,25 @@ use super::record::AgentInboxRecord; use super::record::{row_to_record, AgentInboxBatch}; use super::{AgentInboxStore, MAX_INBOX_DRAIN_PAYLOAD_BYTES, MAX_INBOX_DRAIN_ROWS}; +fn ensure_inbox_claim_allowed(conn: &Connection, org_run_id: &str) -> Result<(), String> { + let status: Option = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + [org_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + if status.as_deref() + == Some(crate::coordination::agent_org_runs::AgentOrgRunStatus::Archived.as_str()) + { + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + org_run_id, "archived", + )); + } + Ok(()) +} + impl AgentInboxStore { /// Load the single formal Inbox input bound to one persisted /// `TaskExecution` context. @@ -42,6 +61,7 @@ impl AgentInboxStore { turn_intent_id: &str, ) -> Result { let conn = get_connection().map_err(|err| err.to_string())?; + ensure_inbox_claim_allowed(&conn, org_run_id)?; let mut stmt = conn .prepare( "SELECT inbox.id, @@ -251,6 +271,7 @@ impl AgentInboxStore { org_run_id: &str, ) -> Result { let conn = get_connection().map_err(|err| err.to_string())?; + ensure_inbox_claim_allowed(&conn, org_run_id)?; let mut stmt = conn .prepare( "SELECT id, @@ -365,6 +386,40 @@ impl AgentInboxStore { let tx = conn .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|err| err.to_string())?; + // A late acknowledgement is still a write. Archive leaves + // Inbox history readable but permanently closes claim/ack + // paths, even when the pre-Archive materialization owner is + // otherwise still valid. + { + let mut status_stmt = tx + .prepare( + "SELECT inbox.org_run_id,run.status + FROM agent_org_runtime_inbox inbox + LEFT JOIN agent_org_runtime_runs run ON run.id=inbox.org_run_id + WHERE inbox.id=?1", + ) + .map_err(|err| err.to_string())?; + for id in ids { + let source: Option<(Option, Option)> = status_stmt + .query_row([id], |row| Ok((row.get(0)?, row.get(1)?))) + .optional() + .map_err(|err| err.to_string())?; + if let Some((Some(run_id), status)) = source { + if status.as_deref() + == Some( + crate::coordination::agent_org_runs::AgentOrgRunStatus::Archived + .as_str(), + ) + { + return Err( + crate::coordination::agent_org_runs::mutation_blocked_error( + &run_id, "archived", + ), + ); + } + } + } + } if let (Some(session_id), Some(turn_intent_id)) = (materialization_session_id, formal_turn_intent_id) { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs index 9faeb60629..51477ef39c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs @@ -456,6 +456,12 @@ impl AgentInboxStore { AgentOrgRunStore::get_run_status_with_connection(&tx, ¶ms.org_run_id) .map_err(storage)?; if run_status != Some(AgentOrgRunStatus::Running) { + if run_status == Some(AgentOrgRunStatus::Archived) { + return Err(constraint(format!( + "team_archived: Agent Org run {} is read-only", + params.org_run_id + ))); + } return Err(constraint(format!( "Agent Org run {} is not Running; Inbox delivery repair was not applied", params.org_run_id diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs index 161c4ce0ad..3a315b64b3 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs @@ -1,7 +1,7 @@ //! Write path for [`AgentInboxStore`]: message persistence, run-gated and //! causation-idempotent inserts, and the shared transactional insert core. -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use crate::coordination::agent_org_payload_limits as limits; use database::db::{get_connection, with_sessions_writer}; @@ -130,6 +130,21 @@ impl AgentInboxStore { .to_string(), ); } + if let Some(org_run_id) = params.org_run_id.as_deref() { + let status: Option = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + [org_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + if status.as_deref() == Some("archived") { + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + org_run_id, "archived", + )); + } + } params.message.validate()?; let kind = params.message.kind_tag().to_string(); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs index d8f6db4625..d43dc5bafa 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs @@ -12,6 +12,8 @@ use std::collections::HashSet; fn sandbox_with_inbox_schema() -> test_helpers::test_env::SandboxGuard { let sandbox = test_helpers::test_env::sandbox(); let conn = get_connection().expect("open sandbox database"); + crate::coordination::agent_org_runs::init_schema(&conn) + .expect("initialize Agent Org run schema"); init_schema(&conn).expect("initialize agent inbox schema"); sandbox } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_member_interventions.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_member_interventions.rs index 4b1023b174..5886349caa 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_member_interventions.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_member_interventions.rs @@ -116,6 +116,7 @@ impl AgentMemberInterventionStore { with_sessions_writer(|| -> Result<(), String> { let conn = get_connection().map_err(|err| err.to_string())?; + ensure_intervention_run_is_writable(&conn, ¶ms.org_run_id)?; conn.execute( "INSERT INTO agent_org_runtime_member_interventions ( org_run_id, @@ -167,6 +168,7 @@ impl AgentMemberInterventionStore { let now = chrono::Utc::now().to_rfc3339(); let changed = with_sessions_writer(|| -> Result { let conn = get_connection().map_err(|err| err.to_string())?; + ensure_intervention_run_is_writable(&conn, org_run_id)?; let updated = conn .execute( "UPDATE agent_org_runtime_member_interventions @@ -199,6 +201,7 @@ impl AgentMemberInterventionStore { let tx = conn .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|err| err.to_string())?; + ensure_intervention_run_is_writable(&tx, org_run_id)?; let updated = tx .execute( "UPDATE agent_org_runtime_member_interventions @@ -362,6 +365,23 @@ impl AgentMemberInterventionStore { } } +fn ensure_intervention_run_is_writable(conn: &Connection, org_run_id: &str) -> Result<(), String> { + let status: Option = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + [org_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + if status.as_deref() == Some("archived") { + return Err(super::agent_org_runs::mutation_blocked_error( + org_run_id, "archived", + )); + } + Ok(()) +} + fn resume_after_is_future(value: &str) -> bool { chrono::DateTime::parse_from_rfc3339(value) .map(|timestamp| timestamp.with_timezone(&chrono::Utc) > chrono::Utc::now()) @@ -398,6 +418,7 @@ mod tests { fn setup() -> test_helpers::test_env::SandboxGuard { let sandbox = test_helpers::test_env::sandbox(); let conn = get_connection().expect("db connection"); + crate::coordination::agent_org_runs::init_schema(&conn).expect("run schema"); init_schema(&conn).expect("schema"); conn.execute("DELETE FROM agent_org_runtime_member_interventions", []) .expect("clear"); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_archive.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_archive.rs new file mode 100644 index 0000000000..6186bada4d --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_archive.rs @@ -0,0 +1,705 @@ +//! Irreversible Agent Org Archive fence and bounded runtime teardown receipts. +//! +//! The database transaction owns the terminal decision. Runtime shutdown is +//! post-commit evidence only and can never reopen the Team. + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::Serialize; + +use super::agent_org_ownership::load_team_for_run; +use super::agent_org_runs::AgentOrgRunStatus; +use super::agent_org_tasks::{ + AgentOrgTaskStore, SystemArchiveOrRecovery, SystemTaskOperation, TaskTerminalReason, +}; + +pub const ARCHIVE_TEARDOWN_MAX_ATTEMPTS: i64 = 3; +pub const ARCHIVE_TEARDOWN_DEADLINE_SECS: i64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ArchiveTeardownStatus { + Pending, + Quiesced, + RetainedRuntime, +} + +impl ArchiveTeardownStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Quiesced => "quiesced", + Self::RetainedRuntime => "retained_runtime", + } + } + + fn parse(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "quiesced" => Ok(Self::Quiesced), + "retained_runtime" => Ok(Self::RetainedRuntime), + other => Err(format!("unknown Archive teardown status: {other:?}")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveTeardownSummary { + pub receipt_id: String, + pub status: ArchiveTeardownStatus, + pub attempt_count: i64, + pub retained_runtime_count: usize, + pub deadline_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveCancellationCounts { + pub tasks: usize, + pub turns: usize, + pub inbox_deliveries: usize, + pub plan_approvals: usize, + pub interventions: usize, + pub pause_continuations: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveRunOutcome { + pub request_id: String, + pub run_id: String, + pub receipt_id: String, + pub transitioned: bool, + pub archive_generation: i64, + pub archived_at: String, + pub cancellations: ArchiveCancellationCounts, + pub teardown: ArchiveTeardownSummary, +} + +#[derive(Debug)] +pub(crate) struct ArchiveCommit { + pub outcome: ArchiveRunOutcome, + pub owns_teardown: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ArchiveTeardownTarget { + pub receipt_id: String, + pub run_id: String, + pub session_id: String, + pub member_id: Option, + pub attempt_count: i64, +} + +pub(super) fn create_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS agent_org_runtime_archive_episodes ( + archive_receipt_id TEXT PRIMARY KEY CHECK(length(trim(archive_receipt_id)) > 0), + org_run_id TEXT NOT NULL UNIQUE, + archive_request_id TEXT NOT NULL CHECK(length(trim(archive_request_id)) > 0), + archive_generation INTEGER NOT NULL CHECK(archive_generation >= 2), + teardown_status TEXT NOT NULL CHECK(teardown_status IN ( + 'pending','quiesced','retained_runtime' + )), + teardown_attempt_count INTEGER NOT NULL DEFAULT 0 + CHECK(teardown_attempt_count BETWEEN 0 AND 3), + retained_runtime_count INTEGER NOT NULL DEFAULT 0 + CHECK(retained_runtime_count >= 0), + task_cancel_count INTEGER NOT NULL DEFAULT 0 CHECK(task_cancel_count >= 0), + turn_cancel_count INTEGER NOT NULL DEFAULT 0 CHECK(turn_cancel_count >= 0), + inbox_cancel_count INTEGER NOT NULL DEFAULT 0 CHECK(inbox_cancel_count >= 0), + approval_cancel_count INTEGER NOT NULL DEFAULT 0 CHECK(approval_cancel_count >= 0), + intervention_cancel_count INTEGER NOT NULL DEFAULT 0 CHECK(intervention_cancel_count >= 0), + pause_continuation_cancel_count INTEGER NOT NULL DEFAULT 0 + CHECK(pause_continuation_cancel_count >= 0), + deadline_at TEXT NOT NULL, + last_error TEXT, + archived_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + quiesced_at TEXT, + UNIQUE(org_run_id, archive_request_id), + FOREIGN KEY(org_run_id) REFERENCES agent_org_runtime_runs(id) ON DELETE CASCADE, + CHECK( + (teardown_status='quiesced' AND quiesced_at IS NOT NULL + AND retained_runtime_count=0) + OR + (teardown_status<>'quiesced' AND quiesced_at IS NULL) + ) + ); + CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_archive_pending + ON agent_org_runtime_archive_episodes(teardown_status, deadline_at); + + CREATE TABLE IF NOT EXISTS agent_org_runtime_archive_teardowns ( + teardown_id TEXT PRIMARY KEY CHECK(length(trim(teardown_id)) > 0), + archive_receipt_id TEXT NOT NULL, + org_run_id TEXT NOT NULL, + session_id TEXT NOT NULL, + member_id TEXT, + captured_parent_session_id TEXT, + teardown_status TEXT NOT NULL CHECK(teardown_status IN ( + 'pending','quiesced','retained_runtime' + )), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 3), + runtime_lease_id TEXT, + dialog_turn_generation TEXT, + last_error TEXT, + released_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(archive_receipt_id, session_id), + FOREIGN KEY(archive_receipt_id) + REFERENCES agent_org_runtime_archive_episodes(archive_receipt_id) ON DELETE CASCADE, + FOREIGN KEY(org_run_id) REFERENCES agent_org_runtime_runs(id) ON DELETE CASCADE, + CHECK( + (teardown_status='quiesced' AND released_at IS NOT NULL) + OR + (teardown_status<>'quiesced' AND released_at IS NULL) + ), + CHECK( + (runtime_lease_id IS NULL AND dialog_turn_generation IS NULL) + OR runtime_lease_id IS NOT NULL + ) + ); + CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_archive_teardown_pending + ON agent_org_runtime_archive_teardowns( + archive_receipt_id, teardown_status, session_id + );", + ) +} + +pub(crate) fn archive_run_commit(run_id: &str, request_id: &str) -> Result { + validate_request_id(request_id)?; + database::db::with_sessions_writer(|| { + let mut conn = database::db::get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + + if let Some(outcome) = outcome_for_request(&tx, run_id, request_id, false)? { + tx.commit().map_err(|error| error.to_string())?; + return Ok(ArchiveCommit { + outcome, + owns_teardown: false, + }); + } + + let run: Option<(String, i64)> = tx + .query_row( + "SELECT status,activation_generation + FROM agent_org_runtime_runs WHERE id=?1", + [run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((status_raw, generation)) = run else { + return Err(format!("agent_org_run_not_found: {run_id}")); + }; + let status = AgentOrgRunStatus::parse(&status_raw) + .ok_or_else(|| format!("unknown Agent Org run status: {status_raw:?}"))?; + match status { + AgentOrgRunStatus::Starting => { + return Err(format!( + "team_not_ready: Agent Org run {run_id} is still materializing" + )); + } + AgentOrgRunStatus::Archived => { + return Err(format!( + "team_archived: Agent Org run {run_id} is already read-only" + )); + } + AgentOrgRunStatus::Idle + | AgentOrgRunStatus::Running + | AgentOrgRunStatus::Paused + | AgentOrgRunStatus::Failed => {} + } + + let archive_generation = generation + .checked_add(1) + .ok_or_else(|| format!("Agent Org run {run_id} generation overflow"))?; + let receipt_id = uuid::Uuid::new_v4().to_string(); + let archived_at = chrono::Utc::now(); + let archived_at_text = archived_at.to_rfc3339(); + let deadline_at = + (archived_at + chrono::Duration::seconds(ARCHIVE_TEARDOWN_DEADLINE_SECS)).to_rfc3339(); + + let changed = tx + .execute( + "UPDATE agent_org_runtime_runs + SET status='archived',activation_generation=?2,archived_at=?3, + archive_receipt_id=?4,updated_at=?3 + WHERE id=?1 AND status=?5 AND activation_generation=?6", + params![ + run_id, + archive_generation, + &archived_at_text, + &receipt_id, + status.as_str(), + generation + ], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err(format!( + "Agent Org run {run_id} changed while Archive was committing" + )); + } + tx.execute( + "INSERT INTO agent_org_runtime_archive_episodes ( + archive_receipt_id,org_run_id,archive_request_id,archive_generation, + teardown_status,deadline_at,archived_at,updated_at + ) VALUES (?1,?2,?3,?4,'pending',?5,?6,?6)", + params![ + &receipt_id, + run_id, + request_id, + archive_generation, + &deadline_at, + &archived_at_text + ], + ) + .map_err(|error| error.to_string())?; + + let ownership = load_team_for_run(&tx, run_id)?; + for session in &ownership.sessions { + tx.execute( + "INSERT INTO agent_org_runtime_archive_teardowns ( + teardown_id,archive_receipt_id,org_run_id,session_id,member_id, + captured_parent_session_id,teardown_status,created_at,updated_at + ) VALUES (?1,?2,?3,?4,?5,?6,'pending',?7,?7)", + params![ + uuid::Uuid::new_v4().to_string(), + &receipt_id, + run_id, + &session.session_id, + session.member_id.as_deref(), + session.parent_session_id.as_deref(), + &archived_at_text + ], + ) + .map_err(|error| error.to_string())?; + } + + let task_actor = SystemArchiveOrRecovery::new( + &receipt_id, + archive_generation, + SystemTaskOperation::ArchiveCancel, + )?; + let tasks = AgentOrgTaskStore::cancel_open_for_archive_with_connection( + &tx, + &task_actor, + run_id, + &TaskTerminalReason { + code: "team_archived".to_string(), + message: "The Team was archived; unfinished work was cancelled.".to_string(), + }, + )?; + let turns = tx + .execute( + "UPDATE session_turn_intents + SET status='cancelled',updated_at=?2 + WHERE org_run_id=?1 AND status IN ('optimistic','queued','running')", + params![run_id, &archived_at_text], + ) + .map_err(|error| error.to_string())?; + + tx.execute( + "DELETE FROM agent_org_runtime_inbox_materializations + WHERE inbox_id IN ( + SELECT inbox.id FROM agent_org_runtime_inbox inbox + WHERE inbox.org_run_id=?1 AND inbox.read_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM agent_org_runtime_inbox_delivery_resolutions resolution + WHERE resolution.inbox_id=inbox.id + ) + )", + [run_id], + ) + .map_err(|error| error.to_string())?; + let inbox_deliveries = tx + .execute( + "INSERT INTO agent_org_runtime_inbox_delivery_resolutions ( + inbox_id,org_run_id,resolution_kind,resolved_by_member_id, + reason,replacement_inbox_id,replacement_task_id,created_at + ) + SELECT inbox.id,inbox.org_run_id,'cancelled','system:archive', + 'team_archived',NULL,NULL,?2 + FROM agent_org_runtime_inbox inbox + WHERE inbox.org_run_id=?1 AND inbox.read_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM agent_org_runtime_inbox_delivery_resolutions resolution + WHERE resolution.inbox_id=inbox.id + )", + params![run_id, &archived_at_text], + ) + .map_err(|error| error.to_string())?; + let plan_approvals = tx + .execute( + "UPDATE agent_org_runtime_plan_approvals + SET status='cancelled',decision_by='system',feedback='team_archived', + resolved_at=?2 + WHERE org_run_id=?1 AND status='pending'", + params![run_id, &archived_at_text], + ) + .map_err(|error| error.to_string())?; + let interventions = tx + .execute( + "UPDATE agent_org_runtime_member_interventions + SET cleared_at=?2 + WHERE org_run_id=?1 AND cleared_at IS NULL", + params![run_id, &archived_at_text], + ) + .map_err(|error| error.to_string())?; + let pause_continuations = tx + .execute( + "UPDATE agent_org_runtime_pause_handoffs + SET continuation_status='skipped',skip_reason='team_archived',updated_at=?2 + WHERE org_run_id=?1 AND continuation_status='queued'", + params![run_id, &archived_at_text], + ) + .map_err(|error| error.to_string())?; + + tx.execute( + "UPDATE agent_org_runtime_archive_episodes + SET task_cancel_count=?2,turn_cancel_count=?3,inbox_cancel_count=?4, + approval_cancel_count=?5,intervention_cancel_count=?6, + pause_continuation_cancel_count=?7,updated_at=?8 + WHERE archive_receipt_id=?1", + params![ + &receipt_id, + tasks as i64, + turns as i64, + inbox_deliveries as i64, + plan_approvals as i64, + interventions as i64, + pause_continuations as i64, + &archived_at_text + ], + ) + .map_err(|error| error.to_string())?; + + let outcome = outcome_for_request(&tx, run_id, request_id, true)? + .ok_or_else(|| "Archive receipt disappeared before commit".to_string())?; + tx.commit().map_err(|error| error.to_string())?; + Ok(ArchiveCommit { + outcome, + owns_teardown: true, + }) + }) +} + +pub(crate) fn teardown_targets(receipt_id: &str) -> Result, String> { + let conn = database::db::get_connection().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT teardown.archive_receipt_id,teardown.org_run_id, + teardown.session_id,teardown.member_id,teardown.attempt_count + FROM agent_org_runtime_archive_teardowns teardown + JOIN agent_org_runtime_archive_episodes archive + ON archive.archive_receipt_id=teardown.archive_receipt_id + WHERE teardown.archive_receipt_id=?1 + AND archive.teardown_status='pending' + AND teardown.teardown_status='pending' + ORDER BY teardown.session_id", + ) + .map_err(|error| error.to_string())?; + let targets = statement + .query_map([receipt_id], |row| { + Ok(ArchiveTeardownTarget { + receipt_id: row.get(0)?, + run_id: row.get(1)?, + session_id: row.get(2)?, + member_id: row.get(3)?, + attempt_count: row.get(4)?, + }) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + Ok(targets) +} + +pub(crate) fn record_teardown_attempt( + target: &ArchiveTeardownTarget, + runtime_lease_id: Option<&str>, + dialog_turn_generation: Option<&str>, + released: bool, + error: Option<&str>, +) -> Result { + database::db::with_sessions_writer(|| { + let mut conn = database::db::get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let now = chrono::Utc::now().to_rfc3339(); + let archive: Option<(String, i64)> = tx + .query_row( + "SELECT teardown_status,teardown_attempt_count + FROM agent_org_runtime_archive_episodes + WHERE archive_receipt_id=?1 AND org_run_id=?2", + params![&target.receipt_id, &target.run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((archive_status, _)) = archive else { + return Err("archive_teardown_receipt_not_found".to_string()); + }; + if archive_status != ArchiveTeardownStatus::Pending.as_str() { + let summary = load_summary(&tx, &target.receipt_id)?; + tx.commit().map_err(|error| error.to_string())?; + return Ok(summary); + } + + let next_attempt = target + .attempt_count + .checked_add(1) + .ok_or_else(|| "archive teardown attempt overflow".to_string())?; + let target_status = if released { + ArchiveTeardownStatus::Quiesced + } else if next_attempt >= ARCHIVE_TEARDOWN_MAX_ATTEMPTS { + ArchiveTeardownStatus::RetainedRuntime + } else { + ArchiveTeardownStatus::Pending + }; + let changed = tx + .execute( + "UPDATE agent_org_runtime_archive_teardowns + SET teardown_status=?4,attempt_count=?5,runtime_lease_id=?6, + dialog_turn_generation=?7,last_error=?8, + released_at=CASE WHEN ?4='quiesced' THEN ?9 ELSE NULL END, + updated_at=?9 + WHERE archive_receipt_id=?1 AND org_run_id=?2 AND session_id=?3 + AND teardown_status='pending' AND attempt_count=?10", + params![ + &target.receipt_id, + &target.run_id, + &target.session_id, + target_status.as_str(), + next_attempt, + runtime_lease_id, + dialog_turn_generation, + error, + &now, + target.attempt_count + ], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + let summary = load_summary(&tx, &target.receipt_id)?; + tx.commit().map_err(|error| error.to_string())?; + return Ok(summary); + } + recompute_archive_summary(&tx, &target.receipt_id, error, &now)?; + let summary = load_summary(&tx, &target.receipt_id)?; + tx.commit().map_err(|error| error.to_string())?; + Ok(summary) + }) +} + +pub(crate) fn mark_deadline_expired(receipt_id: &str) -> Result { + database::db::with_sessions_writer(|| { + let mut conn = database::db::get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let now = chrono::Utc::now().to_rfc3339(); + tx.execute( + "UPDATE agent_org_runtime_archive_teardowns + SET teardown_status='retained_runtime',last_error='archive_teardown_deadline', + updated_at=?2 + WHERE archive_receipt_id=?1 AND teardown_status='pending'", + params![receipt_id, &now], + ) + .map_err(|error| error.to_string())?; + recompute_archive_summary(&tx, receipt_id, Some("archive_teardown_deadline"), &now)?; + let summary = load_summary(&tx, receipt_id)?; + tx.commit().map_err(|error| error.to_string())?; + Ok(summary) + }) +} + +pub(crate) fn pending_receipt_ids(limit: usize) -> Result, String> { + if limit == 0 { + return Ok(Vec::new()); + } + let conn = database::db::get_connection().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT archive_receipt_id FROM agent_org_runtime_archive_episodes + WHERE teardown_status='pending' + ORDER BY archived_at ASC LIMIT ?1", + ) + .map_err(|error| error.to_string())?; + let receipt_ids = statement + .query_map([limit as i64], |row| row.get(0)) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + Ok(receipt_ids) +} + +pub fn summary_for_run(run_id: &str) -> Result, String> { + let conn = database::db::get_connection().map_err(|error| error.to_string())?; + summary_for_run_with_connection(&conn, run_id) +} + +/// Canonical Team Session scope for debug/WebDriver runtime evidence. Kept +/// out of release builds so the production API surface remains unchanged. +#[cfg(debug_assertions)] +pub fn debug_owned_session_ids_for_run(run_id: &str) -> Result, String> { + let conn = database::db::get_connection().map_err(|error| error.to_string())?; + Ok(load_team_for_run(&conn, run_id)? + .sessions + .into_iter() + .map(|session| session.session_id) + .collect()) +} + +pub(crate) fn summary_for_run_with_connection( + conn: &Connection, + run_id: &str, +) -> Result, String> { + let receipt_id: Option = conn + .query_row( + "SELECT archive_receipt_id FROM agent_org_runtime_archive_episodes + WHERE org_run_id=?1", + [run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + receipt_id + .map(|receipt_id| load_summary(conn, &receipt_id)) + .transpose() +} + +fn recompute_archive_summary( + conn: &Connection, + receipt_id: &str, + error: Option<&str>, + now: &str, +) -> Result<(), String> { + let (pending, retained, max_attempt): (i64, i64, i64) = conn + .query_row( + "SELECT + SUM(CASE WHEN teardown_status='pending' THEN 1 ELSE 0 END), + SUM(CASE WHEN teardown_status='retained_runtime' THEN 1 ELSE 0 END), + COALESCE(MAX(attempt_count),0) + FROM agent_org_runtime_archive_teardowns + WHERE archive_receipt_id=?1", + [receipt_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|error| error.to_string())?; + let status = if pending > 0 { + ArchiveTeardownStatus::Pending + } else if retained > 0 { + ArchiveTeardownStatus::RetainedRuntime + } else { + ArchiveTeardownStatus::Quiesced + }; + conn.execute( + "UPDATE agent_org_runtime_archive_episodes + SET teardown_status=?2,teardown_attempt_count=?3, + retained_runtime_count=?4,last_error=COALESCE(?5,last_error), + quiesced_at=CASE WHEN ?2='quiesced' THEN ?6 ELSE NULL END, + updated_at=?6 + WHERE archive_receipt_id=?1", + params![ + receipt_id, + status.as_str(), + max_attempt, + retained, + error, + now + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn outcome_for_request( + conn: &Connection, + run_id: &str, + request_id: &str, + transitioned: bool, +) -> Result, String> { + conn.query_row( + "SELECT archive_request_id,org_run_id,archive_receipt_id,archive_generation, + archived_at,task_cancel_count,turn_cancel_count,inbox_cancel_count, + approval_cancel_count,intervention_cancel_count, + pause_continuation_cancel_count,teardown_status, + teardown_attempt_count,retained_runtime_count,deadline_at + FROM agent_org_runtime_archive_episodes + WHERE org_run_id=?1 AND archive_request_id=?2", + params![run_id, request_id], + |row| { + let status_raw: String = row.get(11)?; + let status = ArchiveTeardownStatus::parse(&status_raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 11, + rusqlite::types::Type::Text, + error.into(), + ) + })?; + Ok(ArchiveRunOutcome { + request_id: row.get(0)?, + run_id: row.get(1)?, + receipt_id: row.get(2)?, + transitioned, + archive_generation: row.get(3)?, + archived_at: row.get(4)?, + cancellations: ArchiveCancellationCounts { + tasks: row.get::<_, i64>(5)? as usize, + turns: row.get::<_, i64>(6)? as usize, + inbox_deliveries: row.get::<_, i64>(7)? as usize, + plan_approvals: row.get::<_, i64>(8)? as usize, + interventions: row.get::<_, i64>(9)? as usize, + pause_continuations: row.get::<_, i64>(10)? as usize, + }, + teardown: ArchiveTeardownSummary { + receipt_id: row.get(2)?, + status, + attempt_count: row.get(12)?, + retained_runtime_count: row.get::<_, i64>(13)? as usize, + deadline_at: row.get(14)?, + }, + }) + }, + ) + .optional() + .map_err(|error| error.to_string()) +} + +fn load_summary(conn: &Connection, receipt_id: &str) -> Result { + conn.query_row( + "SELECT teardown_status,teardown_attempt_count,retained_runtime_count,deadline_at + FROM agent_org_runtime_archive_episodes WHERE archive_receipt_id=?1", + [receipt_id], + |row| { + let status_raw: String = row.get(0)?; + let status = ArchiveTeardownStatus::parse(&status_raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + error.into(), + ) + })?; + Ok(ArchiveTeardownSummary { + receipt_id: receipt_id.to_string(), + status, + attempt_count: row.get(1)?, + retained_runtime_count: row.get::<_, i64>(2)? as usize, + deadline_at: row.get(3)?, + }) + }, + ) + .map_err(|error| error.to_string()) +} + +fn validate_request_id(request_id: &str) -> Result<(), String> { + uuid::Uuid::parse_str(request_id) + .map(|_| ()) + .map_err(|_| "Archive request_id must be a UUID".to_string()) +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_archive_tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_archive_tests.rs new file mode 100644 index 0000000000..8f4d4fc90a --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_archive_tests.rs @@ -0,0 +1,782 @@ +use rusqlite::params; + +use super::agent_org_archive::{ + archive_run_commit, pending_receipt_ids, record_teardown_attempt, summary_for_run, + teardown_targets, ArchiveTeardownStatus, +}; +use super::agent_org_runs::COORDINATOR_MEMBER_ID; +use super::agent_org_tasks::{TaskOutputInput, TaskOwnerExecution}; + +fn setup() { + let conn = database::db::get_connection().expect("sandbox DB"); + crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); + crate::session::persistence::init(&conn).expect("session schema"); + crate::interaction::plan_approval::persistence::init_schema(&conn) + .expect("plan approval schema"); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) + );", + ) + .expect("turn intent schema"); +} + +fn seed_session(session_id: &str, parent: Option<&str>, member_id: &str) { + database::db::get_connection() + .expect("sandbox DB") + .execute( + "INSERT INTO agent_sessions ( + session_id,name,status,created_at,updated_at,session_type, + parent_session_id,org_member_id,workspace_additional_json,key_source + ) VALUES (?1,?1,'running',?2,?2,'agent',?3,?4,'{}','own_key')", + params![session_id, "2026-08-23T00:00:00Z", parent, member_id], + ) + .expect("seed Session"); +} + +fn seed_run(run_id: &str, root_session_id: &str, status: &str, generation: i64) { + let snapshot = serde_json::json!({ + "schemaVersion": 1, + "orgId": "org-archive-test", + "orgName": "Archive Test Team", + "coordinatorRole": "Lead", + "coordinatorAgentId": "coordinator-agent", + "planApprovalPolicy": "coordinator", + "members": [ + { + "memberId": "worker", + "name": "Worker", + "role": "Builder", + "agentId": "worker-agent" + } + ], + "additionalTaskGraphWriterMemberIds": [], + "memberCommunicationLinks": [], + }) + .to_string(); + database::db::get_connection() + .expect("sandbox DB") + .execute( + "INSERT INTO agent_org_runtime_runs ( + id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json,entry_mode, + status,activation_generation,created_at,updated_at + ) VALUES (?1,'org-archive-test','coordinator-agent',?2,?3, + 'standalone_session',?4,?5,?6,?6)", + params![ + run_id, + root_session_id, + snapshot, + status, + generation, + "2026-08-23T00:00:00Z" + ], + ) + .expect("seed Run"); +} + +fn scalar_string(sql: &str, key: &str) -> String { + database::db::get_connection() + .expect("sandbox DB") + .query_row(sql, [key], |row| row.get(0)) + .expect("read scalar") +} + +fn assert_archive_failure_rolled_back(run_id: &str, expected_error: &str) { + let error = archive_run_commit(run_id, &uuid::Uuid::new_v4().to_string()) + .expect_err("injected Archive write failure must roll back"); + assert!( + error.contains(expected_error), + "unexpected Archive error: {error}" + ); + assert_eq!( + scalar_string( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + run_id + ), + "running" + ); + let receipt_count: i64 = database::db::get_connection() + .expect("sandbox DB") + .query_row( + "SELECT COUNT(*) FROM agent_org_runtime_archive_episodes WHERE org_run_id=?1", + [run_id], + |row| row.get(0), + ) + .expect("receipt count"); + assert_eq!(receipt_count, 0); +} + +#[test] +fn archive_fence_cancels_open_work_and_is_request_idempotent() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + let run_id = "archive-full-run"; + let root = "archive-full-root"; + let member = "archive-full-member"; + seed_session(root, None, COORDINATOR_MEMBER_ID); + seed_session(member, Some(root), "worker"); + seed_run(run_id, root, "running", 1); + let conn = database::db::get_connection().expect("sandbox DB"); + conn.execute( + "INSERT INTO agent_org_runtime_tasks ( + id,org_run_id,subject,description,owner,status,execution_mode, + blocked_by_json,created_by_participant_id,source_turn_intent_id, + created_at,updated_at + ) VALUES ('task-open',?1,'Open work','','worker','in_progress','build', + '[]','coordinator','turn-create',?2,?2)", + params![run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Task"); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES (?1,'turn-running',?2,'agent_org','running',?3,?3)", + params![member, run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Turn"); + conn.execute( + "INSERT INTO agent_org_runtime_inbox ( + recipient_agent_id,recipient_member_id,sender_agent_id,sender_member_id, + org_run_id,payload_kind,payload_json,created_at + ) VALUES ('worker-agent','worker','coordinator-agent','coordinator',?1, + 'plain','{\"summary\":\"work\",\"text\":\"work\"}',?2)", + params![run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Inbox"); + let inbox_id = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO agent_org_runtime_inbox_materializations ( + inbox_id,session_id,transcript_message_id,transcript_intent_id,materialized_at + ) VALUES (?1,?2,'materialized-message','materialized-intent',?3)", + params![inbox_id, member, "2026-08-23T00:00:00Z"], + ) + .expect("seed Inbox materialization"); + conn.execute( + "INSERT INTO agent_org_runtime_plan_approvals ( + approval_id,plan_revision_id,request_id,org_run_id,source_task_id, + source_member_id,source_session_id,source_turn_intent_id,root_session_id, + policy,status,plan_title,plan_path,plan_content,created_at + ) VALUES ( + 'approval-open','revision-open','approval-request',?1,'task-open', + 'worker',?2,'turn-running',?3,'user','pending','Plan','/tmp/plan.md', + '# Plan',?4 + )", + params![run_id, member, root, "2026-08-23T00:00:00Z"], + ) + .expect("seed Plan approval"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts ( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind,task_id, + owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,activation_generation,created_at + ) VALUES (?1,'turn-running',?2,'worker','task_execution','task-open', + 'worker','worker',1,'task','task-open',1,?3)", + params![member, run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Turn context"); + conn.execute( + "INSERT INTO agent_org_runtime_pause_episodes ( + episode_id,org_run_id,pause_request_id,pause_generation,status, + teardown_owner_id,created_at,updated_at + ) VALUES ('pause-open',?1,'pause-request',2,'active','pause-owner',?2,?2)", + params![run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Pause episode"); + conn.execute( + "INSERT INTO agent_org_runtime_pause_handoffs ( + handoff_id,episode_id,org_run_id,session_id,original_turn_intent_id, + turn_kind,participant_id,task_id,original_owner_member_id, + original_activation_generation,original_intent_status,drain_status, + continuation_turn_intent_id,continuation_status,created_at,updated_at + ) VALUES ( + 'handoff-open','pause-open',?1,?2,'turn-running','task_execution', + 'worker','task-open','worker',1,'running','released', + 'continuation-open','queued',?3,?3 + )", + params![run_id, member, "2026-08-23T00:00:00Z"], + ) + .expect("seed queued Pause continuation"); + conn.execute( + "INSERT INTO agent_org_runtime_member_interventions ( + org_run_id,member_id,agent_id,session_id,status,entered_at, + last_user_activity_at,resume_after + ) VALUES (?1,'worker','worker-agent',?2,'user_intervention',?3,?3,?4)", + params![ + run_id, + member, + "2026-08-23T00:00:00Z", + "2026-08-23T00:03:00Z" + ], + ) + .expect("seed intervention"); + drop(conn); + + let request_id = uuid::Uuid::new_v4().to_string(); + let first = archive_run_commit(run_id, &request_id).expect("Archive commit"); + assert!(first.owns_teardown); + assert!(first.outcome.transitioned); + assert_eq!(first.outcome.archive_generation, 2); + assert_eq!(first.outcome.cancellations.tasks, 1); + assert_eq!(first.outcome.cancellations.turns, 1); + assert_eq!(first.outcome.cancellations.inbox_deliveries, 1); + assert_eq!(first.outcome.cancellations.plan_approvals, 1); + assert_eq!(first.outcome.cancellations.interventions, 1); + assert_eq!(first.outcome.cancellations.pause_continuations, 1); + let wire = serde_json::to_value(&first.outcome).expect("serialize Archive outcome"); + let wire_object = wire.as_object().expect("Archive outcome object"); + assert_eq!( + wire_object.len(), + 8, + "wire stays bounded to contract fields" + ); + assert_eq!(wire["requestId"], request_id); + assert_eq!(wire["runId"], run_id); + assert_eq!(wire["archiveGeneration"], 2); + assert_eq!(wire["teardown"]["status"], "pending"); + assert_eq!( + wire["teardown"] + .as_object() + .expect("Archive teardown object") + .len(), + 5, + "teardown wire must not expose logs or user content" + ); + assert!(wire.get("request_id").is_none()); + assert!(wire["teardown"].get("lastError").is_none()); + assert_eq!( + scalar_string( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + run_id + ), + "archived" + ); + assert_eq!( + scalar_string( + "SELECT status FROM agent_org_runtime_tasks WHERE org_run_id=?1", + run_id + ), + "cancelled" + ); + assert_eq!( + scalar_string( + "SELECT status FROM session_turn_intents WHERE org_run_id=?1", + run_id + ), + "cancelled" + ); + assert_eq!( + scalar_string( + "SELECT status FROM agent_org_runtime_plan_approvals WHERE org_run_id=?1", + run_id + ), + "cancelled" + ); + assert_eq!( + scalar_string( + "SELECT continuation_status FROM agent_org_runtime_pause_handoffs WHERE org_run_id=?1", + run_id + ), + "skipped" + ); + let materialization_count: i64 = database::db::get_connection() + .expect("sandbox DB") + .query_row( + "SELECT COUNT(*) FROM agent_org_runtime_inbox_materializations WHERE inbox_id=?1", + [inbox_id], + |row| row.get(0), + ) + .expect("materialization count"); + assert_eq!(materialization_count, 0); + let claim_error = + crate::coordination::agent_inbox::AgentInboxStore::list_unread_batch_for_member( + "worker", run_id, + ) + .expect_err("Archived Inbox claim must fail"); + assert!(claim_error.starts_with("team_archived:")); + let ack_error = crate::coordination::agent_inbox::AgentInboxStore::mark_many_read(&[inbox_id]) + .expect_err("Archived Inbox acknowledgement must fail"); + assert!(ack_error.starts_with("team_archived:")); + let materialize_error = crate::session::persistence::materialize_agent_org_inbox_transcript( + member, + &[inbox_id], + "late-materialized-message", + "late-materialized-intent", + "late", + ) + .expect_err("Archived Inbox materialization must fail"); + assert!(materialize_error.starts_with("team_archived:")); + let teardown_count: i64 = database::db::get_connection() + .expect("sandbox DB") + .query_row( + "SELECT COUNT(*) FROM agent_org_runtime_archive_teardowns WHERE org_run_id=?1", + [run_id], + |row| row.get(0), + ) + .expect("teardown count"); + assert_eq!(teardown_count, 2); + + let late_task_result = + crate::coordination::agent_org_tasks::AgentOrgTaskStore::owner_complete_with_transactional_effects( + TaskOwnerExecution::new(member, "turn-running").expect("late Task owner actor"), + run_id, + "task-open", + TaskOutputInput { + summary: "late after Archive".to_string(), + content: None, + artifact_ids: Vec::new(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect_err("Archived Team rejects late Task final"); + assert!(late_task_result.starts_with("team_archived:")); + let resume_error = + crate::coordination::agent_org_pause::resume_run(run_id, &uuid::Uuid::new_v4().to_string()) + .expect_err("Archived Team rejects Resume"); + assert!(resume_error.starts_with("team_archived:")); + + let replay = archive_run_commit(run_id, &request_id).expect("Archive replay"); + assert!(!replay.owns_teardown); + assert!(!replay.outcome.transitioned); + assert_eq!(replay.outcome.receipt_id, first.outcome.receipt_id); + assert_eq!(replay.outcome.archive_generation, 2); + let error = archive_run_commit(run_id, &uuid::Uuid::new_v4().to_string()) + .expect_err("different request cannot re-Archive"); + assert!(error.starts_with("team_archived:")); +} + +#[test] +fn concurrent_different_archive_requests_transition_exactly_once() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + let run_id = "archive-concurrent-run"; + seed_session("archive-concurrent-root", None, COORDINATOR_MEMBER_ID); + seed_run(run_id, "archive-concurrent-root", "running", 1); + let request_ids = [ + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + ]; + let barrier = std::sync::Arc::new(std::sync::Barrier::new(request_ids.len())); + let handles = request_ids.map(|request_id| { + let barrier = std::sync::Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + archive_run_commit(run_id, &request_id) + }) + }); + let results = handles.map(|handle| handle.join().expect("Archive writer thread")); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| result + .as_ref() + .is_err_and(|error| error.starts_with("team_archived:"))) + .count(), + 1 + ); + assert_eq!( + scalar_string( + "SELECT CAST(activation_generation AS TEXT) + FROM agent_org_runtime_runs WHERE id=?1", + run_id + ), + "2" + ); +} + +#[test] +fn archive_accepts_only_documented_source_states() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + for (index, status) in ["running", "paused", "idle", "failed"] + .into_iter() + .enumerate() + { + let run_id = format!("archive-state-run-{index}"); + let root = format!("archive-state-root-{index}"); + seed_session(&root, None, COORDINATOR_MEMBER_ID); + seed_run(&run_id, &root, status, 1); + assert!( + archive_run_commit(&run_id, &uuid::Uuid::new_v4().to_string()) + .expect("allowed Archive state") + .outcome + .transitioned + ); + } + + seed_session("archive-starting-root", None, COORDINATOR_MEMBER_ID); + seed_run( + "archive-starting-run", + "archive-starting-root", + "starting", + 1, + ); + let error = archive_run_commit("archive-starting-run", &uuid::Uuid::new_v4().to_string()) + .expect_err("Starting is not ready for Archive"); + assert!(error.starts_with("team_not_ready:")); + assert_eq!( + scalar_string( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + "archive-starting-run" + ), + "starting" + ); +} + +#[test] +fn archive_rolls_back_the_fence_when_a_cancellation_write_fails() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + let run_id = "archive-rollback-run"; + let root = "archive-rollback-root"; + seed_session(root, None, COORDINATOR_MEMBER_ID); + seed_run(run_id, root, "running", 1); + let conn = database::db::get_connection().expect("sandbox DB"); + conn.execute( + "INSERT INTO agent_org_runtime_tasks ( + id,org_run_id,subject,description,status,execution_mode,blocked_by_json, + created_by_participant_id,source_turn_intent_id,created_at,updated_at + ) VALUES ('task-rollback',?1,'Open work','','pending','build','[]', + 'coordinator','turn-create',?2,?2)", + params![run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Task"); + conn.execute_batch( + "CREATE TRIGGER abort_archive_task_cancel + BEFORE UPDATE OF status ON agent_org_runtime_tasks + WHEN NEW.status='cancelled' + BEGIN SELECT RAISE(ABORT,'injected Archive cancellation failure'); END;", + ) + .expect("install failure injection"); + drop(conn); + + assert_archive_failure_rolled_back(run_id, "injected Archive cancellation failure"); + assert_eq!( + scalar_string( + "SELECT status FROM agent_org_runtime_tasks WHERE org_run_id=?1", + run_id + ), + "pending" + ); +} + +#[test] +fn archive_rolls_back_at_every_non_task_transaction_boundary() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + + for boundary in [ + "fence", + "episode", + "teardown", + "turn", + "inbox", + "approval", + "intervention", + "pause_continuation", + "receipt_finalize", + ] { + let run_id = format!("archive-fault-{boundary}-run"); + let root = format!("archive-fault-{boundary}-root"); + seed_session(&root, None, COORDINATOR_MEMBER_ID); + seed_run(&run_id, &root, "running", 1); + let conn = database::db::get_connection().expect("sandbox DB"); + + match boundary { + "turn" => { + conn.execute( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES (?1,?2,?3,'agent_org','running',?4,?4)", + params![ + &root, + format!("{run_id}-turn"), + &run_id, + "2026-08-23T00:00:00Z" + ], + ) + .expect("seed Turn intent"); + } + "inbox" => { + conn.execute( + "INSERT INTO agent_org_runtime_inbox ( + recipient_agent_id,recipient_member_id,sender_agent_id, + sender_member_id,org_run_id,payload_kind,payload_json,created_at + ) VALUES ('coordinator-agent','coordinator','worker-agent','worker', + ?1,'plain','{\"summary\":\"work\",\"text\":\"work\"}',?2)", + params![&run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Inbox delivery"); + } + "approval" => { + let task_id = format!("{run_id}-task"); + conn.execute( + "INSERT INTO agent_org_runtime_tasks ( + id,org_run_id,subject,description,status,execution_mode, + blocked_by_json,created_by_participant_id, + source_turn_intent_id,created_at,updated_at + ) VALUES (?1,?2,'Approval work','','pending','plan','[]', + 'coordinator',?3,?4,?4)", + params![ + &task_id, + &run_id, + format!("{run_id}-turn"), + "2026-08-23T00:00:00Z" + ], + ) + .expect("seed approval source Task"); + conn.execute( + "INSERT INTO agent_org_runtime_plan_approvals ( + approval_id,plan_revision_id,request_id,org_run_id, + source_task_id,source_member_id,source_session_id, + source_turn_intent_id,root_session_id,policy,status, + plan_title,plan_path,plan_content,created_at + ) VALUES (?1,?2,?3,?4,?5,'coordinator',?6,?7,?6, + 'user','pending','Plan','/tmp/archive-fault-plan.md', + '# Plan',?8)", + params![ + format!("{run_id}-approval"), + format!("{run_id}-revision"), + format!("{run_id}-request"), + &run_id, + &task_id, + &root, + format!("{run_id}-turn"), + "2026-08-23T00:00:00Z" + ], + ) + .expect("seed Plan approval"); + } + "intervention" => { + conn.execute( + "INSERT INTO agent_org_runtime_member_interventions ( + org_run_id,member_id,agent_id,session_id,status,entered_at, + last_user_activity_at,resume_after + ) VALUES (?1,'coordinator','coordinator-agent',?2, + 'user_intervention',?3,?3,?4)", + params![ + &run_id, + &root, + "2026-08-23T00:00:00Z", + "2026-08-23T00:03:00Z" + ], + ) + .expect("seed intervention"); + } + "pause_continuation" => { + let episode_id = format!("{run_id}-pause"); + let turn_intent_id = format!("{run_id}-turn"); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES (?1,?2,?3,'agent_org','running',?4,?4)", + params![&root, &turn_intent_id, &run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Pause Turn intent"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts ( + session_id,turn_intent_id,org_run_id,participant_id, + turn_kind,source_kind,source_id,activation_generation,created_at + ) VALUES (?1,?2,?3,'coordinator','coordinator','root_turn',?2,1,?4)", + params![&root, &turn_intent_id, &run_id, "2026-08-23T00:00:00Z"], + ) + .expect("seed Pause Turn context"); + conn.execute( + "INSERT INTO agent_org_runtime_pause_episodes ( + episode_id,org_run_id,pause_request_id,pause_generation,status, + teardown_owner_id,created_at,updated_at + ) VALUES (?1,?2,?3,2,'active',?4,?5,?5)", + params![ + &episode_id, + &run_id, + format!("{run_id}-pause-request"), + format!("{run_id}-pause-owner"), + "2026-08-23T00:00:00Z" + ], + ) + .expect("seed Pause episode"); + conn.execute( + "INSERT INTO agent_org_runtime_pause_handoffs ( + handoff_id,episode_id,org_run_id,session_id, + original_turn_intent_id,turn_kind,participant_id, + original_activation_generation,original_intent_status, + drain_status,continuation_turn_intent_id,continuation_status, + created_at,updated_at + ) VALUES (?1,?2,?3,?4,?5,'coordinator','coordinator',1, + 'running','released',?6,'queued',?7,?7)", + params![ + format!("{run_id}-handoff"), + &episode_id, + &run_id, + &root, + &turn_intent_id, + format!("{run_id}-continuation"), + "2026-08-23T00:00:00Z" + ], + ) + .expect("seed Pause continuation"); + } + _ => {} + } + + let trigger = match boundary { + "fence" => format!( + "CREATE TRIGGER fault_archive_fence_{boundary} + BEFORE UPDATE OF status ON agent_org_runtime_runs + WHEN NEW.id='{run_id}' AND NEW.status='archived' + BEGIN SELECT RAISE(ABORT,'fault_archive_fence'); END;" + ), + "episode" => format!( + "CREATE TRIGGER fault_archive_episode_{boundary} + BEFORE INSERT ON agent_org_runtime_archive_episodes + WHEN NEW.org_run_id='{run_id}' + BEGIN SELECT RAISE(ABORT,'fault_archive_episode'); END;" + ), + "teardown" => format!( + "CREATE TRIGGER fault_archive_teardown_{boundary} + BEFORE INSERT ON agent_org_runtime_archive_teardowns + WHEN NEW.org_run_id='{run_id}' + BEGIN SELECT RAISE(ABORT,'fault_archive_teardown'); END;" + ), + "turn" => format!( + "CREATE TRIGGER fault_archive_turn_{boundary} + BEFORE UPDATE OF status ON session_turn_intents + WHEN NEW.org_run_id='{run_id}' AND NEW.status='cancelled' + BEGIN SELECT RAISE(ABORT,'fault_archive_turn'); END;" + ), + "inbox" => format!( + "CREATE TRIGGER fault_archive_inbox_{boundary} + BEFORE INSERT ON agent_org_runtime_inbox_delivery_resolutions + WHEN NEW.org_run_id='{run_id}' + BEGIN SELECT RAISE(ABORT,'fault_archive_inbox'); END;" + ), + "approval" => format!( + "CREATE TRIGGER fault_archive_approval_{boundary} + BEFORE UPDATE OF status ON agent_org_runtime_plan_approvals + WHEN NEW.org_run_id='{run_id}' AND NEW.status='cancelled' + BEGIN SELECT RAISE(ABORT,'fault_archive_approval'); END;" + ), + "intervention" => format!( + "CREATE TRIGGER fault_archive_intervention_{boundary} + BEFORE UPDATE OF cleared_at ON agent_org_runtime_member_interventions + WHEN NEW.org_run_id='{run_id}' AND NEW.cleared_at IS NOT NULL + BEGIN SELECT RAISE(ABORT,'fault_archive_intervention'); END;" + ), + "pause_continuation" => format!( + "CREATE TRIGGER fault_archive_pause_{boundary} + BEFORE UPDATE OF continuation_status ON agent_org_runtime_pause_handoffs + WHEN NEW.org_run_id='{run_id}' AND NEW.continuation_status='skipped' + BEGIN SELECT RAISE(ABORT,'fault_archive_pause'); END;" + ), + "receipt_finalize" => format!( + "CREATE TRIGGER fault_archive_receipt_{boundary} + BEFORE UPDATE OF task_cancel_count ON agent_org_runtime_archive_episodes + WHEN NEW.org_run_id='{run_id}' + BEGIN SELECT RAISE(ABORT,'fault_archive_receipt'); END;" + ), + _ => unreachable!(), + }; + conn.execute_batch(&trigger).expect("install fault trigger"); + drop(conn); + let expected_error = match boundary { + "pause_continuation" => "fault_archive_pause".to_string(), + "receipt_finalize" => "fault_archive_receipt".to_string(), + _ => format!("fault_archive_{boundary}"), + }; + assert_archive_failure_rolled_back(&run_id, &expected_error); + } +} + +#[test] +fn archive_rejects_generation_overflow_without_mutation() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + seed_session("archive-overflow-root", None, COORDINATOR_MEMBER_ID); + seed_run( + "archive-overflow-run", + "archive-overflow-root", + "running", + i64::MAX, + ); + let error = archive_run_commit("archive-overflow-run", &uuid::Uuid::new_v4().to_string()) + .expect_err("generation overflow must fail closed"); + assert!(error.contains("generation overflow")); + assert_eq!( + scalar_string( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + "archive-overflow-run" + ), + "running" + ); +} + +#[test] +fn archive_teardown_is_exactly_bounded_and_retains_failure_evidence() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + let run_id = "archive-retained-run"; + seed_session("archive-retained-root", None, COORDINATOR_MEMBER_ID); + seed_run(run_id, "archive-retained-root", "running", 1); + let commit = + archive_run_commit(run_id, &uuid::Uuid::new_v4().to_string()).expect("Archive commit"); + + for expected_attempt in 1..=3 { + let targets = teardown_targets(&commit.outcome.receipt_id).expect("pending targets"); + assert_eq!(targets.len(), 1); + let summary = record_teardown_attempt( + &targets[0], + Some("retained-lease"), + Some("retained-turn"), + false, + Some("archive_runtime_stop_timeout"), + ) + .expect("record failed attempt"); + assert_eq!(summary.attempt_count, expected_attempt); + } + + let summary = summary_for_run(run_id) + .expect("summary read") + .expect("Archive summary"); + assert_eq!(summary.status, ArchiveTeardownStatus::RetainedRuntime); + assert_eq!(summary.attempt_count, 3); + assert_eq!(summary.retained_runtime_count, 1); + assert!(teardown_targets(&commit.outcome.receipt_id) + .expect("terminal targets") + .is_empty()); + assert!(!pending_receipt_ids(10) + .expect("pending receipts") + .contains(&commit.outcome.receipt_id)); +} + +#[test] +fn archive_teardown_quiesces_only_after_every_captured_session_releases() { + let _sandbox = test_helpers::test_env::sandbox(); + setup(); + let run_id = "archive-quiesced-run"; + let root = "archive-quiesced-root"; + seed_session(root, None, COORDINATOR_MEMBER_ID); + seed_session("archive-quiesced-worker", Some(root), "worker"); + seed_run(run_id, root, "idle", 5); + let commit = + archive_run_commit(run_id, &uuid::Uuid::new_v4().to_string()).expect("Archive commit"); + let targets = teardown_targets(&commit.outcome.receipt_id).expect("captured targets"); + assert_eq!(targets.len(), 2); + + let first = + record_teardown_attempt(&targets[0], None, None, true, None).expect("first release"); + assert_eq!(first.status, ArchiveTeardownStatus::Pending); + let second = + record_teardown_attempt(&targets[1], None, None, true, None).expect("second release"); + assert_eq!(second.status, ArchiveTeardownStatus::Quiesced); + assert_eq!(second.attempt_count, 1); + assert_eq!(second.retained_runtime_count, 0); +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_ownership.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_ownership.rs new file mode 100644 index 0000000000..7aa88ba23e --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_ownership.rs @@ -0,0 +1,334 @@ +//! Canonical ownership resolution for one long-lived Agent Org Team. +//! +//! Archive, Team Delete, and the generic Session Delete guard all use this +//! resolver. A member Session must never fall through to ordinary one-row +//! deletion merely because only the root row carries `root_session_id`. + +use std::collections::{HashMap, HashSet}; + +use rusqlite::{params, Connection, OptionalExtension}; + +use super::agent_org_runs::AgentOrgRunStatus; +use crate::core::session::SessionStatus; + +pub const MAX_AGENT_ORG_OWNED_SESSIONS: usize = 1_024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentOrgOwnedSession { + pub session_id: String, + pub parent_session_id: Option, + pub member_id: Option, + pub status: SessionStatus, + pub depth: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentOrgTeamOwnership { + pub run_id: String, + pub root_session_id: String, + pub run_status: AgentOrgRunStatus, + pub activation_generation: i64, + pub archived_at: Option, + pub archive_receipt_id: Option, + pub sessions: Vec, +} + +struct AgentOrgRunOwnershipRow { + root_session_id: String, + status: String, + activation_generation: i64, + archived_at: Option, + archive_receipt_id: Option, +} + +/// Resolve either a Team root or any descendant member to its single Team. +/// Corrupt cycles, duplicate root claims, and nested Team roots fail closed. +pub fn resolve_team_for_session( + conn: &Connection, + session_id: &str, +) -> Result, String> { + let mut current_id = session_id.to_string(); + let mut visited = HashSet::new(); + let mut candidate_run_id = None; + let mut saw_agent_org_member_marker = false; + + for _ in 0..=MAX_AGENT_ORG_OWNED_SESSIONS { + if !visited.insert(current_id.clone()) { + return Err(format!( + "agent_org_ownership_ambiguous: ancestry cycle at session {current_id}" + )); + } + let run_ids = run_ids_for_root(conn, ¤t_id)?; + if run_ids.len() > 1 { + return Err(format!( + "agent_org_ownership_ambiguous: {} runs claim root session {current_id}", + run_ids.len() + )); + } + if let Some(run_id) = run_ids.into_iter().next() { + if candidate_run_id.replace(run_id).is_some() { + return Err(format!( + "agent_org_ownership_ambiguous: session {session_id} is nested beneath multiple Team roots" + )); + } + } + + let row: Option<(Option, Option)> = conn + .query_row( + "SELECT parent_session_id,org_member_id + FROM agent_sessions WHERE session_id=?1", + [¤t_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((parent, member_id)) = row else { + break; + }; + saw_agent_org_member_marker |= member_id.is_some(); + match parent { + Some(parent) => current_id = parent, + None => break, + } + } + + let Some(run_id) = candidate_run_id else { + if saw_agent_org_member_marker + || descendant_agent_org_member_marker_exists(conn, session_id)? + { + return Err(format!( + "agent_org_ownership_ambiguous: session {session_id} or its descendants have an Agent Org member marker but no owning Team root" + )); + } + return Ok(None); + }; + load_team_for_run(conn, &run_id).map(Some) +} + +fn descendant_agent_org_member_marker_exists( + conn: &Connection, + session_id: &str, +) -> Result { + conn.query_row( + "WITH RECURSIVE descendants(session_id,depth,path,cycle) AS ( + SELECT session_id,0,'/' || hex(session_id) || '/',0 + FROM agent_sessions WHERE session_id=?1 + UNION ALL + SELECT child.session_id,parent.depth + 1, + parent.path || hex(child.session_id) || '/', + instr(parent.path, '/' || hex(child.session_id) || '/') > 0 + FROM agent_sessions child + JOIN descendants parent ON child.parent_session_id=parent.session_id + WHERE parent.cycle=0 AND parent.depth < ?2 + ) + SELECT EXISTS( + SELECT 1 FROM descendants descendant + JOIN agent_sessions session ON session.session_id=descendant.session_id + WHERE descendant.depth>0 AND session.org_member_id IS NOT NULL + )", + params![session_id, MAX_AGENT_ORG_OWNED_SESSIONS as i64], + |row| row.get(0), + ) + .map_err(|error| error.to_string()) +} + +pub fn load_team_for_run(conn: &Connection, run_id: &str) -> Result { + let run: Option = conn + .query_row( + "SELECT root_session_id,status,activation_generation,archived_at,archive_receipt_id + FROM agent_org_runtime_runs WHERE id=?1", + [run_id], + |row| { + Ok(AgentOrgRunOwnershipRow { + root_session_id: row.get(0)?, + status: row.get(1)?, + activation_generation: row.get(2)?, + archived_at: row.get(3)?, + archive_receipt_id: row.get(4)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string())?; + let Some(run) = run else { + return Err(format!("agent_org_run_not_found: {run_id}")); + }; + let run_status = AgentOrgRunStatus::parse(&run.status).ok_or_else(|| { + format!( + "agent_org_ownership_ambiguous: unknown run status {:?}", + run.status + ) + })?; + let sessions = load_owned_sessions(conn, run_id, &run.root_session_id)?; + Ok(AgentOrgTeamOwnership { + run_id: run_id.to_string(), + root_session_id: run.root_session_id, + run_status, + activation_generation: run.activation_generation, + archived_at: run.archived_at, + archive_receipt_id: run.archive_receipt_id, + sessions, + }) +} + +fn run_ids_for_root(conn: &Connection, root_session_id: &str) -> Result, String> { + let mut statement = conn + .prepare( + "SELECT id FROM agent_org_runtime_runs + WHERE root_session_id=?1 ORDER BY id", + ) + .map_err(|error| error.to_string())?; + let run_ids = statement + .query_map([root_session_id], |row| row.get(0)) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + Ok(run_ids) +} + +fn load_owned_sessions( + conn: &Connection, + run_id: &str, + root_session_id: &str, +) -> Result, String> { + let mut statement = conn + .prepare( + "WITH RECURSIVE descendants( + session_id,parent_session_id,org_member_id,status,depth,path,cycle + ) AS ( + SELECT session_id,parent_session_id,org_member_id,status,0, + '/' || hex(session_id) || '/',0 + FROM agent_sessions WHERE session_id=?1 + UNION ALL + SELECT child.session_id,child.parent_session_id,child.org_member_id, + child.status,parent.depth + 1, + parent.path || hex(child.session_id) || '/', + instr(parent.path, '/' || hex(child.session_id) || '/') > 0 + FROM agent_sessions child + JOIN descendants parent ON child.parent_session_id=parent.session_id + WHERE parent.cycle=0 AND parent.depth < ?3 + ) + SELECT descendant.session_id,descendant.parent_session_id, + descendant.org_member_id,descendant.status,descendant.depth, + descendant.cycle, + (SELECT nested.id FROM agent_org_runtime_runs nested + WHERE nested.id<>?2 + AND nested.root_session_id=descendant.session_id + ORDER BY nested.id LIMIT 1), + EXISTS(SELECT 1 FROM agent_sessions child + WHERE child.parent_session_id=descendant.session_id) + FROM descendants descendant", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map( + params![root_session_id, run_id, MAX_AGENT_ORG_OWNED_SESSIONS as i64], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, bool>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, bool>(7)?, + )) + }, + ) + .map_err(|error| error.to_string())?; + + let mut sessions = Vec::new(); + let mut visited = HashSet::new(); + for row in rows { + let ( + session_id, + parent_session_id, + member_id, + status_raw, + depth, + cycle, + nested_run, + has_children, + ) = row.map_err(|error| error.to_string())?; + if cycle || !visited.insert(session_id.clone()) { + return Err(format!( + "agent_org_ownership_ambiguous: Team {run_id} session hierarchy cycles at {session_id}" + )); + } + let depth = usize::try_from(depth).map_err(|error| error.to_string())?; + if depth >= MAX_AGENT_ORG_OWNED_SESSIONS && has_children { + return Err(format!( + "agent_org_ownership_ambiguous: Team {run_id} exceeds {MAX_AGENT_ORG_OWNED_SESSIONS} owned Sessions" + )); + } + if depth > 0 { + if let Some(nested_run_id) = nested_run { + return Err(format!( + "agent_org_ownership_ambiguous: descendant {session_id} is root of nested Team {nested_run_id}" + )); + } + } + let status = SessionStatus::parse(&status_raw).ok_or_else(|| { + format!( + "agent_org_ownership_ambiguous: session {session_id} has unknown status {status_raw:?}" + ) + })?; + sessions.push(AgentOrgOwnedSession { + session_id, + parent_session_id, + member_id, + status, + depth, + }); + if sessions.len() > MAX_AGENT_ORG_OWNED_SESSIONS { + return Err(format!( + "agent_org_ownership_ambiguous: Team {run_id} exceeds {MAX_AGENT_ORG_OWNED_SESSIONS} owned Sessions" + )); + } + } + if sessions.is_empty() + || !sessions + .iter() + .any(|session| session.depth == 0 && session.session_id == root_session_id) + { + return Err(format!( + "agent_org_ownership_ambiguous: Team {run_id} root session {root_session_id} is missing" + )); + } + + let depths = sessions + .iter() + .map(|session| (session.session_id.as_str(), session.depth)) + .collect::>(); + for session in &sessions { + if session.depth == 0 { + continue; + } + let parent = session.parent_session_id.as_deref().ok_or_else(|| { + format!( + "agent_org_ownership_ambiguous: descendant {} has no parent", + session.session_id + ) + })?; + let parent_depth = depths.get(parent).ok_or_else(|| { + format!( + "agent_org_ownership_ambiguous: descendant {} references missing parent {parent}", + session.session_id + ) + })?; + if parent_depth.saturating_add(1) != session.depth { + return Err(format!( + "agent_org_ownership_ambiguous: descendant {} has inconsistent depth", + session.session_id + )); + } + } + sessions.sort_by(|left, right| { + right + .depth + .cmp(&left.depth) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + Ok(sessions) +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_pause.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_pause.rs index 6bc5382d50..88e822e90a 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_pause.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_pause.rs @@ -155,8 +155,7 @@ pub(super) fn create_schema(conn: &Connection) -> rusqlite::Result<()> { (continuation_status IN ('queued','dispatched') AND continuation_turn_intent_id IS NOT NULL AND skip_reason IS NULL) OR - (continuation_status='skipped' - AND continuation_turn_intent_id IS NULL AND skip_reason IS NOT NULL) + (continuation_status='skipped' AND skip_reason IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_pause_capture @@ -214,6 +213,11 @@ pub(crate) fn pause_run_commit(run_id: &str, request_id: &str) -> Result Result Result { let (changed, run_ids) = with_sessions_writer(|| -> Result<(usize, Vec), String> { @@ -640,7 +641,7 @@ impl AgentOrgPlanApprovalStore { OR EXISTS ( SELECT 1 FROM agent_org_runtime_runs run WHERE run.id=approval.org_run_id - AND run.status IN ('failed','archived') + AND run.status='failed' ) )", ) @@ -667,7 +668,7 @@ impl AgentOrgPlanApprovalStore { OR EXISTS ( SELECT 1 FROM agent_org_runtime_runs run WHERE run.id=agent_org_runtime_plan_approvals.org_run_id - AND run.status IN ('failed','archived') + AND run.status='failed' ) )", params![ diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs index 69a5e8963e..32c4eddd9f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs @@ -37,10 +37,9 @@ pub(super) fn create_pending_in_tx( .optional() .map_err(|err| err.to_string())?; if run_status.as_deref() != Some("running") { - return Err(format!( - "agent_org_run_not_mutable: run {} is {}", - params.org_run_id, - run_status.as_deref().unwrap_or("missing") + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + ¶ms.org_run_id, + run_status.as_deref().unwrap_or("missing"), )); } let owner_actor = TaskOwnerExecution::new( diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/helpers.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/helpers.rs index 2d4548168a..c2a040227c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/helpers.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/helpers.rs @@ -62,7 +62,9 @@ pub(super) fn load_by_id(run_id: &str) -> SqliteResult last_activity_outcome, created_at, updated_at, - idled_at + idled_at, + archived_at, + archive_receipt_id FROM agent_org_runtime_runs WHERE id = ?1 LIMIT 1", @@ -95,7 +97,9 @@ pub(super) fn load_by_root_session( last_activity_outcome, created_at, updated_at, - idled_at + idled_at, + archived_at, + archive_receipt_id FROM agent_org_runtime_runs WHERE root_session_id = ?1 ORDER BY created_at DESC @@ -143,6 +147,8 @@ pub(super) fn row_to_run(row: &rusqlite::Row<'_>) -> SqliteResult SqliteRe last_activity_outcome, created_at, updated_at, - idled_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", + idled_at, + archived_at, + archive_receipt_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", params![ &run.id, &run.org_id, @@ -245,6 +253,8 @@ pub(super) fn insert_run(conn: &Connection, run: &AgentOrgRunRecord) -> SqliteRe &run.created_at, &run.updated_at, run.idled_at.as_deref(), + run.archived_at.as_deref(), + run.archive_receipt_id.as_deref(), ], )?; Ok(()) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs index 2546189282..5bbe59603c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs @@ -77,9 +77,6 @@ impl std::fmt::Display for AgentOrgRunEntryMode { pub enum AgentOrgRunStatus { Starting, Running, - /// Reserved non-terminal user-pause state. PR1 freezes the canonical enum - /// but deliberately does not define Pause/Resume handoff behavior; Paused - /// Teams are not fallback-polled. Paused, Idle, Failed, @@ -117,6 +114,18 @@ impl std::fmt::Display for AgentOrgRunStatus { } } +/// Stable write-fence error shared by every Agent Org commit boundary. +/// Archived is product-visible and irreversible, so it receives its own +/// machine-readable prefix; other lifecycle states retain the existing +/// not-mutable contract. +pub(crate) fn mutation_blocked_error(run_id: &str, status: &str) -> String { + if status == AgentOrgRunStatus::Archived.as_str() { + format!("team_archived: Agent Org run {run_id} is read-only") + } else { + format!("agent_org_run_not_mutable: run {run_id} is {status}") + } +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentOrgContextMember { @@ -339,6 +348,8 @@ pub struct AgentOrgRunRecord { pub created_at: String, pub updated_at: String, pub idled_at: Option, + pub archived_at: Option, + pub archive_receipt_id: Option, } #[derive(Debug, Clone)] @@ -418,7 +429,14 @@ pub(crate) fn create_schema(conn: &Connection) -> SqliteResult<()> { )), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - idled_at TEXT + idled_at TEXT, + archived_at TEXT, + archive_receipt_id TEXT UNIQUE, + CHECK( + (status='archived' AND archived_at IS NOT NULL AND archive_receipt_id IS NOT NULL) + OR + (status<>'archived' AND archived_at IS NULL AND archive_receipt_id IS NULL) + ) ); CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_runs_org_updated ON agent_org_runtime_runs(org_id, updated_at); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs index 0d9cbd6d1d..e15480262c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs @@ -92,9 +92,7 @@ impl AgentOrgRunStore { return Err(format!("agent_org_run_not_found: {run_id}")); }; if status != AgentOrgRunStatus::Running.as_str() { - return Err(format!( - "agent_org_run_not_mutable: run {run_id} is {status}" - )); + return Err(super::super::mutation_blocked_error(run_id, &status)); } let unresolved_task_ids = { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs index 733aa737c9..bf6bd72d99 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs @@ -4,13 +4,19 @@ use database::db::{get_connection, with_sessions_writer}; use super::super::helpers::{insert_run, validate_entry_mode, validate_status}; use super::super::progress::ensure_progress_in_conn; -use super::super::{AgentOrgRunRecord, CreateAgentOrgRunParams}; +use super::super::{AgentOrgRunRecord, AgentOrgRunStatus, CreateAgentOrgRunParams}; use super::AgentOrgRunStore; impl AgentOrgRunStore { pub fn create(params: CreateAgentOrgRunParams) -> Result { let entry_mode = validate_entry_mode(params.entry_mode.as_str())?; let status = validate_status(params.status.as_str())?; + if status == AgentOrgRunStatus::Archived { + return Err( + "team_archived_requires_receipt: create the Team in a live state and use Archive" + .to_string(), + ); + } let org_snapshot_json = super::serialize_launch_snapshot(¶ms.org_snapshot)?; let now = chrono::Utc::now().to_rfc3339(); let run = AgentOrgRunRecord { @@ -33,6 +39,8 @@ impl AgentOrgRunStore { created_at: now.clone(), updated_at: now, idled_at: None, + archived_at: None, + archive_receipt_id: None, }; with_sessions_writer(|| -> Result<(), String> { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/queries.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/queries.rs index deb12695e2..9239091d00 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/queries.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/queries.rs @@ -41,7 +41,9 @@ impl AgentOrgRunStore { last_activity_outcome, created_at, updated_at, - idled_at + idled_at, + archived_at, + archive_receipt_id FROM agent_org_runtime_runs WHERE root_session_id IN ({placeholders}) ORDER BY updated_at DESC, id DESC" @@ -87,7 +89,9 @@ impl AgentOrgRunStore { last_activity_outcome, created_at, updated_at, - idled_at + idled_at, + archived_at, + archive_receipt_id FROM agent_org_runtime_runs WHERE root_session_id IS NOT NULL ORDER BY updated_at DESC @@ -140,7 +144,9 @@ impl AgentOrgRunStore { last_activity_outcome, created_at, updated_at, - idled_at + idled_at, + archived_at, + archive_receipt_id FROM agent_org_runtime_runs WHERE root_session_id IS NOT NULL AND status = ?1 diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/starting.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/starting.rs index bf3fd6f39f..a6b389eba2 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/starting.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/starting.rs @@ -46,6 +46,8 @@ impl AgentOrgRunStore { created_at: now.clone(), updated_at: now.clone(), idled_at: None, + archived_at: None, + archive_receipt_id: None, }; let mut member_ids = HashSet::new(); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs index 5b095b9479..021a943029 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs @@ -115,6 +115,7 @@ impl TaskOwnerExecution { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SystemTaskOperation { + ArchiveCancel, RecoveryRequeue, RecoveryFail, ShutdownRelease, @@ -123,6 +124,7 @@ pub(crate) enum SystemTaskOperation { impl SystemTaskOperation { pub(crate) const fn as_wire(self) -> &'static str { match self { + Self::ArchiveCancel => "archive_cancel", Self::RecoveryRequeue => "recovery_requeue", Self::RecoveryFail => "recovery_fail", Self::ShutdownRelease => "shutdown_release", @@ -160,8 +162,37 @@ impl SystemArchiveOrRecovery { org_run_id: &str, target_key: &str, ) -> Result { + if self.operation == SystemTaskOperation::ArchiveCancel { + let archive: Option<(String, i64, i64)> = conn + .query_row( + "SELECT run.status,run.activation_generation,archive.archive_generation + FROM agent_org_runtime_archive_episodes archive + JOIN agent_org_runtime_runs run ON run.id=archive.org_run_id + WHERE archive.org_run_id=?1 AND archive.archive_receipt_id=?2", + params![org_run_id, &self.receipt_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((status, run_generation, archive_generation)) = archive else { + return Err("system_task_archive_receipt_invalid".to_string()); + }; + if status != AgentOrgRunStatus::Archived.as_str() + || run_generation != self.generation + || archive_generation != self.generation + { + return Err("system_task_archive_generation_mismatch".to_string()); + } + return Ok(TaskActorAudit { + kind: TaskActorKind::System, + participant_id: format!("system:{}", self.operation.as_wire()), + turn_intent_id: None, + }); + } + validate_run_and_generation(conn, org_run_id, Some(self.generation))?; let action_kind = match self.operation { + SystemTaskOperation::ArchiveCancel => unreachable!("handled above"), SystemTaskOperation::RecoveryRequeue | SystemTaskOperation::RecoveryFail => { "task_failure_recovery" } @@ -182,6 +213,7 @@ impl SystemArchiveOrRecovery { return Err("system_task_recovery_receipt_invalid".to_string()); }; let operation_matches_receipt = match self.operation { + SystemTaskOperation::ArchiveCancel => unreachable!("handled above"), SystemTaskOperation::RecoveryRequeue => { persisted_action_kind == "task_failure_recovery" && !crate::coordination::agent_org_watchdog::task_failure_recovery_attempts_exhausted(attempts) @@ -210,6 +242,7 @@ impl SystemArchiveOrRecovery { pub(crate) const fn action_kind(&self) -> &'static str { match self.operation { + SystemTaskOperation::ArchiveCancel => "team_archive", SystemTaskOperation::RecoveryRequeue | SystemTaskOperation::RecoveryFail => { "task_failure_recovery" } @@ -262,8 +295,9 @@ fn validate_run_and_generation( let status = AgentOrgRunStatus::parse(&status_raw) .ok_or_else(|| format!("unknown Agent Org run status: {status_raw}"))?; if status != AgentOrgRunStatus::Running { - return Err(format!( - "agent_org_run_not_mutable: run {org_run_id} is {status}" + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + org_run_id, + status.as_str(), )); } if expected_generation != Some(generation) { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs index 3099533672..57c940e8eb 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs @@ -14,10 +14,11 @@ use super::super::helpers::{ list_tasks_with_conn, now_rfc3339, row_to_task, SELECT_COLUMNS, }; use super::super::{ - task_dependency_closure, CreatePendingTaskParams, PendingTaskGraphPatch, Task, - TaskCreateSchedulingPolicy, TaskGraphWriterAdmin, TaskMutationOutcome, TaskOutput, - TaskOutputInput, TaskOwnerExecution, TaskStatus, TaskTerminalReason, TASK_EVENT_CREATED, - TASK_EVENT_UPDATED, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, TASK_MUTATION_CONFLICT_ERROR, + task_dependency_closure, CreatePendingTaskParams, PendingTaskGraphPatch, + SystemArchiveOrRecovery, Task, TaskCreateSchedulingPolicy, TaskGraphWriterAdmin, + TaskMutationOutcome, TaskOutput, TaskOutputInput, TaskOwnerExecution, TaskStatus, + TaskTerminalReason, TASK_EVENT_CREATED, TASK_EVENT_UPDATED, + TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, TASK_MUTATION_CONFLICT_ERROR, TASK_TERMINAL_IMMUTABLE_ERROR, }; use super::dependencies::canonicalize_dependencies; @@ -27,6 +28,49 @@ use super::validation::{ use super::AgentOrgTaskStore; impl AgentOrgTaskStore { + /// Cancel every non-terminal Task as part of the caller-owned Archive + /// transaction. The Archive receipt is the typed system authority; Task + /// rows and their audit events therefore commit or roll back with the + /// Team terminal fence. + pub(crate) fn cancel_open_for_archive_with_connection( + tx: &rusqlite::Transaction<'_>, + actor: &SystemArchiveOrRecovery, + org_run_id: &str, + reason: &TaskTerminalReason, + ) -> Result { + let audit = actor.validate(tx, org_run_id, "all_open_tasks")?; + let mut tasks = list_tasks_with_conn(tx, org_run_id)?; + let now = now_rfc3339(); + let mut cancelled = 0usize; + for task in &mut tasks { + if !task.status.is_open() { + continue; + } + let previous = task.clone(); + task.status = TaskStatus::Cancelled; + task.output = None; + task.failure_reason = None; + task.cancel_reason = Some(reason.clone()); + task.updated_at = now.clone(); + validate_task_model_invariants(tx, task)?; + update_task_row(tx, task)?; + insert_task_history_event_as( + tx, + org_run_id, + &task.id, + TASK_EVENT_UPDATED, + Some(&previous), + task, + &audit, + )?; + cancelled += 1; + } + if cancelled > 0 { + crate::coordination::agent_org_runs::bump_work_revision_in_tx(tx, org_run_id)?; + } + Ok(cancelled) + } + pub fn create_pending_with_transactional_effects( actor: TaskGraphWriterAdmin, params: CreatePendingTaskParams, diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs index a53ba40253..c0ef5b1fad 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs @@ -78,8 +78,8 @@ pub(super) fn ensure_run_allows_task_mutation( None => return Err(format!("agent_org_run_not_found: {org_run_id}")), }; if status != "running" { - return Err(format!( - "agent_org_run_not_mutable: run {org_run_id} is {status}", + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + org_run_id, &status, )); } Ok(()) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs index d3551ae848..2c7d316bf5 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs @@ -199,8 +199,11 @@ fn task_mutations_require_running_parent_run() { )) .expect("running run permits create"); conn.execute( - "UPDATE agent_org_runtime_runs SET status='archived' WHERE id='guarded-run'", - [], + "UPDATE agent_org_runtime_runs + SET status='archived',activation_generation=activation_generation+1, + archived_at=?1,archive_receipt_id='guarded-run-archive-receipt' + WHERE id='guarded-run'", + [&now], ) .unwrap(); assert!(AgentOrgTaskStore::update( @@ -212,10 +215,10 @@ fn task_mutations_require_running_parent_run() { }, ) .unwrap_err() - .contains("agent_org_run_not_mutable")); + .contains("team_archived")); assert!(AgentOrgTaskStore::delete("guarded-run", "guarded-task") .unwrap_err() - .contains("agent_org_run_not_mutable")); + .contains("team_archived")); } #[test] diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs index 353845b173..66873a581f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs @@ -14,6 +14,30 @@ use super::agent_org_runs::{AgentOrgRunStatus, COORDINATOR_MEMBER_ID}; const TASK_WAKE_CANDIDATE_LIMIT: i64 = crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS as i64 + 1; +const TASK_ASSISTANT_PERSISTENCE_TARGET_SQL: &str = "SELECT task.status, + EXISTS( + SELECT 1 + FROM agent_org_runtime_task_events event + WHERE event.org_run_id=task.org_run_id + AND event.task_id=task.id + AND event.previous_owner=?3 + AND event.next_owner=?3 + AND event.previous_status='in_progress' + AND event.next_status=task.status + AND event.actor_kind='owner_execution' + AND event.actor_member_id=?3 + AND event.source_turn_intent_id=?4 + AND event.created_at=task.updated_at + AND event.rowid=( + SELECT MAX(latest.rowid) + FROM agent_org_runtime_task_events latest + WHERE latest.org_run_id=task.org_run_id + AND latest.task_id=task.id + ) + ) + FROM agent_org_runtime_tasks task + WHERE task.org_run_id=?1 AND task.id=?2 AND task.owner=?3"; + pub(crate) const TURN_CONTEXT_INVARIANT_PREFIX: &str = "agent_org_turn_context_invalid:"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -499,6 +523,52 @@ pub(crate) fn revalidate_context_with_connection( conn: &Connection, session_id: &str, turn_intent_id: &str, +) -> Result { + let context = revalidate_live_formal_context_with_connection(conn, session_id, turn_intent_id)?; + if context.turn_kind == AgentOrgTurnKind::TaskExecution { + let task_id = context + .task_id + .as_deref() + .ok_or_else(|| invariant_error("TaskExecution context has no task_id".to_string()))?; + let owner_member_id = context.owner_member_id.as_deref().ok_or_else(|| { + invariant_error("TaskExecution context has no owner_member_id".to_string()) + })?; + validate_task_execution_target(conn, &context.org_run_id, task_id, owner_member_id)?; + } + Ok(context) +} + +/// Re-check the authority for persisting one assistant iteration. This is a +/// later lifecycle phase than execution admission: the exact running Turn may +/// have already completed or failed its Task, but no other Turn or actor may +/// use that terminal Task as transcript authority. +pub(crate) fn revalidate_assistant_persistence_with_connection( + conn: &Connection, + session_id: &str, + turn_intent_id: &str, +) -> Result { + let context = revalidate_live_formal_context_with_connection(conn, session_id, turn_intent_id)?; + validate_assistant_persistence_base_turn(conn, &context)?; + if context.turn_kind == AgentOrgTurnKind::TaskExecution { + let task_id = context + .task_id + .as_deref() + .ok_or_else(|| invariant_error("TaskExecution context has no task_id".to_string()))?; + let owner_member_id = context.owner_member_id.as_deref().ok_or_else(|| { + invariant_error("TaskExecution context has no owner_member_id".to_string()) + })?; + validate_task_assistant_persistence_target(conn, &context, task_id, owner_member_id)?; + } + Ok(context) +} + +/// Common formal-Turn authority shared by execution admission and assistant +/// persistence. Target-state rules intentionally stay in the two public phase +/// validators above. +fn revalidate_live_formal_context_with_connection( + conn: &Connection, + session_id: &str, + turn_intent_id: &str, ) -> Result { let context = require_context_with_connection(conn, session_id, turn_intent_id)?; let run: Option<(Option, Option, i64, String)> = conn @@ -518,6 +588,12 @@ pub(crate) fn revalidate_context_with_connection( }; let status = AgentOrgRunStatus::parse(&status_raw) .ok_or_else(|| invariant_error(format!("unknown run status {status_raw:?}")))?; + if status == AgentOrgRunStatus::Archived { + return Err(format!( + "team_archived: Agent Org run {} is read-only", + context.org_run_id + )); + } if status != AgentOrgRunStatus::Running { return Err(invariant_error(format!( "Turn execution requires a running Team, found {status}" @@ -560,6 +636,8 @@ pub(crate) fn revalidate_context_with_connection( })?; if context.participant_id != owner_member_id || context.dispatch_member_id.as_deref() != Some(owner_member_id) + || context.source_kind != AgentOrgTurnSourceKind::Task + || context.source_id != task_id || context.activation_generation != Some(generation) { return Err(invariant_error( @@ -568,7 +646,6 @@ pub(crate) fn revalidate_context_with_connection( } let agent_id = snapshot_member_agent_id(&snapshot, owner_member_id)?; resolve_materialization_version_for_context(conn, &context, owner_member_id, agent_id)?; - validate_task_execution_target(conn, &context.org_run_id, task_id, owner_member_id)?; } AgentOrgTurnKind::UserDirectedWork => { return Err(invariant_error( @@ -579,6 +656,38 @@ pub(crate) fn revalidate_context_with_connection( Ok(context) } +fn validate_assistant_persistence_base_turn( + conn: &Connection, + context: &AgentOrgTurnContext, +) -> Result<(), String> { + let base: Option<(Option, String)> = conn + .query_row( + "SELECT org_run_id,status FROM session_turn_intents + WHERE session_id=?1 AND turn_intent_id=?2", + params![&context.session_id, &context.turn_intent_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((base_run_id, base_status)) = base else { + return Err(invariant_error( + "assistant persistence has no base Turn".to_string(), + )); + }; + if base_run_id.as_deref() != Some(context.org_run_id.as_str()) { + return Err(invariant_error(format!( + "assistant persistence base Turn belongs to another run, expected {}", + context.org_run_id + ))); + } + if base_status != TurnIntentBridgeStatus::Running.as_str() { + return Err(invariant_error(format!( + "assistant persistence requires the current running Turn, found {base_status}" + ))); + } + Ok(()) +} + /// Resolve the persisted TaskExecution identity used by failure recovery. /// A session-level status or Member id is never sufficient: the failed Turn /// must name one Task, Owner, run, source, and activation generation. @@ -894,6 +1003,46 @@ fn validate_task_execution_target( } } +fn validate_task_assistant_persistence_target( + conn: &Connection, + context: &AgentOrgTurnContext, + task_id: &str, + owner_member_id: &str, +) -> Result<(), String> { + let target: Option<(String, bool)> = conn + .query_row( + TASK_ASSISTANT_PERSISTENCE_TARGET_SQL, + params![ + &context.org_run_id, + task_id, + owner_member_id, + &context.turn_intent_id + ], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + match target.as_ref().map(|(status, exact_terminal)| (status.as_str(), *exact_terminal)) { + Some(("pending", _)) + if task_is_pending_and_ready(conn, &context.org_run_id, task_id, owner_member_id)? => + { + Ok(()) + } + Some(("in_progress", _)) => Ok(()), + Some(("completed" | "failed", true)) => Ok(()), + Some(("completed" | "failed", false)) => Err(invariant_error(format!( + "TaskExecution target {task_id} terminal provenance does not belong to Turn {}", + context.turn_intent_id + ))), + Some((status, _)) => Err(invariant_error(format!( + "TaskExecution target {task_id} cannot authorize assistant persistence (status {status})" + ))), + None => Err(invariant_error(format!( + "TaskExecution target {task_id} is missing or no longer owned by {owner_member_id}" + ))), + } +} + /// Connection-scoped admission for lifecycle owners that already hold an /// IMMEDIATE transaction (notably Starting completion). pub(crate) fn accept_with_connection( @@ -1043,6 +1192,11 @@ fn resolve_canonical_admission( "Starting authority mismatch: expected generation {expected}, current generation {generation}, status {status}" ))); } + } else if status == AgentOrgRunStatus::Archived { + return Err(format!( + "team_archived: Agent Org run {} is read-only", + request.org_run_id + )); } else if status != AgentOrgRunStatus::Running { return Err(invariant_error(format!( "Coordinator Turn requires a running Team, found {status}" @@ -1072,6 +1226,12 @@ fn resolve_canonical_admission( owner_member_id, activation_generation, } => { + if status == AgentOrgRunStatus::Archived { + return Err(format!( + "team_archived: Agent Org run {} is read-only", + request.org_run_id + )); + } if status != AgentOrgRunStatus::Running || generation != *activation_generation { return Err(invariant_error(format!( "TaskExecution authority mismatch for generation {activation_generation}; current generation {generation}, status {status}" @@ -1110,6 +1270,12 @@ fn resolve_canonical_admission( dispatch_member_id, source, } => { + if status == AgentOrgRunStatus::Archived { + return Err(format!( + "team_archived: Agent Org run {} is read-only", + request.org_run_id + )); + } if matches!( status, AgentOrgRunStatus::Starting @@ -1517,6 +1683,12 @@ pub(crate) fn validate_formal_turn_generation_with_connection( let Some((status, generation)) = run else { return Err(invariant_error("formal Turn run disappeared".to_string())); }; + if status == AgentOrgRunStatus::Archived.as_str() { + return Err(format!( + "team_archived: Agent Org run {} is read-only", + context.org_run_id + )); + } if status != AgentOrgRunStatus::Running.as_str() || context.activation_generation != Some(generation) { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs index 7eb74bd4bb..e5135d7c0f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs @@ -112,9 +112,26 @@ fn create_fixture(conn: &Connection) { status TEXT NOT NULL DEFAULT 'pending', execution_mode TEXT NOT NULL DEFAULT 'build', blocked_by_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL DEFAULT 'now', PRIMARY KEY(org_run_id, id), FOREIGN KEY(org_run_id) REFERENCES agent_org_runtime_runs(id) ON DELETE CASCADE ); + CREATE TABLE agent_org_runtime_task_events ( + id TEXT PRIMARY KEY, + org_run_id TEXT NOT NULL, + task_id TEXT NOT NULL, + event_type TEXT NOT NULL, + previous_owner TEXT, + next_owner TEXT, + previous_status TEXT, + next_status TEXT, + actor_member_id TEXT, + actor_kind TEXT NOT NULL, + source_turn_intent_id TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX idx_agent_org_runtime_task_events_task + ON agent_org_runtime_task_events(org_run_id, task_id, created_at, id); CREATE TABLE agent_org_runtime_inbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipient_agent_id TEXT NOT NULL DEFAULT 'agent-member', @@ -393,6 +410,257 @@ fn member_wake_binds_oldest_dependency_ready_assignment_and_revalidates_at_start assert!(error.contains("no longer owned"), "{error}"); } +#[test] +fn assistant_persistence_accepts_only_exact_same_turn_terminal_provenance() { + let mut conn = connection(); + let turn_id = "turn-final-assistant"; + accept_in_transaction(&mut conn, &task_request(turn_id)).expect("accept TaskExecution Turn"); + conn.execute( + "UPDATE session_turn_intents SET status='running' + WHERE session_id=?1 AND turn_intent_id=?2", + params![MEMBER_SESSION_ID, turn_id], + ) + .expect("promote Turn to running"); + conn.execute( + "UPDATE agent_org_runtime_tasks + SET status='completed',updated_at='completed-at' + WHERE org_run_id=?1 AND id='task-a'", + [RUN_ID], + ) + .expect("complete Task"); + conn.execute( + "INSERT INTO agent_org_runtime_task_events ( + id,org_run_id,task_id,event_type,previous_owner,next_owner, + previous_status,next_status,actor_member_id,actor_kind, + source_turn_intent_id,created_at + ) VALUES ( + 'event-completed',?1,'task-a','updated',?2,?2, + 'in_progress','completed',?2,'owner_execution',?3,'completed-at' + )", + params![RUN_ID, MEMBER_ID, turn_id], + ) + .expect("record exact terminal provenance"); + + let admission_error = revalidate_context_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("completed Task must remain closed to execution admission"); + assert!( + admission_error.contains("not runnable (status completed)"), + "{admission_error}" + ); + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect("same completing Turn may persist its final assistant iteration"); + + conn.execute( + "INSERT INTO agent_org_runtime_task_events ( + id,org_run_id,task_id,event_type,previous_owner,next_owner, + previous_status,next_status,actor_member_id,actor_kind, + source_turn_intent_id,created_at + ) VALUES ( + 'event-later-system',?1,'task-a','updated',?2,?2, + 'completed','completed','system:recovery','system',NULL,'later-at' + )", + params![RUN_ID, MEMBER_ID], + ) + .expect("record a later non-owner Task mutation"); + let stale_terminal = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("an older exact terminal event must not outrank a later mutation"); + assert!( + stale_terminal.contains("terminal provenance"), + "{stale_terminal}" + ); + conn.execute( + "DELETE FROM agent_org_runtime_task_events WHERE id='event-later-system'", + [], + ) + .expect("remove later mutation for the remaining provenance cases"); + + conn.execute( + "UPDATE agent_org_runtime_task_events + SET source_turn_intent_id='turn-other' + WHERE id='event-completed'", + [], + ) + .expect("replace terminal provenance with another Turn"); + let other_turn = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("same participant but another Turn must not authorize persistence"); + assert!(other_turn.contains("terminal provenance"), "{other_turn}"); + + conn.execute( + "UPDATE agent_org_runtime_task_events + SET source_turn_intent_id=?1,actor_kind='system' + WHERE id='event-completed'", + [turn_id], + ) + .expect("replace owner provenance with system actor"); + let system_actor = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("system terminal mutation must not authorize owner final output"); + assert!( + system_actor.contains("terminal provenance"), + "{system_actor}" + ); + + conn.execute( + "UPDATE agent_org_runtime_task_events + SET actor_kind='owner_execution' + WHERE id='event-completed'", + [], + ) + .expect("restore exact owner provenance"); + conn.execute( + "UPDATE session_turn_intents SET status='completed' + WHERE session_id=?1 AND turn_intent_id=?2", + params![MEMBER_SESSION_ID, turn_id], + ) + .expect("make base Turn terminal"); + let terminal_base = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("terminal base Turn must not keep writing assistant iterations"); + assert!( + terminal_base.contains("requires the current running Turn"), + "{terminal_base}" + ); +} + +#[test] +fn assistant_persistence_allows_exact_owner_failure_but_rejects_cancel_and_actor_drift() { + let mut conn = connection(); + let turn_id = "turn-failed-assistant"; + accept_in_transaction(&mut conn, &task_request(turn_id)).expect("accept TaskExecution Turn"); + conn.execute( + "UPDATE session_turn_intents SET status='running' + WHERE session_id=?1 AND turn_intent_id=?2", + params![MEMBER_SESSION_ID, turn_id], + ) + .expect("promote Turn to running"); + conn.execute( + "UPDATE agent_org_runtime_tasks + SET status='failed',updated_at='failed-at' + WHERE org_run_id=?1 AND id='task-a'", + [RUN_ID], + ) + .expect("fail Task"); + conn.execute( + "INSERT INTO agent_org_runtime_task_events ( + id,org_run_id,task_id,event_type,previous_owner,next_owner, + previous_status,next_status,actor_member_id,actor_kind, + source_turn_intent_id,created_at + ) VALUES ( + 'event-failed',?1,'task-a','updated',?2,?2, + 'in_progress','failed',?2,'owner_execution',?3,'failed-at' + )", + params![RUN_ID, MEMBER_ID, turn_id], + ) + .expect("record exact owner failure provenance"); + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect("owner may explain a failure committed by this exact Turn"); + + conn.execute( + "UPDATE agent_org_runtime_tasks + SET status='cancelled',updated_at='cancelled-at' + WHERE org_run_id=?1 AND id='task-a'", + [RUN_ID], + ) + .expect("cancel Task"); + let cancelled = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("cancelled Task never authorizes owner final output"); + assert!( + cancelled.contains("cannot authorize assistant persistence (status cancelled)"), + "{cancelled}" + ); + + conn.execute( + "UPDATE agent_org_runtime_tasks + SET status='failed',updated_at='failed-at',owner='member-reassigned' + WHERE org_run_id=?1 AND id='task-a'", + [RUN_ID], + ) + .expect("reassign terminal Task"); + let reassigned = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("owner drift must invalidate the old Turn"); + assert!(reassigned.contains("no longer owned"), "{reassigned}"); +} + +#[test] +fn assistant_persistence_rejects_generation_and_materialization_drift() { + let mut conn = connection(); + let turn_id = "turn-drift-assistant"; + accept_in_transaction(&mut conn, &task_request(turn_id)).expect("accept TaskExecution Turn"); + conn.execute( + "UPDATE session_turn_intents SET status='running' + WHERE session_id=?1 AND turn_intent_id=?2", + params![MEMBER_SESSION_ID, turn_id], + ) + .expect("promote Turn to running"); + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect("current in-progress Task may persist assistant output"); + + conn.execute( + "UPDATE agent_org_runtime_runs SET activation_generation=2 WHERE id=?1", + [RUN_ID], + ) + .expect("advance activation generation"); + let generation = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("stale formal generation must fail closed"); + assert!( + generation.contains("participant/generation"), + "{generation}" + ); + conn.execute( + "UPDATE agent_org_runtime_runs SET activation_generation=1 WHERE id=?1", + [RUN_ID], + ) + .expect("restore activation generation"); + + conn.execute_batch( + "INSERT INTO agent_sessions VALUES + ('session-member-new', 'agent-member', 'member-a'); + INSERT INTO agent_org_runtime_member_materializations + (org_run_id, member_id, agent_id, generation, session_id, status) + VALUES + ('run-a', 'member-a', 'agent-member', 2, 'session-member-new', 'succeeded');", + ) + .expect("replace canonical Member materialization"); + let materialization = + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("replaced Member Session must fail closed"); + assert!( + materialization.contains("not the latest canonical materialization"), + "{materialization}" + ); +} + +#[test] +fn assistant_terminal_provenance_query_is_task_index_bounded() { + let conn = connection(); + let explain = format!("EXPLAIN QUERY PLAN {TASK_ASSISTANT_PERSISTENCE_TARGET_SQL}"); + let mut statement = conn.prepare(&explain).expect("prepare query plan"); + let details = statement + .query_map(params![RUN_ID, "task-a", MEMBER_ID, "turn-a"], |row| { + row.get::<_, String>(3) + }) + .expect("query plan rows") + .collect::>>() + .expect("decode query plan"); + assert!( + details + .iter() + .any(|detail| detail.contains("idx_agent_org_runtime_task_events_task")), + "task event lookup must use the exact run/task index: {details:?}" + ); + assert!( + details + .iter() + .all(|detail| !detail.contains("SCAN agent_org_runtime_task_events")), + "task event lookup must not scan the full history table: {details:?}" + ); +} + #[test] fn durable_pause_continuation_excludes_parallel_ordinary_wake_until_terminal() { let mut conn = connection(); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs index 61565a6e67..00f574d16c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs @@ -28,6 +28,8 @@ fn fake_run(id: &str) -> AgentOrgRunRecord { created_at: now.clone(), updated_at: now, idled_at: None, + archived_at: None, + archive_receipt_id: None, } } @@ -169,14 +171,17 @@ fn running_query_is_limited_and_never_visits_quiet_states() { conn.execute( "INSERT INTO agent_org_runtime_runs ( id, org_id, coordinator_agent_id, root_session_id, entry_mode, status, - created_at, updated_at + created_at, updated_at,archived_at,archive_receipt_id ) VALUES (?1, 'watchdog-org', 'coordinator', ?2, 'standalone_session', - ?3, ?4, ?4)", + ?3, ?4, ?4, + CASE WHEN ?3='archived' THEN ?4 ELSE NULL END, + CASE WHEN ?3='archived' THEN ?5 ELSE NULL END)", params![ format!("quiet-{status}"), format!("root-quiet-{status}"), status, - &now + &now, + format!("quiet-{status}-archive-receipt") ], ) .expect("seed quiet run"); diff --git a/src-tauri/crates/agent-core/src/core/coordination/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/mod.rs index e1d465a005..00362f2052 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/mod.rs @@ -21,6 +21,10 @@ pub mod agent_inbox; pub mod agent_member_interventions; +pub mod agent_org_archive; +#[cfg(test)] +mod agent_org_archive_tests; +pub(crate) mod agent_org_ownership; pub mod agent_org_pause; pub mod agent_org_payload_limits; pub mod agent_org_plan_approvals; diff --git a/src-tauri/crates/agent-core/src/core/coordination/schema.rs b/src-tauri/crates/agent-core/src/core/coordination/schema.rs index ee2c27517a..a129ec87b5 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/schema.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/schema.rs @@ -11,11 +11,12 @@ use std::collections::BTreeMap; use rusqlite::{ffi, Connection, Error as SqliteError, Result as SqliteResult}; use super::{ - agent_inbox, agent_member_interventions, agent_org_pause, agent_org_plan_approvals, - agent_org_runs, agent_org_tasks, agent_org_turn_contexts, agent_org_watchdog, + agent_inbox, agent_member_interventions, agent_org_archive, agent_org_pause, + agent_org_plan_approvals, agent_org_runs, agent_org_tasks, agent_org_turn_contexts, + agent_org_watchdog, }; -const RUNTIME_TABLES: [&str; 17] = [ +const RUNTIME_TABLES: [&str; 19] = [ "agent_org_runtime_runs", "agent_org_runtime_run_progress", "agent_org_runtime_member_materializations", @@ -33,6 +34,8 @@ const RUNTIME_TABLES: [&str; 17] = [ "agent_org_runtime_turn_contexts", "agent_org_runtime_pause_episodes", "agent_org_runtime_pause_handoffs", + "agent_org_runtime_archive_episodes", + "agent_org_runtime_archive_teardowns", ]; const LEGACY_TABLES: [&str; 13] = [ @@ -131,7 +134,8 @@ fn create_runtime_schema(conn: &Connection) -> SqliteResult<()> { agent_member_interventions::create_schema(conn)?; agent_org_watchdog::create_schema(conn)?; agent_org_turn_contexts::create_schema(conn)?; - agent_org_pause::create_schema(conn) + agent_org_pause::create_schema(conn)?; + agent_org_archive::create_schema(conn) } fn expected_manifest() -> SqliteResult { @@ -613,7 +617,7 @@ mod tests { DROP TABLE agent_org_runtime_member_dispatch_allocators;", ) .expect("make partial schema"); - assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 15); + assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 17); } "changed" => { conn.execute_batch( @@ -641,21 +645,21 @@ mod tests { } #[test] - fn previous_fifteen_table_manifest_requires_an_isolated_database() { + fn previous_seventeen_table_manifest_requires_an_isolated_database() { let conn = connection(); initialize(&conn).expect("canonical pause runtime"); conn.execute_batch( "DROP TABLE agent_org_runtime_pause_handoffs; DROP TABLE agent_org_runtime_pause_episodes;", ) - .expect("simulate the previous strict fifteen-table manifest"); - assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 15); + .expect("simulate the previous strict seventeen-table manifest"); + assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 17); let error = initialize(&conn).expect_err("previous runtime must not be migrated in place"); assert!( error .to_string() - .contains("found 15 of 17 canonical tables"), + .contains("found 17 of 19 canonical tables"), "unexpected strict-schema error: {error}" ); } @@ -724,7 +728,7 @@ mod tests { let conn = Connection::open(path).expect("reopen shared database"); verify_manifest(&conn, &expected_manifest().expect("expected manifest")) .expect("canonical manifest after concurrent init"); - assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 17); + assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 19); } #[test] diff --git a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs index b702840dab..ceecce3b91 100644 --- a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs +++ b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs @@ -17,6 +17,7 @@ const ADDRESS_COMMENT_ID_MARKER: &str = " — id: "; const REPLY_SESSION_COMMENT_TOOL: &str = "reply_session_comment"; const AGENT_ORG_TASK_FSM_MARKER: &str = "E2E_AGENT_ORG_TASK_FSM:"; const AGENT_ORG_PAUSE_MARKER: &str = "E2E_AGENT_ORG_PAUSE:"; +const AGENT_ORG_ARCHIVE_STOP_TIMEOUT_MARKER: &str = "E2E_AGENT_ORG_ARCHIVE_STOP_TIMEOUT:"; const CONTROL_WAIT_MARKER: &str = "Create a stoppable window by waiting for about "; const TASK_GRAPH_CREATE_TOOL: &str = "task_graph_create"; const TASK_UPDATE_TOOL: &str = "task_update"; @@ -468,6 +469,11 @@ impl E2eFakeProvider { .any(|message| message.get("role").and_then(Value::as_str) == Some("tool")) } + fn archive_stop_timeout_required(messages: &[Value]) -> bool { + latest_model_user(messages) + .is_some_and(|content| content.contains(AGENT_ORG_ARCHIVE_STOP_TIMEOUT_MARKER)) + } + fn build_response(messages: &[Value], tools: Option<&[Value]>) -> LLMResponse { let mut tool_calls = Self::address_comment_tool_calls(messages, tools); if tool_calls.is_empty() { @@ -692,6 +698,11 @@ impl LLMProvider for E2eFakeProvider { sleep(Duration::from_millis(25)).await; } } => { + if Self::archive_stop_timeout_required(messages) { + // Debug-only fault injection: keep the provider call alive + // beyond Archive's absolute 60-second teardown deadline. + sleep(Duration::from_secs(65)).await; + } // Keep the rendered Draining phase observable while // still proving ten providers yield in parallel. sleep(Duration::from_millis(350)).await; @@ -848,6 +859,24 @@ mod tests { ); } + #[test] + fn archive_stop_timeout_fault_injection_requires_explicit_marker() { + let ordinary = vec![json!({ + "role": "user", + "content": "Create a stoppable window by waiting for about 45 seconds before the final answer." + })]; + let fault = vec![json!({ + "role": "user", + "content": concat!( + "E2E_AGENT_ORG_ARCHIVE_STOP_TIMEOUT:retained-runtime\n", + "Create a stoppable window by waiting for about 60 seconds before the final answer." + ) + })]; + + assert!(!E2eFakeProvider::archive_stop_timeout_required(&ordinary)); + assert!(E2eFakeProvider::archive_stop_timeout_required(&fault)); + } + #[test] fn session_memory_compaction_does_not_replay_task_fsm_markers() { let messages = vec![json!({ diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs index 26f2754f39..1775631dc7 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs @@ -963,6 +963,11 @@ pub(crate) fn finish_session_delete(session_id: &str) { } fn cleanup_session_derived_resources(session_id: &str) { + // The active-session registry is a derived filesystem projection. A hard + // delete must remove it immediately; otherwise the deleted session keeps + // advertising itself as running until the next process-start stale sweep. + crate::session::file_registry::unregister_session(session_id); + // Per-session file-history is addressed by session_id alone, so drop the // whole directory regardless of workspace_path. Other sessions on the same // project are untouched. diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index e0d1b46487..2f22226c01 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -146,6 +146,35 @@ fn materialize_agent_org_inbox_transcript_internal( )?; } + // A transcript materialization is an Inbox claim, not a history + // read. Archive may resolve the source rows while a pre-Archive drain + // is still assembling its prompt, so re-read every owning Run inside + // this same writer transaction before creating either transcript or + // receipt rows. + { + let mut stmt = tx + .prepare( + "SELECT inbox.org_run_id,run.status + FROM agent_org_runtime_inbox inbox + LEFT JOIN agent_org_runtime_runs run ON run.id=inbox.org_run_id + WHERE inbox.id=?1", + ) + .map_err(|err| err.to_string())?; + for inbox_id in inbox_ids { + let source: Option<(Option, Option)> = stmt + .query_row(params![inbox_id], |row| Ok((row.get(0)?, row.get(1)?))) + .optional() + .map_err(|err| err.to_string())?; + if let Some((Some(run_id), status)) = source { + if status.as_deref() == Some("archived") { + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + &run_id, "archived", + )); + } + } + } + } + let mut existing_receipts = Vec::new(); { let mut stmt = tx @@ -304,6 +333,39 @@ pub fn save_assistant_msg(session_id: &str, content: &str, model: &str) -> Sqlit shared::save_assistant_msg(SESSION_TABLE_PREFIX, session_id, content, model) } +/// Persist an Agent Org assistant iteration only while its exact durable Turn +/// still owns a mutable Team generation. The revalidation and transcript +/// insert share one `BEGIN IMMEDIATE` transaction, so Archive cannot commit in +/// the gap between the check and the write. +pub fn save_agent_org_assistant_msg_for_turn( + session_id: &str, + turn_intent_id: &str, + content: &str, + model: &str, +) -> Result { + with_sessions_writer(|| { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + crate::coordination::agent_org_turn_contexts::revalidate_assistant_persistence_with_connection( + &tx, + session_id, + turn_intent_id, + )?; + let message_id = shared::save_assistant_msg_with_connection( + &tx, + SESSION_TABLE_PREFIX, + session_id, + content, + model, + ) + .map_err(|error| error.to_string())?; + tx.commit().map_err(|error| error.to_string())?; + Ok(message_id) + }) +} + /// Save a persisted compact summary boundary. /// /// Unlike runtime stable/dynamic system prompts, this row is part of the durable @@ -1015,6 +1077,364 @@ mod tests { .expect("seed session row"); } + #[test] + fn archived_turn_cannot_persist_a_late_assistant_iteration() { + let _sandbox = test_env::sandbox(); + let session_id = "archive-assistant-root"; + let run_id = "archive-assistant-run"; + let turn_intent_id = "archive-assistant-turn"; + seed_session_for_message_tests(session_id); + crate::session::persistence::upsert_session( + &crate::session::persistence::UnifiedSessionRecord { + session_id: session_id.to_string(), + name: "Archive assistant coordinator".to_string(), + status: crate::session::SessionStatus::Running.as_str().to_string(), + session_type: "agent".to_string(), + agent_definition_id: Some("builtin:sde".to_string()), + org_member_id: Some("coordinator".to_string()), + created_at: "2026-08-23T00:00:00Z".to_string(), + updated_at: "2026-08-23T00:00:00Z".to_string(), + ..Default::default() + }, + ) + .expect("seed canonical coordinator Session"); + let conn = get_connection().expect("sandbox DB"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) + );", + ) + .expect("Turn intent schema"); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); + let now = "2026-08-23T00:00:00Z"; + let snapshot = serde_json::json!({ + "schemaVersion": 1, + "orgId": "org-archive-assistant", + "orgName": "Archive Assistant Test", + "coordinatorRole": "Lead", + "coordinatorAgentId": "builtin:sde", + "planApprovalPolicy": "coordinator", + "members": [{ + "memberId": "worker", + "name": "Worker", + "role": "Builder", + "agentId": "builtin:sde" + }], + "additionalTaskGraphWriterMemberIds": [], + "memberCommunicationLinks": [] + }) + .to_string(); + conn.execute( + "INSERT INTO agent_org_runtime_runs ( + id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, + entry_mode,status,activation_generation,created_at,updated_at + ) VALUES (?1,'org-archive-assistant','builtin:sde',?2,?3, + 'standalone_session','running',1,?4,?4)", + params![run_id, session_id, snapshot, now], + ) + .expect("seed Run"); + conn.execute( + "INSERT INTO agent_org_runtime_member_materializations ( + org_run_id,member_id,agent_id,generation,session_id, + authority_class,status,created_at,updated_at + ) VALUES (?1,'coordinator','builtin:sde',1,?2, + 'formal','succeeded',?3,?3)", + params![run_id, session_id, now], + ) + .expect("seed coordinator materialization"); + let materialization_fixture: ( + String, + String, + String, + String, + String, + Option, + Option, + ) = conn + .query_row( + "SELECT materialization.org_run_id,materialization.member_id, + materialization.agent_id,materialization.session_id, + materialization.status,session.agent_definition_id, + session.org_member_id + FROM agent_org_runtime_member_materializations materialization + JOIN agent_sessions session + ON session.session_id=materialization.session_id + WHERE materialization.org_run_id=?1 AND materialization.session_id=?2", + params![run_id, session_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + ) + .expect("validate coordinator materialization fixture"); + assert_eq!( + materialization_fixture, + ( + run_id.to_string(), + "coordinator".to_string(), + "builtin:sde".to_string(), + session_id.to_string(), + "succeeded".to_string(), + Some("builtin:sde".to_string()), + Some("coordinator".to_string()), + ) + ); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES (?1,?2,?3,'agent_org','running',?4,?4)", + params![session_id, turn_intent_id, run_id, now], + ) + .expect("seed Turn intent"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts ( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + source_kind,source_id,activation_generation,created_at + ) VALUES (?1,?2,?3,'coordinator','coordinator','root_turn',?2,1,?4)", + params![session_id, turn_intent_id, run_id, now], + ) + .expect("seed Turn context"); + drop(conn); + + save_agent_org_assistant_msg_for_turn( + session_id, + turn_intent_id, + "committed before Archive", + "test-model", + ) + .expect("mutable Team accepts assistant iteration"); + crate::coordination::agent_org_archive::archive_run_commit( + run_id, + &uuid::Uuid::new_v4().to_string(), + ) + .expect("Archive commit"); + let late = save_agent_org_assistant_msg_for_turn( + session_id, + turn_intent_id, + "late after Archive", + "test-model", + ) + .expect_err("Archived Team must reject late assistant iteration"); + assert!(late.starts_with("team_archived:"), "{late}"); + + let assistant_count: i64 = get_connection() + .expect("sandbox DB") + .query_row( + "SELECT COUNT(*) FROM agent_messages + WHERE session_id=?1 AND role='assistant'", + [session_id], + |row| row.get(0), + ) + .expect("assistant message count"); + assert_eq!(assistant_count, 1); + } + + #[test] + fn completed_task_from_same_turn_can_persist_final_assistant_iteration() { + let _sandbox = test_env::sandbox(); + let root_session_id = "completed-assistant-root"; + let member_session_id = "completed-assistant-member"; + let run_id = "completed-assistant-run"; + let turn_intent_id = "completed-assistant-turn"; + let task_id = "completed-assistant-task"; + let now = "2026-08-24T00:00:00Z"; + + seed_session_for_message_tests(root_session_id); + seed_session_for_message_tests(member_session_id); + for (session_id, member_id) in [ + (root_session_id, "coordinator"), + (member_session_id, "worker"), + ] { + crate::session::persistence::upsert_session( + &crate::session::persistence::UnifiedSessionRecord { + session_id: session_id.to_string(), + name: format!("Completed assistant {member_id}"), + status: crate::session::SessionStatus::Running.as_str().to_string(), + session_type: "agent".to_string(), + agent_definition_id: Some("builtin:sde".to_string()), + org_member_id: Some(member_id.to_string()), + created_at: now.to_string(), + updated_at: now.to_string(), + ..Default::default() + }, + ) + .expect("seed canonical Agent Org Session"); + } + + let conn = get_connection().expect("sandbox DB"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) + );", + ) + .expect("Turn intent schema"); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); + let snapshot = serde_json::json!({ + "schemaVersion": 1, + "orgId": "org-completed-assistant", + "orgName": "Completed Assistant Test", + "coordinatorRole": "Lead", + "coordinatorAgentId": "builtin:sde", + "planApprovalPolicy": "coordinator", + "members": [{ + "memberId": "worker", + "name": "Worker", + "role": "Builder", + "agentId": "builtin:sde" + }], + "additionalTaskGraphWriterMemberIds": [], + "memberCommunicationLinks": [] + }) + .to_string(); + conn.execute( + "INSERT INTO agent_org_runtime_runs ( + id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, + entry_mode,status,activation_generation,created_at,updated_at + ) VALUES (?1,'org-completed-assistant','builtin:sde',?2,?3, + 'standalone_session','running',1,?4,?4)", + params![run_id, root_session_id, snapshot, now], + ) + .expect("seed Run"); + conn.execute( + "INSERT INTO agent_org_runtime_member_materializations ( + org_run_id,member_id,agent_id,generation,session_id, + authority_class,status,created_at,updated_at + ) VALUES (?1,'worker','builtin:sde',1,?2, + 'formal','succeeded',?3,?3)", + params![run_id, member_session_id, now], + ) + .expect("seed Member materialization"); + conn.execute( + "INSERT INTO agent_org_runtime_tasks ( + id,org_run_id,subject,description,owner,status,execution_mode, + blocked_by_json,created_by_participant_id,source_turn_intent_id, + created_at,updated_at + ) VALUES (?1,?2,'Complete persistence regression','', + 'worker','in_progress','build','[]','coordinator', + 'task-create-turn',?3,?3)", + params![task_id, run_id, now], + ) + .expect("seed in-progress Task"); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES (?1,?2,?3,'agent_org','running',?4,?4)", + params![member_session_id, turn_intent_id, run_id, now], + ) + .expect("seed running Turn intent"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts ( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind,task_id, + owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,activation_generation,created_at + ) VALUES (?1,?2,?3,'worker','task_execution',?4, + 'worker','worker',1,'task',?4,1,?5)", + params![member_session_id, turn_intent_id, run_id, task_id, now], + ) + .expect("seed TaskExecution context"); + drop(conn); + + let actor = crate::coordination::agent_org_tasks::TaskOwnerExecution::new( + member_session_id, + turn_intent_id, + ) + .expect("construct exact Task owner actor"); + crate::coordination::agent_org_tasks::AgentOrgTaskStore::owner_complete_with_transactional_effects( + actor, + run_id, + task_id, + crate::coordination::agent_org_tasks::TaskOutputInput { + summary: "Task completed".to_string(), + content: Some("Durable output".to_string()), + artifact_ids: Vec::new(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect("same Turn completes its Task"); + + let conn = get_connection().expect("sandbox DB after completion"); + let admission_error = + crate::coordination::agent_org_turn_contexts::revalidate_context_with_connection( + &conn, + member_session_id, + turn_intent_id, + ) + .expect_err("a terminal Task must remain ineligible for a new execution admission"); + assert!(admission_error.contains("not runnable (status completed)")); + drop(conn); + + save_agent_org_assistant_msg_for_turn( + member_session_id, + turn_intent_id, + "Final assistant summary", + "test-model", + ) + .expect("the exact completing Turn may persist its final assistant iteration"); + + let conn = get_connection().expect("sandbox DB for assertions"); + let assistant_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_messages + WHERE session_id=?1 AND role='assistant' AND content=?2", + params![member_session_id, "Final assistant summary"], + |row| row.get(0), + ) + .expect("assistant message count"); + assert_eq!(assistant_count, 1); + let terminal_event: (String, String, String, String, String) = conn + .query_row( + "SELECT previous_status,next_status,actor_kind,actor_member_id, + source_turn_intent_id + FROM agent_org_runtime_task_events + WHERE org_run_id=?1 AND task_id=?2 + ORDER BY rowid DESC LIMIT 1", + params![run_id, task_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .expect("read terminal provenance"); + assert_eq!( + terminal_event, + ( + "in_progress".to_string(), + "completed".to_string(), + "owner_execution".to_string(), + "worker".to_string(), + turn_intent_id.to_string(), + ) + ); + } + #[test] fn compact_boundary_hides_old_rows_but_keeps_them_in_table() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index bc95a33453..094d8e8a04 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -50,10 +50,10 @@ pub use messages::{ load_llm_history_text_only_bounded, load_messages, load_session_memory_state, mark_turn_cancelled, materialize_agent_org_inbox_transcript, materialize_agent_org_inbox_transcript_for_turn, message_anchor, message_created_at, - save_assistant_msg, save_compact_summary_msg, save_session_memory_state, save_snapshot, - save_subagent_transcript, save_tool_call_msg, save_tool_result_msg, save_user_msg, - save_user_msg_with_id, seed_session_with_messages, take_turn_cancelled, - truncate_messages_from_sequence, update_compact_boundary_token_delta, + save_agent_org_assistant_msg_for_turn, save_assistant_msg, save_compact_summary_msg, + save_session_memory_state, save_snapshot, save_subagent_transcript, save_tool_call_msg, + save_tool_result_msg, save_user_msg, save_user_msg_with_id, seed_session_with_messages, + take_turn_cancelled, truncate_messages_from_sequence, update_compact_boundary_token_delta, AgentOrgInboxTranscriptMaterialization, MessageAnchor, }; diff --git a/src-tauri/crates/agent-core/src/core/session/turn/entry.rs b/src-tauri/crates/agent-core/src/core/session/turn/entry.rs index dd1ed00ffb..ea818a38fb 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/entry.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/entry.rs @@ -160,6 +160,7 @@ pub async fn process_message( .and_then(|ctx| ctx.repo_path.clone()), agent_org_task_lifecycle: None, require_durable_assistant_event: false, + agent_org_turn_intent_id: None, }; let policy = Arc::clone(&runtime.policy); diff --git a/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs index 69bef586b5..5a5095547f 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs @@ -130,6 +130,10 @@ pub struct EventHandlerConfig { /// Agent Org work-capable turns may not become terminal until their /// assistant EventStore rows are durably committed. pub require_durable_assistant_event: bool, + + /// Exact durable Agent Org Turn bound to assistant transcript writes. + /// Ordinary Sessions leave this unset and pay no lifecycle query. + pub agent_org_turn_intent_id: Option, } /// Durable identity needed to verify that an Agent Org worker did not end a @@ -779,7 +783,19 @@ impl TurnEventHandler for UnifiedEventHandler { return; } - if let Err(err) = unified_persistence::save_assistant_msg(session_id, text, model) { + let persistence_result = + if let Some(turn_intent_id) = self.config.agent_org_turn_intent_id.as_deref() { + unified_persistence::save_agent_org_assistant_msg_for_turn( + session_id, + turn_intent_id, + text, + model, + ) + } else { + unified_persistence::save_assistant_msg(session_id, text, model) + .map_err(|error| error.to_string()) + }; + if let Err(err) = persistence_result { warn!( "[unified_handler] Failed to persist assistant iteration: {}", err @@ -789,6 +805,9 @@ impl TurnEventHandler for UnifiedEventHandler { "assistant transcript persistence failed: {err}" )); } + if self.config.agent_org_turn_intent_id.is_some() { + return; + } } let has_active_message_stream = self diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs index 1cb6786eee..5294a2886e 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs @@ -118,6 +118,11 @@ impl UnifiedMessageProcessor { event_handler_config.turn_id = Some(turn_id.to_string()); event_handler_config.require_durable_assistant_event = self.runtime.agent_org_context.is_some(); + event_handler_config.agent_org_turn_intent_id = self + .runtime + .agent_org_context + .as_ref() + .map(|_| turn_intent_id.to_string()); event_handler_config.agent_org_task_lifecycle = self .runtime .agent_org_context diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs index b3441bee8f..5160568428 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs @@ -24,6 +24,19 @@ use crate::turn_executor::TurnResult; use super::super::post_turn as post_turn_jobs; use super::super::streaming::{broadcast_agent_complete, AgentCompleteParams}; +fn should_spawn_goal_loop( + final_turn_state: DialogTurnState, + is_stream_error: bool, + is_agent_org_session: bool, +) -> bool { + final_turn_state != DialogTurnState::Cancelled + && !is_stream_error + // Agent Org has its own durable multi-member progress loop. Starting + // the ordinary SDE presence goal-loop would create an unowned side + // provider that Team Archive cannot represent as a formal Turn. + && !is_agent_org_session +} + /// Inputs for [`UnifiedMessageProcessor::dispatch_post_turn_work`]. /// /// Bundled into a struct so the call site stays a single line. The @@ -190,7 +203,11 @@ impl UnifiedMessageProcessor { // the presence policy enables it (Invisible / custom autonomous // modes). Fire-and-forget; skipped for cancelled turns (the user // explicitly stopped — auto-continuing would fight the Stop). - if final_turn_state != DialogTurnState::Cancelled && !result.is_stream_error { + if should_spawn_goal_loop( + final_turn_state, + result.is_stream_error, + self.runtime.agent_org_context.is_some(), + ) { crate::session::goal_loop::spawn_turn_end_evaluation( crate::session::goal_loop::GoalLoopTurnEnd { session_id: session_id.to_string(), @@ -206,3 +223,33 @@ impl UnifiedMessageProcessor { } } } + +#[cfg(test)] +mod tests { + use super::should_spawn_goal_loop; + use crate::core::session::types::DialogTurnState; + + #[test] + fn agent_org_turns_never_start_the_standalone_goal_loop() { + assert!(!should_spawn_goal_loop( + DialogTurnState::Completed, + false, + true + )); + assert!(should_spawn_goal_loop( + DialogTurnState::Completed, + false, + false + )); + assert!(!should_spawn_goal_loop( + DialogTurnState::Cancelled, + false, + false + )); + assert!(!should_spawn_goal_loop( + DialogTurnState::Completed, + true, + false + )); + } +} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs index 1c42aedb7a..3cfc04678d 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs @@ -17,6 +17,14 @@ use tokio_util::sync::CancellationToken; use crate::tools::call_context::{TurnProcessControl, TurnProcessOwner}; +mod session_lifecycle; + +pub use session_lifecycle::{ + execution_blockers_for_sessions, purge_deleted_sessions, request_cancel_for_session, + retained_tombstone_count, session_runtime_evidence, wait_for_session_finality, + PurgedSessionJobs, SessionJobEvidence, +}; + /// Status of a background job. #[derive(Debug, Clone)] pub enum JobStatus { @@ -129,6 +137,10 @@ pub struct BackgroundJob { /// Cancellation was requested, but the monitor has not yet proved the /// process group and replay pipeline are terminal. shell_kill_requested: bool, + /// Archive has installed its short, Session-scoped escalation for this + /// subagent. Separate from `Killed`: status is user-visible, while this + /// flag prevents repeated finality polls from spawning duplicate timers. + session_cancel_escalation_requested: bool, /// Set to `true` once the agent has read the completed job's output via /// `AwaitTool` (monitor/wait_for). Acknowledged completed jobs are excluded /// from the per-turn system reminder to avoid the stale-reminder @@ -369,6 +381,7 @@ const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 6 /// mistyped it), instead of synthesising a guess from the handle string. #[derive(Clone)] struct Tombstone { + session_id: String, status: JobStatus, kind: JobKind, created_at: Instant, @@ -475,6 +488,7 @@ fn register_shell_inner(registration: ShellRegistration) -> broadcast::Sender broadcast::Sender broadcast::Sender= TOMBSTONE_TTL) + .map(|(expired_handle, tombstone)| { + (tombstone.session_id.clone(), expired_handle.clone()) + }) + .collect::>(); + tombs.retain(|_, tombstone| now.duration_since(tombstone.created_at) < TOMBSTONE_TTL); + session_lifecycle::remove_expired_tombstone_indexes(&expired); + let replaced = tombs.insert( handle.to_string(), Tombstone { + session_id: job.session_id.clone(), status: job.status.clone(), kind: job.kind.clone(), created_at: now, }, ); + session_lifecycle::replace_tombstone_index( + replaced + .as_ref() + .map(|previous| previous.session_id.as_str()), + &job.session_id, + handle, + ); + } +} + +fn remove_indexed_handle( + index: &mut HashMap>, + owner: &TurnProcessOwner, + handle: &str, +) { + if let Some(handles) = index.get_mut(owner) { + handles.remove(handle); + if handles.is_empty() { + index.remove(owner); + } } } @@ -833,14 +894,15 @@ pub fn resolve_status_with_tombstone(handle: &str) -> Option<(JobStatus, JobKind if let Some(found) = get_status(handle) { return Some(found); } - let tombs = TOMBSTONES.lock().unwrap_or_else(|e| e.into_inner()); - tombs.get(handle).and_then(|t| { - if Instant::now().duration_since(t.created_at) < TOMBSTONE_TTL { - Some((t.status.clone(), t.kind.clone())) - } else { - None - } - }) + let mut tombs = TOMBSTONES.lock().unwrap_or_else(|e| e.into_inner()); + let tombstone = tombs.get(handle).cloned()?; + if Instant::now().duration_since(tombstone.created_at) < TOMBSTONE_TTL { + Some((tombstone.status, tombstone.kind)) + } else { + tombs.remove(handle); + session_lifecycle::remove_tombstone_index(&tombstone.session_id, handle); + None + } } /// Get the final result text for a job. @@ -939,13 +1001,14 @@ pub async fn await_shells_terminated_for_owner( /// scope, `None` for global scope. pub fn list_jobs(session_id: Option<&str>) -> Vec { let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); - reg.values() - .filter(|job| match session_id { - Some(sid) => job.session_id == sid, - None => true, - }) - .map(|job| job.snapshot()) - .collect() + match session_id { + Some(session_id) => session_lifecycle::live_handles(session_id) + .into_iter() + .filter_map(|handle| reg.get(&handle)) + .map(BackgroundJob::snapshot) + .collect(), + None => reg.values().map(BackgroundJob::snapshot).collect(), + } } /// Mark a completed job's output as acknowledged. Once acknowledged, the job diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry/session_lifecycle.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry/session_lifecycle.rs new file mode 100644 index 0000000000..3c7dc47060 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry/session_lifecycle.rs @@ -0,0 +1,553 @@ +//! Session-scoped lifecycle operations for the background-job registry. +//! +//! Archive and Team Delete need a stronger invariant than the public job +//! status: a killed subagent may still own a Tokio task, and a killed shell +//! may still own a process group or an output-replay pipeline. This module +//! keeps exact Session indexes and exposes bounded, read-only evidence for +//! those execution owners without adding a scanner or retained worker. + +use std::collections::{HashMap, HashSet}; +use std::sync::{LazyLock, Mutex}; +use std::time::Duration; + +use serde::Serialize; +use tokio::task::AbortHandle; +use tokio_util::sync::CancellationToken; + +use super::{ + broadcast_subagent_job_changed, process_tree_exists, remove, remove_indexed_handle, + terminate_shell_process_tree, BackgroundJob, JobKind, JobStatus, ShellCompletionState, + OWNER_INDEX, REGISTRY, TOMBSTONES, +}; + +const FINALITY_OBSERVATION_INTERVAL: Duration = Duration::from_millis(25); +const FINALITY_QUIET_PASSES: usize = 3; +const SESSION_SUBAGENT_ABORT_GRACE: Duration = Duration::from_secs(2); +const DEFAULT_EVIDENCE_LIMIT: usize = 16; + +/// Lock order is `REGISTRY` -> `OWNER_INDEX` -> `SESSION_INDEX`. +/// Registration, removal, Archive and Delete all use that same order. +static SESSION_INDEX: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Tombstones have a separate lock domain. Its order is `TOMBSTONES` -> +/// `TOMBSTONE_SESSION_INDEX`; no code holds either lock across an await. +static TOMBSTONE_SESSION_INDEX: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionJobEvidence { + pub session_id: String, + pub handle: String, + pub kind: String, + pub status: String, + pub execution_state: String, + pub execution_terminal: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PurgedSessionJobs { + pub live_jobs: usize, + pub tombstones: usize, +} + +pub(super) fn replace_live_index( + previous_session_id: Option<&str>, + session_id: &str, + handle: &str, +) { + let mut index = SESSION_INDEX.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(previous_session_id) = previous_session_id { + remove_handle(&mut index, previous_session_id, handle); + } + index + .entry(session_id.to_string()) + .or_default() + .insert(handle.to_string()); +} + +pub(super) fn remove_live_index(session_id: &str, handle: &str) { + let mut index = SESSION_INDEX.lock().unwrap_or_else(|e| e.into_inner()); + remove_handle(&mut index, session_id, handle); +} + +pub(super) fn live_handles(session_id: &str) -> Vec { + let index = SESSION_INDEX.lock().unwrap_or_else(|e| e.into_inner()); + index + .get(session_id) + .into_iter() + .flatten() + .cloned() + .collect() +} + +pub(super) fn replace_tombstone_index( + previous_session_id: Option<&str>, + session_id: &str, + handle: &str, +) { + let mut index = TOMBSTONE_SESSION_INDEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some(previous_session_id) = previous_session_id { + remove_handle(&mut index, previous_session_id, handle); + } + index + .entry(session_id.to_string()) + .or_default() + .insert(handle.to_string()); +} + +pub(super) fn remove_tombstone_index(session_id: &str, handle: &str) { + let mut index = TOMBSTONE_SESSION_INDEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + remove_handle(&mut index, session_id, handle); +} + +pub(super) fn remove_expired_tombstone_indexes(expired: &[(String, String)]) { + if expired.is_empty() { + return; + } + let mut index = TOMBSTONE_SESSION_INDEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + for (session_id, handle) in expired { + remove_handle(&mut index, session_id, handle); + } +} + +fn remove_handle(index: &mut HashMap>, session_id: &str, handle: &str) { + if let Some(handles) = index.get_mut(session_id) { + handles.remove(handle); + if handles.is_empty() { + index.remove(session_id); + } + } +} + +fn status_label(status: &JobStatus) -> String { + match status { + JobStatus::Running => "running".to_string(), + JobStatus::Exited(code) => format!("exited:{code}"), + JobStatus::Killed => "killed".to_string(), + JobStatus::Completed => "completed".to_string(), + JobStatus::Failed => "failed".to_string(), + } +} + +fn execution_state(job: &BackgroundJob) -> (&'static str, bool) { + match &job.kind { + JobKind::Shell { pid, .. } => match job.shell_completion.as_ref() { + Some(completion) => match &*completion.borrow() { + ShellCompletionState::Running if job.shell_kill_requested => { + ("process_tree_or_replay_draining", false) + } + ShellCompletionState::Running => ("process_tree_running", false), + ShellCompletionState::Terminated if job.is_running() => { + ("registry_terminal_status_pending", false) + } + ShellCompletionState::Terminated => ("terminated", true), + ShellCompletionState::Failed(_) => ("termination_unproven", false), + }, + None => { + #[cfg(unix)] + let process_tree_gone = !process_tree_exists(*pid); + #[cfg(windows)] + let process_tree_gone = !job.is_running(); + + if !job.is_running() && process_tree_gone { + ("terminated", true) + } else if job.shell_kill_requested { + ("process_tree_draining", false) + } else { + ("process_tree_running", false) + } + } + }, + JobKind::Subagent { .. } => { + if !job.join_handle_attached { + ("join_handle_pending", false) + } else if job + .join_handle + .as_ref() + .is_some_and(tokio::task::JoinHandle::is_finished) + && !job.is_running() + { + ("terminated", true) + } else if matches!(job.status, JobStatus::Killed) { + ("worker_task_draining", false) + } else if !job.is_running() { + ("result_retention_task_draining", false) + } else { + ("worker_task_running", false) + } + } + } +} + +fn evidence_for_job(job: &BackgroundJob) -> SessionJobEvidence { + let (kind, execution_state, execution_terminal) = match &job.kind { + JobKind::Shell { .. } => { + let (state, terminal) = execution_state(job); + ("shell", state, terminal) + } + JobKind::Subagent { .. } => { + let (state, terminal) = execution_state(job); + ("subagent", state, terminal) + } + }; + SessionJobEvidence { + session_id: job.session_id.clone(), + handle: job.handle.clone(), + kind: kind.to_string(), + status: status_label(&job.status), + execution_state: execution_state.to_string(), + execution_terminal, + } +} + +fn indexed_evidence(session_ids: &[String], limit: usize) -> Vec { + let limit = limit.max(1); + let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + let index = SESSION_INDEX.lock().unwrap_or_else(|e| e.into_inner()); + let mut evidence = session_ids + .iter() + .flat_map(|session_id| index.get(session_id).into_iter().flatten()) + .filter_map(|handle| reg.get(handle)) + .map(evidence_for_job) + .collect::>(); + evidence.sort_by(|left, right| { + left.session_id + .cmp(&right.session_id) + .then_with(|| left.handle.cmp(&right.handle)) + }); + evidence.truncate(limit); + evidence +} + +/// Read-only, bounded evidence for debug/WebDriver observations. +pub fn session_runtime_evidence(session_ids: &[String], limit: usize) -> Vec { + indexed_evidence(session_ids, limit) +} + +/// Return only jobs whose external execution owner has not reached finality. +/// Status `killed` is deliberately insufficient for a subagent with a live +/// JoinHandle or a shell whose process/replay completion is still pending. +pub fn execution_blockers_for_sessions( + session_ids: &[String], + limit: usize, +) -> Vec { + let limit = limit.max(1); + let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + let index = SESSION_INDEX.lock().unwrap_or_else(|e| e.into_inner()); + let mut blockers = Vec::new(); + 'sessions: for session_id in session_ids { + for handle in index.get(session_id).into_iter().flatten() { + let Some(job) = reg.get(handle) else { + continue; + }; + if !execution_state(job).1 { + blockers.push(evidence_for_job(job)); + if blockers.len() >= limit { + break 'sessions; + } + } + } + } + blockers.sort_by(|left, right| { + left.session_id + .cmp(&right.session_id) + .then_with(|| left.handle.cmp(&right.handle)) + }); + blockers +} + +#[derive(Clone)] +struct SubagentCancelRequest { + session_id: String, + handle: String, + agent_name: String, + subagent_type: String, + broadcast_killed: bool, + cancel_flag: Option>, + abort_handle: Option, + abort_immediately: bool, +} + +/// Idempotently request cancellation for every execution owner in one +/// Session. The function never waits while holding a registry lock. +pub fn request_cancel_for_session(session_id: &str) -> usize { + let (shell_requests, subagent_requests) = { + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + let handles = live_handles(session_id); + let mut shell_requests = Vec::<(String, u32, Option)>::new(); + let mut subagent_requests = Vec::::new(); + for handle in handles { + let Some(job) = reg.get_mut(&handle) else { + continue; + }; + if execution_state(job).1 { + continue; + } + match &job.kind { + JobKind::Shell { pid, .. } => { + let newly_requested = !job.shell_kill_requested; + job.shell_kill_requested = true; + if job.shell_cancel.is_some() || newly_requested { + shell_requests.push((job.handle.clone(), *pid, job.shell_cancel.clone())); + } + } + JobKind::Subagent { + subagent_type, + agent_name, + } => { + let broadcast_killed = job.is_running(); + let abort_immediately = + !job.is_running() && !matches!(job.status, JobStatus::Killed); + let install_escalation = !job.session_cancel_escalation_requested; + job.status = JobStatus::Killed; + job.session_cancel_escalation_requested = true; + subagent_requests.push(SubagentCancelRequest { + session_id: job.session_id.clone(), + handle: job.handle.clone(), + agent_name: agent_name.clone(), + subagent_type: subagent_type.clone(), + broadcast_killed, + cancel_flag: job.cancel_flag.clone(), + abort_handle: install_escalation + .then(|| job.join_handle.as_ref().map(|handle| handle.abort_handle())) + .flatten(), + abort_immediately, + }); + } + } + } + (shell_requests, subagent_requests) + }; + + for (handle, pid, cancel) in &shell_requests { + if let Some(cancel) = cancel { + cancel.cancel(); + } else { + let handle = handle.clone(); + let pid = *pid; + tokio::spawn(async move { + if let Err(error) = terminate_shell_process_tree(pid).await { + tracing::warn!(pid, error = %error, "Archive could not stop legacy shell process tree"); + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(job) = reg.get_mut(&handle) { + job.shell_kill_requested = false; + } + } + }); + } + } + for request in &subagent_requests { + if request.broadcast_killed { + broadcast_subagent_job_changed( + &request.session_id, + &request.handle, + &request.agent_name, + &request.subagent_type, + "killed", + ); + } + if let Some(cancel_flag) = &request.cancel_flag { + cancel_flag.store(true, std::sync::atomic::Ordering::SeqCst); + } + if let Some(abort_handle) = request.abort_handle.clone() { + if request.abort_immediately { + abort_handle.abort(); + } else { + tokio::spawn(async move { + tokio::time::sleep(SESSION_SUBAGENT_ABORT_GRACE).await; + if !abort_handle.is_finished() { + abort_handle.abort(); + } + }); + } + } + } + shell_requests.len() + subagent_requests.len() +} + +/// Wait for shell process/replay owners and subagent JoinHandles to finish. +/// The caller owns the outer timeout. Three quiet index observations cover +/// the register-to-handle-attachment race without installing a watchdog. +pub async fn wait_for_session_finality(session_id: &str) -> Result<(), String> { + let session_ids = vec![session_id.to_string()]; + let mut quiet_passes = 0usize; + loop { + request_cancel_for_session(session_id); + let blockers = execution_blockers_for_sessions(&session_ids, DEFAULT_EVIDENCE_LIMIT); + if blockers.is_empty() { + quiet_passes += 1; + if quiet_passes >= FINALITY_QUIET_PASSES { + reap_terminal_jobs_for_session(session_id); + return Ok(()); + } + } else { + quiet_passes = 0; + if blockers + .iter() + .any(|job| job.execution_state == "termination_unproven") + { + return Err(format!( + "background_job_termination_unproven:{}", + summarize_evidence(&blockers) + )); + } + } + tokio::time::sleep(FINALITY_OBSERVATION_INTERVAL).await; + } +} + +fn reap_terminal_jobs_for_session(session_id: &str) { + let handles = { + let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + live_handles(session_id) + .into_iter() + .filter(|handle| reg.get(handle).is_some_and(|job| execution_state(job).1)) + .collect::>() + }; + for handle in handles { + remove(&handle); + } +} + +pub fn summarize_evidence(evidence: &[SessionJobEvidence]) -> String { + evidence + .iter() + .take(DEFAULT_EVIDENCE_LIMIT) + .map(|job| { + format!( + "{}:{}:{}:{}", + job.session_id, job.kind, job.handle, job.execution_state + ) + }) + .collect::>() + .join(",") +} + +/// Physically forget every registry artifact owned by deleted Sessions. +/// The preflight and mutation share the same locks, so active execution can +/// never be detached by a purge race. +pub fn purge_deleted_sessions(session_ids: &[String]) -> Result { + let targets = session_ids.iter().cloned().collect::>(); + let mut purged = PurgedSessionJobs::default(); + { + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + let mut owner_index = OWNER_INDEX.lock().unwrap_or_else(|e| e.into_inner()); + let mut session_index = SESSION_INDEX.lock().unwrap_or_else(|e| e.into_inner()); + let handles = targets + .iter() + .flat_map(|session_id| session_index.get(session_id).into_iter().flatten()) + .cloned() + .collect::>(); + let blockers = handles + .iter() + .filter_map(|handle| reg.get(handle)) + .filter(|job| !execution_state(job).1) + .map(evidence_for_job) + .take(DEFAULT_EVIDENCE_LIMIT) + .collect::>(); + if !blockers.is_empty() { + return Err(format!( + "team_background_jobs_not_quiesced:{}", + summarize_evidence(&blockers) + )); + } + for handle in handles { + if let Some(job) = reg.remove(&handle) { + if let Some(owner) = job.turn_owner.as_ref() { + remove_indexed_handle(&mut owner_index, owner, &handle); + } + purged.live_jobs += 1; + } + } + for session_id in &targets { + session_index.remove(session_id); + } + } + + { + let mut tombstones = TOMBSTONES.lock().unwrap_or_else(|e| e.into_inner()); + let mut tombstone_index = TOMBSTONE_SESSION_INDEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + for session_id in &targets { + let handles = tombstone_index.remove(session_id).unwrap_or_default(); + for handle in handles { + if tombstones.remove(&handle).is_some() { + purged.tombstones += 1; + } + } + } + } + Ok(purged) +} + +/// Count retained terminal receipts for exact Sessions. Used only by the +/// debug/WebDriver evidence surface and tests; no background scan is needed. +pub fn retained_tombstone_count(session_ids: &[String]) -> usize { + let now = std::time::Instant::now(); + let mut tombstones = TOMBSTONES.lock().unwrap_or_else(|e| e.into_inner()); + let mut index = TOMBSTONE_SESSION_INDEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut retained = 0usize; + for session_id in session_ids { + let handles = index.get(session_id).cloned().unwrap_or_default(); + for handle in handles { + let expired = tombstones.get(&handle).is_none_or(|tombstone| { + now.duration_since(tombstone.created_at) >= super::TOMBSTONE_TTL + }); + if expired { + tombstones.remove(&handle); + remove_handle(&mut index, session_id, &handle); + } else { + retained += 1; + } + } + } + retained +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Instant; + + use super::*; + use crate::tools::impls::coding::exec::registry::{ + resolve_status_with_tombstone, Tombstone, TOMBSTONE_TTL, + }; + + #[test] + fn expired_tombstone_is_removed_from_session_index_on_read() { + let session_id = "expired-tombstone-index"; + let handle = "expired-tombstone-handle"; + { + let mut tombstones = TOMBSTONES.lock().unwrap_or_else(|e| e.into_inner()); + tombstones.insert( + handle.to_string(), + Tombstone { + session_id: session_id.to_string(), + status: JobStatus::Exited(0), + kind: JobKind::Shell { + pid: 99_969, + log_path: PathBuf::from("/tmp/expired-tombstone.txt"), + replay_session_id: None, + replay_call_id: None, + }, + created_at: Instant::now() - TOMBSTONE_TTL - Duration::from_secs(1), + }, + ); + replace_tombstone_index(None, session_id, handle); + } + assert_eq!(retained_tombstone_count(&[session_id.into()]), 0); + assert!(resolve_status_with_tombstone(handle).is_none()); + } +} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs index 34ca272626..64d8e7dc7a 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs @@ -376,8 +376,15 @@ mod tests { let fixture = fixture(); let conn = get_connection().expect("test sqlite connection"); conn.execute( - "UPDATE agent_org_runtime_runs SET status='archived' WHERE id=?1", - params![&fixture.run_id], + "UPDATE agent_org_runtime_runs + SET status='archived',activation_generation=activation_generation+1, + archived_at=?2,archive_receipt_id=?3 + WHERE id=?1", + params![ + &fixture.run_id, + chrono::Utc::now().to_rfc3339(), + format!("{}-archive-receipt", fixture.run_id) + ], ) .expect("archive run"); let error = OrgInboxRepairTool::new(fixture.coordinator) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs index b92a630df1..2107573a39 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs @@ -124,13 +124,18 @@ pub(super) fn persist_ordinary_message_if_running( ) .optional() .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; + if run_status.as_deref() == Some("archived") { + return Err(ToolError::ExecutionFailed( + crate::coordination::agent_org_runs::mutation_blocked_error(run_id, "archived"), + )); + } if run_status.as_deref() != Some("running") { let guidance = serde_json::to_string(&json!({ "delivered": false, "reason": "run_not_running", "org_run_id": run_id, "run_status": run_status, - "guidance": "The Agent Org Team is not Running, so this formal peer message was not persisted. Starting, Paused, Idle, Failed, and Archived Teams do not accept this mutation in PR1.", + "guidance": "The Agent Org Team is not Running, so this formal peer message was not persisted. Starting, Paused, Idle, and Failed Teams do not accept this mutation.", })) .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; tx.commit() diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs index 0b5270b093..d7665b5f8f 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs @@ -361,8 +361,11 @@ async fn ordinary_message_does_not_create_unread_work_after_run_is_archived() { let _sandbox = init_inbox_schema(); let conn = database::db::get_connection().expect("test sqlite connection"); conn.execute( - "UPDATE agent_org_runtime_runs SET status='archived' WHERE id='run-1'", - [], + "UPDATE agent_org_runtime_runs + SET status='archived',activation_generation=activation_generation+1, + archived_at=?1,archive_receipt_id='send-message-archive-receipt' + WHERE id='run-1'", + [chrono::Utc::now().to_rfc3339()], ) .expect("archive run"); let wake = Arc::new(RecordingWakeHook::default()); @@ -373,16 +376,14 @@ async fn ordinary_message_does_not_create_unread_work_after_run_is_archived() { Arc::new(NoopSelfAbortHook), ); - let result = tool + let error = tool .execute_text( params("coordinator"), &crate::tools::call_context::CallContext::default(), ) .await - .expect("terminal race returns structured no-delivery guidance"); - let value: Value = serde_json::from_str(&result).expect("guidance json"); - assert_eq!(value["delivered"], false); - assert_eq!(value["reason"], "run_not_running"); + .expect_err("Archived Team rejects the write with a stable error"); + assert!(error.to_string().contains("team_archived")); assert!(wake.snapshot().is_empty()); assert!( AgentInboxStore::list_unread_for_member("coordinator", "run-1") diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/member_idle.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/member_idle.rs index a30b258765..dab577b998 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/member_idle.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/member_idle.rs @@ -214,9 +214,11 @@ mod tests { conn.execute( "INSERT INTO agent_org_runtime_runs ( id, org_id, coordinator_agent_id, root_session_id, - entry_mode, status, created_at, updated_at - ) VALUES (?1, 'org-1', 'coord', 'root-1', 'build', ?2, ?3, ?3)", - rusqlite::params![run_id, status, now], + entry_mode, status, created_at, updated_at,archived_at,archive_receipt_id + ) VALUES (?1, 'org-1', 'coord', 'root-1', 'build', ?2, ?3, ?3, + CASE WHEN ?2='archived' THEN ?3 ELSE NULL END, + CASE WHEN ?2='archived' THEN ?4 ELSE NULL END)", + rusqlite::params![run_id, status, now, format!("{run_id}-archive-receipt")], ) .expect("seed Agent Org run"); } diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs index 1c6b028a61..c8f5f8ad51 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs @@ -622,3 +622,180 @@ fn test_tombstone_preserves_shell_exit_code() { tomb.map(|(s, _)| s) ); } + +#[test] +fn session_runtime_evidence_uses_exact_session_index() { + let mine = "agent-session-index-mine".to_string(); + let other = "agent-session-index-other".to_string(); + let (_mine_tx, _mine_cancel) = registry::register_subagent( + mine.clone(), + "delegate".into(), + "Mine".into(), + "session-index-a".into(), + ); + let (_other_tx, _other_cancel) = registry::register_subagent( + other.clone(), + "delegate".into(), + "Other".into(), + "session-index-b".into(), + ); + + let evidence = registry::session_runtime_evidence(&["session-index-a".into()], 8); + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0].handle, mine); + assert_eq!(evidence[0].session_id, "session-index-a"); + + registry::remove(&mine); + registry::remove(&other); + registry::purge_deleted_sessions(&["session-index-a".into(), "session-index-b".into()]) + .expect("purge index fixtures"); +} + +#[tokio::test] +async fn killed_subagent_remains_a_blocker_until_join_handle_finishes() { + let session_id = "session-killed-join-pending"; + let handle = "agent-killed-join-pending".to_string(); + let (_tx, _cancel) = registry::register_subagent( + handle.clone(), + "delegate".into(), + "Pending Worker".into(), + session_id.into(), + ); + registry::set_join_handle(&handle, tokio::spawn(std::future::pending::<()>())); + + registry::request_cancel_for_session(session_id); + let blockers = registry::execution_blockers_for_sessions(&[session_id.into()], 8); + assert_eq!(blockers.len(), 1); + assert_eq!(blockers[0].status, "killed"); + assert_eq!(blockers[0].execution_state, "worker_task_draining"); + + tokio::time::timeout( + Duration::from_secs(4), + registry::wait_for_session_finality(session_id), + ) + .await + .expect("Session finality timeout") + .expect("Session finality"); + assert!(registry::get_status(&handle).is_none()); + registry::purge_deleted_sessions(&[session_id.into()]).expect("purge killed fixture"); +} + +#[tokio::test] +async fn session_finality_waits_for_register_to_join_handle_handoff() { + let session_id = "session-register-join-handoff"; + let handle = "agent-register-join-handoff".to_string(); + let (_tx, cancel) = registry::register_subagent( + handle.clone(), + "delegate".into(), + "Handoff Worker".into(), + session_id.into(), + ); + + let wait = tokio::spawn(async move { registry::wait_for_session_finality(session_id).await }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(cancel.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!wait.is_finished()); + let blockers = registry::execution_blockers_for_sessions(&[session_id.into()], 8); + assert_eq!(blockers[0].execution_state, "join_handle_pending"); + + registry::set_join_handle(&handle, tokio::spawn(std::future::pending::<()>())); + tokio::time::timeout(Duration::from_secs(1), wait) + .await + .expect("handoff wait timeout") + .expect("join handoff waiter") + .expect("handoff finality"); + assert!(registry::get_status(&handle).is_none()); + registry::purge_deleted_sessions(&[session_id.into()]).expect("purge handoff fixture"); +} + +#[tokio::test] +async fn archive_finality_reaps_terminal_shell_but_retains_precise_tombstone() { + let session_id = "session-archive-shell-reap"; + let pid = 99_970; + let completion = registry::register_owned_shell_replay( + pid, + "archive-shell".into(), + PathBuf::from("/tmp/archive-shell-reap.txt"), + session_id.into(), + "archive-shell-call".into(), + &shell_control(session_id, "archive-shell-lease"), + CancellationToken::new(), + ); + registry::mark_exited(&pid.to_string(), JobStatus::Killed); + completion.finish(Ok(())); + + tokio::time::timeout( + Duration::from_secs(1), + registry::wait_for_session_finality(session_id), + ) + .await + .expect("shell finality timeout") + .expect("shell finality"); + assert!(registry::get_status(&pid.to_string()).is_none()); + assert!(matches!( + registry::resolve_status_with_tombstone(&pid.to_string()), + Some((JobStatus::Killed, JobKind::Shell { .. })) + )); + + let purged = registry::purge_deleted_sessions(&[session_id.into()]).expect("purge shell"); + assert_eq!(purged.tombstones, 1); + assert!(registry::resolve_status_with_tombstone(&pid.to_string()).is_none()); +} + +#[tokio::test] +async fn delete_purge_is_session_scoped_and_rejects_execution_active_jobs() { + let deleted_session = "session-delete-purge-target"; + let other_team_session = "session-delete-purge-other-team"; + let ordinary_sde_session = "session-delete-purge-ordinary-sde"; + let tombstone_handle = "agent-delete-target-tombstone".to_string(); + let live_handle = "agent-delete-target-live-terminal".to_string(); + let other_handle = "agent-delete-other-team".to_string(); + let sde_handle = "agent-delete-ordinary-sde".to_string(); + + for (handle, session_id) in [ + (&tombstone_handle, deleted_session), + (&live_handle, deleted_session), + (&other_handle, other_team_session), + (&sde_handle, ordinary_sde_session), + ] { + let (_tx, _cancel) = registry::register_subagent( + handle.clone(), + "delegate".into(), + "Worker".into(), + session_id.into(), + ); + } + registry::mark_exited(&tombstone_handle, JobStatus::Completed); + registry::remove(&tombstone_handle); + + let live_join = tokio::spawn(async {}); + registry::set_join_handle(&live_handle, live_join); + registry::set_join_handle(&other_handle, tokio::spawn(std::future::pending::<()>())); + registry::set_join_handle(&sde_handle, tokio::spawn(std::future::pending::<()>())); + tokio::task::yield_now().await; + registry::mark_exited(&live_handle, JobStatus::Completed); + + let active_error = registry::purge_deleted_sessions(&[other_team_session.into()]) + .expect_err("executing other Team worker must block purge"); + assert!(active_error.starts_with("team_background_jobs_not_quiesced:")); + assert!(registry::get_status(&other_handle).is_some()); + + let purged = registry::purge_deleted_sessions(&[deleted_session.into()]) + .expect("purge terminal target Session"); + assert_eq!(purged.live_jobs, 1); + assert_eq!(purged.tombstones, 1); + assert!(registry::get_status(&live_handle).is_none()); + assert!(registry::resolve_status_with_tombstone(&tombstone_handle).is_none()); + assert!(registry::get_status(&other_handle).is_some()); + assert!(registry::get_status(&sde_handle).is_some()); + + registry::request_cancel_for_session(other_team_session); + registry::request_cancel_for_session(ordinary_sde_session); + tokio::try_join!( + registry::wait_for_session_finality(other_team_session), + registry::wait_for_session_finality(ordinary_sde_session), + ) + .expect("clean unrelated fixtures"); + registry::purge_deleted_sessions(&[other_team_session.into(), ordinary_sde_session.into()]) + .expect("purge unrelated fixture tombstones"); +} diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/builders.rs b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/builders.rs index 33c4c07ca4..e94b138ac3 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/builders.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/builders.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::persistence::images; use super::super::{ - insert_message_if_absent_retry, insert_message_retry, message_role, AgentMessageRow, + insert_message_if_absent_retry, insert_message_retry, insert_message_with_connection, + message_role, AgentMessageRow, MessageConflictPolicy, }; pub fn save_system_msg(prefix: &str, session_id: &str, content: &str) -> SqliteResult { @@ -170,6 +171,37 @@ pub fn save_assistant_msg( insert_message_retry(prefix, &msg) } +/// Save an assistant message through a caller-owned transaction. Agent Org +/// uses this after revalidating its durable Turn/lifecycle fence on the same +/// connection; ordinary Session writes keep using [`save_assistant_msg`]. +pub fn save_assistant_msg_with_connection( + conn: &rusqlite::Connection, + prefix: &str, + session_id: &str, + content: &str, + model: &str, +) -> SqliteResult { + let msg = AgentMessageRow { + id: Uuid::new_v4().to_string(), + session_id: session_id.to_string(), + role: message_role::ASSISTANT.to_string(), + content: content.to_string(), + tool_name: None, + tool_call_id: None, + tool_input: None, + tool_output: None, + model: Some(model.to_string()), + sequence: 0, + created_at: Utc::now().to_rfc3339(), + images: None, + compact_from_sequence: None, + compact_tokens_before: None, + compact_tokens_after: None, + }; + insert_message_with_connection(conn, prefix, &msg, MessageConflictPolicy::Replace) + .map(|(id, _)| id) +} + /// Save a tool call. pub fn save_tool_call_msg( prefix: &str, diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs index 60ee10f692..10ea63f2b2 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/mod.rs @@ -23,8 +23,9 @@ mod insert_tests; mod load_llm; pub use builders::{ - save_assistant_msg, save_compact_boundary_msg, save_system_msg, save_tool_call_msg, - save_tool_result_msg, save_user_msg, save_user_msg_with_id, + save_assistant_msg, save_assistant_msg_with_connection, save_compact_boundary_msg, + save_system_msg, save_tool_call_msg, save_tool_result_msg, save_user_msg, + save_user_msg_with_id, }; pub use cleanup::{clear_messages, truncate_messages_from_sequence}; pub use load_llm::{ diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/mod.rs b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/mod.rs index 30427754b7..a9f8ac7d8b 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/mod.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/mod.rs @@ -336,87 +336,85 @@ enum MessageConflictPolicy { PreserveExisting, } -fn insert_message_with_policy( +/// Insert one message through a caller-owned SQLite transaction. Keeping the +/// sequence allocation and Session timestamp touch in this shared primitive +/// lets lifecycle-aware writers add their authoritative gate in the same +/// transaction without duplicating the message schema. +fn insert_message_with_connection( + conn: &rusqlite::Connection, prefix: &str, msg: &AgentMessageRow, conflict_policy: MessageConflictPolicy, ) -> SqliteResult<(String, bool)> { - with_sessions_writer(|| { - let conn = get_connection()?; - - let seq_sql = format!("SELECT MAX(sequence) FROM {prefix}_messages WHERE session_id = ?1"); - let insert_sql = match conflict_policy { - MessageConflictPolicy::Replace => format!( - "INSERT OR REPLACE INTO {prefix}_messages - (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images, compact_from_sequence, compact_tokens_before, compact_tokens_after) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)" - ), - MessageConflictPolicy::PreserveExisting => format!( - "INSERT INTO {prefix}_messages + let seq_sql = format!("SELECT MAX(sequence) FROM {prefix}_messages WHERE session_id = ?1"); + let insert_sql = match conflict_policy { + MessageConflictPolicy::Replace => format!( + "INSERT OR REPLACE INTO {prefix}_messages + (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images, compact_from_sequence, compact_tokens_before, compact_tokens_after) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)" + ), + MessageConflictPolicy::PreserveExisting => format!( + "INSERT INTO {prefix}_messages (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images, compact_from_sequence, compact_tokens_before, compact_tokens_after) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)" - ), - }; - let exists_sql = format!("SELECT EXISTS(SELECT 1 FROM {prefix}_messages WHERE id = ?1)"); - let touch_sql = - format!("UPDATE {prefix}_sessions SET updated_at = ?2 WHERE session_id = ?1"); + ), + }; + let exists_sql = format!("SELECT EXISTS(SELECT 1 FROM {prefix}_messages WHERE id = ?1)"); + let touch_sql = format!("UPDATE {prefix}_sessions SET updated_at = ?2 WHERE session_id = ?1"); + + if matches!(conflict_policy, MessageConflictPolicy::PreserveExisting) + && conn.query_row(&exists_sql, [&msg.id], |row| row.get::<_, bool>(0))? + { + return Ok((msg.id.clone(), false)); + } - conn.execute_batch("BEGIN IMMEDIATE")?; + let max_seq: Option = conn + .query_row(&seq_sql, [&msg.session_id], |row| row.get(0)) + .unwrap_or(None); + let sequence = max_seq.unwrap_or(-1) + 1; + let now = Utc::now().to_rfc3339(); + conn.execute( + &insert_sql, + params![ + msg.id, + msg.session_id, + msg.role, + msg.content, + msg.tool_name, + msg.tool_call_id, + msg.tool_input, + msg.tool_output, + msg.model, + sequence, + msg.created_at, + msg.images, + msg.compact_from_sequence, + msg.compact_tokens_before, + msg.compact_tokens_after, + ], + )?; + conn.execute(&touch_sql, params![msg.session_id, now])?; + Ok((msg.id.clone(), true)) +} - if matches!(conflict_policy, MessageConflictPolicy::PreserveExisting) { - let already_exists = - match conn.query_row(&exists_sql, [&msg.id], |row| row.get::<_, bool>(0)) { - Ok(exists) => exists, - Err(err) => { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); - } - }; - if already_exists { +fn insert_message_with_policy( + prefix: &str, + msg: &AgentMessageRow, + conflict_policy: MessageConflictPolicy, +) -> SqliteResult<(String, bool)> { + with_sessions_writer(|| { + let conn = get_connection()?; + conn.execute_batch("BEGIN IMMEDIATE")?; + match insert_message_with_connection(&conn, prefix, msg, conflict_policy) { + Ok(outcome) => { conn.execute_batch("COMMIT")?; - return Ok((msg.id.clone(), false)); + Ok(outcome) + } + Err(error) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(error) } } - - let max_seq: Option = conn - .query_row(&seq_sql, [&msg.session_id], |row| row.get(0)) - .unwrap_or(None); - let sequence = max_seq.unwrap_or(-1) + 1; - let now = Utc::now().to_rfc3339(); - - let result = conn.execute( - &insert_sql, - params![ - msg.id, - msg.session_id, - msg.role, - msg.content, - msg.tool_name, - msg.tool_call_id, - msg.tool_input, - msg.tool_output, - msg.model, - sequence, - msg.created_at, - msg.images, - msg.compact_from_sequence, - msg.compact_tokens_before, - msg.compact_tokens_after, - ], - ); - - if let Err(err) = result { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); - } - - if let Err(err) = conn.execute(&touch_sql, params![msg.session_id, now]) { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); - } - - conn.execute_batch("COMMIT")?; - Ok((msg.id.clone(), true)) }) } diff --git a/src-tauri/crates/agent-core/src/init/mod.rs b/src-tauri/crates/agent-core/src/init/mod.rs index fecffe5c7c..6364df9ed1 100644 --- a/src-tauri/crates/agent-core/src/init/mod.rs +++ b/src-tauri/crates/agent-core/src/init/mod.rs @@ -235,13 +235,13 @@ pub async fn init_session( fn load_agent_org_context( state: &AgentAppState, session_id: &str, -) -> Option { +) -> Result, String> { let Some(_handle) = state.app_handle.as_ref() else { tracing::debug!( session_id = %session_id, "[init] agent_org_context lookup skipped (no app_handle — headless context)" ); - return None; + return Ok(None); }; match crate::coordination::agent_org_runs::AgentOrgRunStore::context_for_session_with_parent_walk( session_id, @@ -260,14 +260,14 @@ fn load_agent_org_context( member_count = ctx.members.len(), "[init] loaded Agent Org context" ); - Some(ctx) + Ok(Some(ctx)) } Ok(None) => { tracing::debug!( session_id = %session_id, "[init] no Agent Org context for this session (parent walk found no anchored run)" ); - None + Ok(None) } Err(err) => { tracing::warn!( @@ -275,11 +275,56 @@ fn load_agent_org_context( error = %err, "[init] failed to load Agent Org context" ); - None + Err(format!( + "agent_org_runtime_admission_failed: could not resolve Team ownership: {err}" + )) } } } +#[derive(Debug, Clone)] +struct AgentOrgRuntimeAdmission { + run_id: String, + activation_generation: i64, +} + +fn capture_agent_org_runtime_admission( + context: Option<&crate::coordination::agent_org_runs::AgentOrgRunContext>, +) -> Result, String> { + let Some(context) = context else { + return Ok(None); + }; + let run = crate::coordination::agent_org_runs::AgentOrgRunStore::load(&context.run_id)? + .ok_or_else(|| "agent_org_runtime_admission_stale: Team no longer exists".to_string())?; + if run.status == crate::coordination::agent_org_runs::AgentOrgRunStatus::Archived { + return Err("team_archived: archived Team cannot start a Provider runtime".to_string()); + } + Ok(Some(AgentOrgRuntimeAdmission { + run_id: context.run_id.clone(), + activation_generation: run.activation_generation, + })) +} + +fn revalidate_agent_org_runtime_admission( + admission: Option<&AgentOrgRuntimeAdmission>, +) -> Result<(), String> { + let Some(admission) = admission else { + return Ok(()); + }; + let run = crate::coordination::agent_org_runs::AgentOrgRunStore::load(&admission.run_id)? + .ok_or_else(|| "agent_org_runtime_admission_stale: Team no longer exists".to_string())?; + if run.status == crate::coordination::agent_org_runs::AgentOrgRunStatus::Archived { + return Err("team_archived: archived Team cannot start a Provider runtime".to_string()); + } + if run.activation_generation != admission.activation_generation { + return Err( + "agent_org_runtime_admission_stale: Team generation changed during Provider initialization" + .to_string(), + ); + } + Ok(()) +} + /// Internal entry point for session initialization. Public callers go /// through [`init_session`], which builds the `UnifiedInitRequest` from /// an `AgentDefinition` + workspace + model override. @@ -327,6 +372,7 @@ async fn ensure_session_initialized( ) .await { + capture_agent_org_runtime_admission(existing.agent_org_context.as_ref())?; return Ok(existing); } @@ -448,7 +494,9 @@ async fn ensure_session_initialized( // member-submitted plans to the coordinator's inbox instead of the // user's Build button) see the same snapshot the overlay-assembly // step uses below. - let agent_org_context = load_agent_org_context(state, session_id); + let agent_org_context = load_agent_org_context(state, session_id)?; + let agent_org_runtime_admission = + capture_agent_org_runtime_admission(agent_org_context.as_ref())?; let agent_browser_config = { let controller = state.agent_browser.lock().await; @@ -596,7 +644,10 @@ async fn ensure_session_initialized( // for the SessionStart hook fired after runtime install. let load_workspace_resources = resolved.load_workspace_resources; - let runtime = runtime_assemble::install_runtime( + // Archive may commit while the provider and tool registry are being + // constructed. Recheck before installing anything into the shared slot. + revalidate_agent_org_runtime_admission(agent_org_runtime_admission.as_ref())?; + let (runtime, runtime_lease_id) = runtime_assemble::install_runtime( &session_handle, runtime_assemble::AssembleParams { provider: spec.provider, @@ -618,7 +669,18 @@ async fn ensure_session_initialized( agent_definition_id, }, ) - .await; + .await?; + + // Close the final install race. If Archive or another lifecycle episode + // won after the pre-install check, release only the lease installed by + // this initializer; a replacement runtime remains untouched. + if let Err(error) = revalidate_agent_org_runtime_admission(agent_org_runtime_admission.as_ref()) + { + session_handle + .release_runtime_lease_if_current(&runtime_lease_id) + .await; + return Err(error); + } runtime_assemble::mark_running_for_gateway(state, cap_flags.has_gateway, &account_id).await; runtime_assemble::register_in_file_registry(session_id, &log_prefix, &model, &workspace_root); @@ -694,7 +756,27 @@ pub async fn register_session_with_definition_and_rehydrate( #[cfg(test)] mod tests { - use super::is_model_override_strict; + use rusqlite::params; + + use super::{ + is_model_override_strict, revalidate_agent_org_runtime_admission, AgentOrgRuntimeAdmission, + }; + + fn seed_runtime_admission_run(run_id: &str, status: &str, generation: i64) { + let conn = database::db::get_connection().expect("sandbox DB"); + crate::foundation::persistence::test_schema::ensure_agent_sessions_schema(&conn); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); + let now = "2026-08-23T00:00:00Z"; + conn.execute( + "INSERT INTO agent_org_runtime_runs ( + id,org_id,coordinator_agent_id,entry_mode,status, + activation_generation,created_at,updated_at + ) VALUES (?1,'org-runtime-admission','builtin:sde', + 'standalone_session',?2,?3,?4,?4)", + params![run_id, status, generation, now], + ) + .expect("seed runtime admission Run"); + } #[test] fn inherited_effective_model_matching_launch_model_is_not_strict_override() { @@ -711,4 +793,41 @@ mod tests { "openai/gpt-4.1" )); } + + #[test] + fn provider_runtime_admission_rejects_generation_change_and_archive() { + let _sandbox = test_helpers::test_env::sandbox(); + seed_runtime_admission_run("runtime-admission-run", "running", 7); + let admission = AgentOrgRuntimeAdmission { + run_id: "runtime-admission-run".to_string(), + activation_generation: 7, + }; + revalidate_agent_org_runtime_admission(Some(&admission)) + .expect("unchanged Team admits Provider install"); + + let conn = database::db::get_connection().expect("sandbox DB"); + conn.execute( + "UPDATE agent_org_runtime_runs SET activation_generation=8 WHERE id=?1", + [&admission.run_id], + ) + .expect("change lifecycle generation"); + let stale = revalidate_agent_org_runtime_admission(Some(&admission)) + .expect_err("stale Provider install must fail closed"); + assert!(stale.starts_with("agent_org_runtime_admission_stale:")); + + conn.execute( + "UPDATE agent_org_runtime_runs + SET status='archived',archived_at=?2,archive_receipt_id=?3 + WHERE id=?1", + params![ + &admission.run_id, + "2026-08-23T00:01:00Z", + "runtime-admission-archive-receipt" + ], + ) + .expect("Archive Team"); + let archived = revalidate_agent_org_runtime_admission(Some(&admission)) + .expect_err("Archived Provider install must fail closed"); + assert!(archived.starts_with("team_archived:")); + } } diff --git a/src-tauri/crates/agent-core/src/init/runtime_assemble.rs b/src-tauri/crates/agent-core/src/init/runtime_assemble.rs index e0adbc9cfd..ce7ac67c52 100644 --- a/src-tauri/crates/agent-core/src/init/runtime_assemble.rs +++ b/src-tauri/crates/agent-core/src/init/runtime_assemble.rs @@ -120,7 +120,7 @@ pub(super) struct AssembleParams { pub(super) async fn install_runtime( session_handle: &AgentSession, params: AssembleParams, -) -> Arc { +) -> Result<(Arc, String), String> { let runtime = Arc::new(SessionRuntime { provider: params.provider, tool_registry: params.final_registry, @@ -140,8 +140,8 @@ pub(super) async fn install_runtime( agent_org_current_member_id: params.agent_org_current_member_id, agent_definition_id: params.agent_definition_id, }); - session_handle.set_runtime(Arc::clone(&runtime)).await; - runtime + let runtime_lease_id = session_handle.set_runtime(Arc::clone(&runtime)).await?; + Ok((runtime, runtime_lease_id)) } /// Side-effect: mark the app as "running" + remember the active account. diff --git a/src-tauri/crates/agent-core/src/specialization/memory/background.rs b/src-tauri/crates/agent-core/src/specialization/memory/background.rs index 84f38bf0ac..42c07231f4 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/background.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/background.rs @@ -12,7 +12,7 @@ //! - an always-run cleanup hook so subsystem state cannot remain stuck after a //! timeout or cancellation. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::future::Future; use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; @@ -190,6 +190,11 @@ struct JobSlot { struct CoordinatorState { next_slot_id: u64, slots: HashMap, + /// Sessions sealed by an irreversible lifecycle transition such as Team + /// Archive. This lives beside `slots` so submission and sealing have one + /// total order: work submitted first is cancelled, work submitted later + /// is rejected before it can create a provider. + sealed_sessions: HashSet, } /// Result of a non-blocking submission. @@ -197,6 +202,7 @@ struct CoordinatorState { pub enum MemoryJobSubmission { Started, Coalesced, + RejectedSessionSealed, } /// Process-wide coordinator. It owns every detached memory job spawned through @@ -218,13 +224,26 @@ impl MemoryJobCoordinator { }) } - fn submit(self: &Arc, job: MemoryJob) -> MemoryJobSubmission { + fn submit(self: &Arc, mut job: MemoryJob) -> MemoryJobSubmission { self.metrics.submitted.fetch_add(1, Ordering::Relaxed); let key = job.key(); let mut replaced_slot = None; let (slot_id, cancel) = { let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if state.sealed_sessions.contains(&key.session_id) { + drop(state); + let outcome = self.finish(&key, MemoryJobOutcome::Cancelled, 0); + if let Some(cleanup) = job.cleanup.take() { + tokio::spawn(cleanup(outcome)); + } + info!( + session_id = %key.session_id, + job_kind = key.kind.as_str(), + "[memory_background] rejected job for sealed session" + ); + return MemoryJobSubmission::RejectedSessionSealed; + } // A cancelled slot is torn down by its own drive loop; coalescing // into it would silently drop the new job when the loop exits. // Replace it with a fresh generation instead. @@ -453,6 +472,61 @@ impl MemoryJobCoordinator { self.cancel_where(|key, _| key.session_id == session_id) } + /// Permanently reject new work for this session in the current process + /// and cancel every active/coalesced generation already owned by it. + /// + /// The sealed-set write and the slot snapshot share the same mutex used + /// by `submit`, so a concurrent submission cannot fall between them. + fn seal_session(&self, session_id: &str) -> usize { + let tokens = { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state.sealed_sessions.insert(session_id.to_string()); + state + .slots + .iter() + .filter_map(|(key, slot)| { + (key.session_id == session_id).then_some(slot.cancel.clone()) + }) + .collect::>() + }; + for token in &tokens { + token.cancel(); + } + if !tokens.is_empty() { + self.idle_notify.notify_waiters(); + } + tokens.len() + } + + /// Forget a seal only after the owning session has been physically + /// deleted and no callback can still submit work for that identity. + fn forget_sealed_session(&self, session_id: &str) -> bool { + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .sealed_sessions + .remove(session_id) + } + + async fn wait_for_session_idle(&self, session_id: &str) { + loop { + // Register before checking so a slot cannot disappear between the + // check and waiter installation without waking this future. + let notified = self.idle_notify.notified(); + if !self + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .slots + .keys() + .any(|key| key.session_id == session_id) + { + return; + } + notified.await; + } + } + fn cancel_agent(&self, agent_id: &str) -> usize { self.cancel_where(|_, slot| slot.agent_id.as_deref() == Some(agent_id)) } @@ -502,6 +576,25 @@ pub fn cancel_memory_jobs_for_session(session_id: &str) -> usize { coordinator().cancel_session(session_id) } +/// Atomically seal a session against future memory/evolution work and cancel +/// active or coalesced jobs already owned by it. +pub fn seal_memory_jobs_for_session(session_id: &str) -> usize { + coordinator().seal_session(session_id) +} + +/// Wait until cancellation cleanup has removed every coordinator slot owned +/// by a sealed session. Archive includes this in its bounded teardown receipt. +pub async fn wait_for_memory_jobs_for_session_idle(session_id: &str) { + coordinator().wait_for_session_idle(session_id).await; +} + +/// Release process-local seal bookkeeping after physical session deletion. +/// Archive itself never calls this: Archived sessions remain permanently +/// rejected for as long as their old callbacks could exist. +pub fn forget_memory_job_seal_for_deleted_session(session_id: &str) -> bool { + coordinator().forget_sealed_session(session_id) +} + /// Cancel active and coalesced memory jobs for every live session backed by an /// agent definition. Used by the hot learnings switch. pub fn cancel_memory_jobs_for_agent(agent_id: &str) -> usize { @@ -704,6 +797,90 @@ mod tests { assert_eq!(coordinator.metrics().completed, 0); } + #[tokio::test] + async fn sealed_session_cancels_owned_work_and_rejects_late_submission() { + let coordinator = MemoryJobCoordinator::new(1); + let started = Arc::new(Notify::new()); + let started_wait = started.notified(); + let started_signal = Arc::clone(&started); + coordinator.submit(test_job( + "archived", + MemoryJobKind::SessionMemory, + move |cancel| async move { + started_signal.notify_one(); + cancel.cancelled().await; + Ok(()) + }, + )); + started_wait.await; + + assert_eq!(coordinator.seal_session("archived"), 1); + let late_run_count = Arc::new(AtomicUsize::new(0)); + let late_run_count_for_job = Arc::clone(&late_run_count); + assert_eq!( + coordinator.submit(test_job( + "archived", + MemoryJobKind::WorkspaceExtraction, + move |_| async move { + late_run_count_for_job.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + )), + MemoryJobSubmission::RejectedSessionSealed + ); + + coordinator.wait_for_session_idle("archived").await; + assert_eq!(late_run_count.load(Ordering::SeqCst), 0); + assert_eq!(coordinator.metrics().started, 1); + assert_eq!(coordinator.metrics().cancelled, 2); + + assert!(coordinator.forget_sealed_session("archived")); + let after_delete_run_count = Arc::clone(&late_run_count); + assert_eq!( + coordinator.submit(test_job( + "archived", + MemoryJobKind::WorkspaceExtraction, + move |_| async move { + after_delete_run_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + )), + MemoryJobSubmission::Started + ); + coordinator.wait_for_session_idle("archived").await; + assert_eq!(late_run_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn session_idle_wait_is_scoped_and_does_not_wait_for_other_sessions() { + let coordinator = MemoryJobCoordinator::new(1); + let gate = Arc::new(Notify::new()); + let started = Arc::new(Notify::new()); + let started_wait = started.notified(); + let gate_for_job = Arc::clone(&gate); + let started_for_job = Arc::clone(&started); + coordinator.submit(test_job( + "other-session", + MemoryJobKind::SessionMemory, + move |_| async move { + started_for_job.notify_one(); + gate_for_job.notified().await; + Ok(()) + }, + )); + started_wait.await; + + tokio::time::timeout( + Duration::from_millis(20), + coordinator.wait_for_session_idle("archived"), + ) + .await + .expect("unrelated active session must not hold Archive teardown"); + + gate.notify_waiters(); + coordinator.wait_for_idle().await; + } + #[tokio::test] async fn submissions_hold_only_lightweight_captures_until_admitted() { let coordinator = MemoryJobCoordinator::new(1); diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs index 71fe7f0087..437f81c77f 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs @@ -119,6 +119,18 @@ fn resume_requires_existing_agent_org_context( wake_member_id.is_none() && matches!(source, TurnIntentBridgeSource::Resume) } +fn should_record_standalone_goal( + source: TurnIntentBridgeSource, + is_resume: bool, + has_agent_org_context: bool, +) -> bool { + matches!( + source, + TurnIntentBridgeSource::UserSubmit | TurnIntentBridgeSource::ForceSend + ) && !is_resume + && !has_agent_org_context +} + async fn persist_direct_user_intervention( params: Option, ) -> Result<(), String> { @@ -208,11 +220,7 @@ pub(crate) async fn send_message_impl( // session's standing goal and resets the continuation counter. // `Queue`-sourced messages (goal continuations, queued flushes) and // resumes never reset it — otherwise the loop would feed itself. - if matches!( - source, - TurnIntentBridgeSource::UserSubmit | TurnIntentBridgeSource::ForceSend - ) && !is_resume - { + if should_record_standalone_goal(source, is_resume, preflight_org_run_id.is_some()) { crate::session::goal_loop::on_user_message(&session_id, &content, display_text.as_deref()); } @@ -905,4 +913,28 @@ mod admission_tests { None )); } + + #[test] + fn agent_org_submissions_never_create_standalone_goal_state() { + assert!(!should_record_standalone_goal( + TurnIntentBridgeSource::UserSubmit, + false, + true + )); + assert!(should_record_standalone_goal( + TurnIntentBridgeSource::UserSubmit, + false, + false + )); + assert!(!should_record_standalone_goal( + TurnIntentBridgeSource::Queue, + false, + false + )); + assert!(!should_record_standalone_goal( + TurnIntentBridgeSource::ForceSend, + true, + false + )); + } } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs index 0e8f823f28..9904d7a99f 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs @@ -328,8 +328,17 @@ fn direct_agent_org_turn_only_promotes_while_run_is_running() { AgentOrgRunStatus::Archived, ] { conn.execute( - "UPDATE agent_org_runtime_runs SET status=?1 WHERE id=?2", - rusqlite::params![status.as_str(), &fixture.run_id], + "UPDATE agent_org_runtime_runs + SET status=?1, + archived_at=CASE WHEN ?1='archived' THEN ?3 ELSE NULL END, + archive_receipt_id=CASE WHEN ?1='archived' THEN ?4 ELSE NULL END + WHERE id=?2", + rusqlite::params![ + status.as_str(), + &fixture.run_id, + chrono::Utc::now().to_rfc3339(), + "direct-turn-archive-receipt" + ], ) .expect("set non-runnable run status"); assert_eq!( @@ -353,7 +362,8 @@ fn direct_agent_org_turn_only_promotes_while_run_is_running() { } conn.execute( - "UPDATE agent_org_runtime_runs SET status=?1 WHERE id=?2", + "UPDATE agent_org_runtime_runs + SET status=?1,archived_at=NULL,archive_receipt_id=NULL WHERE id=?2", rusqlite::params![AgentOrgRunStatus::Running.as_str(), &fixture.run_id], ) .expect("restore running run"); diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/group_chat.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/group_chat.rs index 025cb8d513..7b1a144bf0 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/group_chat.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/group_chat.rs @@ -433,11 +433,16 @@ pub(super) fn persist_group_chat_message( })?; match run_status { AgentOrgRunStatus::Running => {} + AgentOrgRunStatus::Archived => { + return Err(format!( + "team_archived: Agent Org run {} is read-only", + context.run_id + )); + } AgentOrgRunStatus::Starting | AgentOrgRunStatus::Paused | AgentOrgRunStatus::Idle - | AgentOrgRunStatus::Failed - | AgentOrgRunStatus::Archived => { + | AgentOrgRunStatus::Failed => { return Err(format!( "Agent Org run {} is {}; this status does not accept new group messages", context.run_id, run_status diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/lifecycle.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/lifecycle.rs index 110c903b5e..ca95d1599f 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/lifecycle.rs @@ -4,6 +4,9 @@ use std::sync::Arc; use std::time::Duration; use crate::coordination::agent_inbox::AgentInboxStore; +use crate::coordination::agent_org_archive::{ + ArchiveRunOutcome, ArchiveTeardownTarget, ARCHIVE_TEARDOWN_MAX_ATTEMPTS, +}; use crate::coordination::agent_org_pause::{ ContinuationDispatch, PauseRunOutcome, ResumeRunOutcome, }; @@ -18,6 +21,50 @@ use super::context::session_org_read_context; const DRAIN_DEADLINE: Duration = Duration::from_secs(10); const DRAIN_OBSERVATION_INTERVAL: Duration = Duration::from_millis(100); const CONTINUATION_DISPATCH_LIMIT: usize = 256; +const ARCHIVE_ROUND_TIMEOUT: Duration = Duration::from_secs(10); +const ARCHIVE_RETRY_BACKOFFS: [Duration; 2] = [Duration::from_secs(5), Duration::from_secs(15)]; +const ARCHIVE_RECONCILE_LIMIT: usize = 128; + +fn archive_retry_backoff(attempt_count: i64, remaining: Duration) -> Duration { + let index = usize::try_from(attempt_count.saturating_sub(1)) + .unwrap_or(0) + .min(ARCHIVE_RETRY_BACKOFFS.len() - 1); + ARCHIVE_RETRY_BACKOFFS[index].min(remaining) +} + +fn archive_retry_delay(attempt_count: i64, remaining: Duration) -> Option { + (attempt_count < ARCHIVE_TEARDOWN_MAX_ATTEMPTS && !remaining.is_zero()) + .then(|| archive_retry_backoff(attempt_count, remaining)) +} + +#[tauri::command] +pub async fn agent_org_archive_run( + state: tauri::State<'_, AgentAppState>, + session_id: String, + request_id: String, +) -> Result { + crate::coordination::agent_org_runs::require_agent_org_redesign()?; + let read_context = session_org_read_context(&state, &session_id) + .await? + .ok_or_else(|| format!("Session {session_id} is not part of an Agent Org run"))?; + let context = read_context + .context + .ok_or_else(|| format!("Session {session_id} has no Agent Org context"))?; + let run_id = context.run_id.clone(); + let archive_run_id = run_id.clone(); + let commit = tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_archive::archive_run_commit(&archive_run_id, &request_id) + }) + .await + .map_err(|error| format!("Agent Org Archive transaction worker failed: {error}"))??; + let outcome = commit.outcome; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + + if commit.owns_teardown { + schedule_archive_teardown(state.inner().clone(), outcome.receipt_id.clone()); + } + Ok(outcome) +} #[tauri::command] pub async fn agent_org_pause_run( @@ -95,6 +142,346 @@ pub async fn agent_org_resume_run( Ok(outcome) } +fn schedule_archive_teardown(state: AgentAppState, receipt_id: String) { + tauri::async_runtime::spawn(async move { + if let Err(error) = teardown_archive_receipt(&state, &receipt_id).await { + tracing::warn!( + archive_receipt_id = %receipt_id, + error = %error, + "Agent Org Archive fence committed, but bounded runtime teardown failed" + ); + } + }); +} + +/// One-shot startup reconciliation. It intentionally installs no watchdog or +/// recurring timer; every pending receipt receives only its remaining bounded +/// attempts and then becomes quiesced or retained-runtime evidence. +pub fn reconcile_pending_archive_teardowns(state: AgentAppState) { + tauri::async_runtime::spawn(async move { + let receipts = match tokio::task::spawn_blocking(|| { + crate::coordination::agent_org_archive::pending_receipt_ids(ARCHIVE_RECONCILE_LIMIT) + }) + .await + { + Ok(Ok(receipts)) => receipts, + Ok(Err(error)) => { + tracing::warn!(error = %error, "failed to read pending Archive receipts"); + return; + } + Err(error) => { + tracing::warn!(error = %error, "pending Archive receipt reader failed"); + return; + } + }; + for receipt_id in receipts { + if let Err(error) = teardown_archive_receipt(&state, &receipt_id).await { + tracing::warn!( + archive_receipt_id = %receipt_id, + error = %error, + "startup Archive teardown reconciliation failed" + ); + } + } + }); +} + +async fn teardown_archive_receipt(state: &AgentAppState, receipt_id: &str) -> Result<(), String> { + loop { + let read_receipt_id = receipt_id.to_string(); + let targets = tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_archive::teardown_targets(&read_receipt_id) + }) + .await + .map_err(|error| format!("Archive teardown target reader failed: {error}"))??; + if targets.is_empty() { + return Ok(()); + } + let run_id = targets[0].run_id.clone(); + let summary_run_id = run_id.clone(); + let pre_round_summary = tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_archive::summary_for_run(&summary_run_id) + }) + .await + .map_err(|error| format!("Archive teardown summary reader failed: {error}"))?? + .ok_or_else(|| "Archive teardown summary disappeared".to_string())?; + let deadline = chrono::DateTime::parse_from_rfc3339(&pre_round_summary.deadline_at) + .map_err(|error| format!("invalid Archive teardown deadline: {error}"))? + .with_timezone(&chrono::Utc); + let remaining = deadline.signed_duration_since(chrono::Utc::now()); + if remaining <= chrono::Duration::zero() { + let expired_receipt_id = receipt_id.to_string(); + tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_archive::mark_deadline_expired(&expired_receipt_id) + }) + .await + .map_err(|error| format!("Archive teardown deadline writer failed: {error}"))??; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + return Ok(()); + } + let round_timeout = + ARCHIVE_ROUND_TIMEOUT.min(remaining.to_std().map_err(|error| { + format!("Archive teardown deadline conversion failed: {error}") + })?); + let max_attempt = targets + .iter() + .map(|target| target.attempt_count) + .max() + .unwrap_or(0); + if max_attempt >= ARCHIVE_TEARDOWN_MAX_ATTEMPTS { + let expired_receipt_id = receipt_id.to_string(); + tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_archive::mark_deadline_expired(&expired_receipt_id) + }) + .await + .map_err(|error| format!("Archive teardown deadline writer failed: {error}"))??; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + return Ok(()); + } + + let mut round = tokio::task::JoinSet::new(); + for target in targets { + let child_state = state.clone(); + round.spawn(async move { + teardown_archive_target(&child_state, target, round_timeout).await + }); + } + while let Some(result) = round.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!(error = %error, "Archive target teardown failed"), + Err(error) => tracing::warn!(error = %error, "Archive target teardown task failed"), + } + } + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + + let summary_receipt_id = receipt_id.to_string(); + let summary = tokio::task::spawn_blocking(move || { + let conn = database::db::get_connection().map_err(|error| error.to_string())?; + let run_id: String = conn + .query_row( + "SELECT org_run_id FROM agent_org_runtime_archive_episodes + WHERE archive_receipt_id=?1", + [&summary_receipt_id], + |row| row.get(0), + ) + .map_err(|error| error.to_string())?; + crate::coordination::agent_org_archive::summary_for_run(&run_id) + }) + .await + .map_err(|error| format!("Archive teardown summary worker failed: {error}"))??; + let Some(summary) = summary else { + return Ok(()); + }; + if summary.status != crate::coordination::agent_org_archive::ArchiveTeardownStatus::Pending + { + return Ok(()); + } + if summary.attempt_count >= ARCHIVE_TEARDOWN_MAX_ATTEMPTS { + let expired_receipt_id = receipt_id.to_string(); + tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_archive::mark_deadline_expired(&expired_receipt_id) + }) + .await + .map_err(|error| format!("Archive teardown finalizer failed: {error}"))??; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + return Ok(()); + } + let deadline = chrono::DateTime::parse_from_rfc3339(&summary.deadline_at) + .map_err(|error| format!("invalid Archive teardown deadline: {error}"))? + .with_timezone(&chrono::Utc); + let remaining = deadline.signed_duration_since(chrono::Utc::now()); + if remaining <= chrono::Duration::zero() { + continue; + } + let Some(backoff) = archive_retry_delay( + summary.attempt_count, + remaining.to_std().unwrap_or_default(), + ) else { + continue; + }; + tokio::time::sleep(backoff).await; + } +} + +#[cfg(test)] +mod archive_retry_policy_tests { + use super::{archive_retry_delay, ARCHIVE_RETRY_BACKOFFS, ARCHIVE_ROUND_TIMEOUT}; + use std::time::Duration; + + #[test] + fn fake_clock_three_round_policy_stays_inside_absolute_sixty_second_budget() { + let mut fake_elapsed = Duration::ZERO; + let mut scheduled_timers = 0; + for attempt in 1..=3 { + fake_elapsed += ARCHIVE_ROUND_TIMEOUT; + if let Some(delay) = archive_retry_delay(attempt, Duration::from_secs(60)) { + scheduled_timers += 1; + fake_elapsed += delay; + } + } + assert_eq!( + ARCHIVE_RETRY_BACKOFFS, + [Duration::from_secs(5), Duration::from_secs(15)] + ); + assert_eq!(fake_elapsed, Duration::from_secs(50)); + assert_eq!(scheduled_timers, 2); + assert_eq!(archive_retry_delay(3, Duration::from_secs(10)), None); + assert!(fake_elapsed <= Duration::from_secs(60)); + } + + #[test] + fn retry_backoff_is_clamped_by_the_absolute_deadline() { + assert_eq!( + archive_retry_delay(1, Duration::from_secs(2)), + Some(Duration::from_secs(2)) + ); + assert_eq!( + archive_retry_delay(2, Duration::from_secs(7)), + Some(Duration::from_secs(7)) + ); + assert_eq!(archive_retry_delay(2, Duration::ZERO), None); + } +} + +async fn teardown_archive_target( + state: &AgentAppState, + target: ArchiveTeardownTarget, + round_timeout: Duration, +) -> Result<(), String> { + // Seal first. `MemoryJobCoordinator` orders this against submission under + // one mutex, so an old post-turn callback either installed its job before + // the seal and is cancelled here, or observes the seal and is rejected. + let cancelled_memory_jobs = + crate::memory::background::seal_memory_jobs_for_session(&target.session_id); + if cancelled_memory_jobs > 0 { + tracing::info!( + session_id = %target.session_id, + cancelled_memory_jobs, + "Archive cancelled session-owned background jobs" + ); + } + + let session = state.get_session(&target.session_id).await; + if let Some(session) = session.as_ref() { + session.cancel_active_turn(CancelReason::OrgArchive).await; + } + crate::tools::impls::coding::exec::registry::request_cancel_for_session(&target.session_id); + let captured = match session.as_ref() { + Some(session) => session.runtime_lease_identity().await, + None => None, + }; + let lease_id = captured + .as_ref() + .map(|identity| identity.runtime_lease_id.clone()); + let turn_generation = captured + .as_ref() + .and_then(|identity| identity.dialog_turn_generation.clone()); + + let released = tokio::time::timeout(round_timeout, async { + let runtime_release = async { + let (Some(session), Some(captured)) = (session, captured) else { + return Ok::(true); + }; + loop { + let current = session.runtime_lease_identity().await; + match current { + None => return Ok(true), + Some(current) if current.runtime_lease_id != captured.runtime_lease_id => { + return Err("archive_runtime_lease_replaced".to_string()); + } + Some(current) if current.dialog_turn_generation.is_none() => { + return Ok(session + .release_runtime_lease_if_current(&captured.runtime_lease_id) + .await); + } + Some(_) => tokio::time::sleep(DRAIN_OBSERVATION_INTERVAL).await, + } + } + }; + let memory_idle = + crate::memory::background::wait_for_memory_jobs_for_session_idle(&target.session_id); + let background_jobs_final = + crate::tools::impls::coding::exec::registry::wait_for_session_finality( + &target.session_id, + ); + let (runtime_result, (), background_result) = + tokio::join!(runtime_release, memory_idle, background_jobs_final); + let runtime_released = runtime_result?; + background_result?; + + // Runtime release closes the last in-flight registration path. Repeat + // the idempotent Session barrier after that point so a job registered + // during the first parallel wait cannot slip between the barrier and + // the durable quiesced receipt. + crate::tools::impls::coding::exec::registry::request_cancel_for_session(&target.session_id); + crate::tools::impls::coding::exec::registry::wait_for_session_finality(&target.session_id) + .await?; + Ok::(runtime_released) + }) + .await; + + match released { + Ok(Ok(true)) => { + persist_archive_teardown_attempt(target, lease_id, turn_generation, true, None).await + } + Ok(Ok(false)) => { + persist_archive_teardown_attempt( + target, + lease_id, + turn_generation, + false, + Some("archive_runtime_release_stale".to_string()), + ) + .await + } + Ok(Err(error)) => { + persist_archive_teardown_attempt(target, lease_id, turn_generation, false, Some(error)) + .await + } + Err(_) => { + let blockers = + crate::tools::impls::coding::exec::registry::execution_blockers_for_sessions( + std::slice::from_ref(&target.session_id), + 8, + ); + let blocker_evidence = blockers + .iter() + .map(|job| format!("{}:{}:{}", job.kind, job.handle, job.execution_state)) + .collect::>() + .join(","); + let error = if blocker_evidence.is_empty() { + "archive_runtime_memory_or_jobs_timeout".to_string() + } else { + format!("archive_runtime_memory_or_jobs_timeout:{blocker_evidence}") + }; + persist_archive_teardown_attempt(target, lease_id, turn_generation, false, Some(error)) + .await + } + } +} + +async fn persist_archive_teardown_attempt( + target: ArchiveTeardownTarget, + runtime_lease_id: Option, + dialog_turn_generation: Option, + released: bool, + error: Option, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_archive::record_teardown_attempt( + &target, + runtime_lease_id.as_deref(), + dialog_turn_generation.as_deref(), + released, + error.as_deref(), + ) + .map(|_| ()) + }) + .await + .map_err(|error| format!("Archive teardown receipt worker failed: {error}"))? +} + async fn teardown_pause_episode( state: AgentAppState, run_id: String, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs index 96f15ae402..9e80eabbc2 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs @@ -120,6 +120,8 @@ pub struct AgentOrgRunView { pub run_phase: AgentOrgRunPhase, #[serde(skip_serializing_if = "Option::is_none")] pub pause_handoff: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub archive_teardown: Option, pub current_member_id: Option, pub members: Vec, pub tasks: Vec, @@ -332,6 +334,14 @@ pub(super) fn build_agent_org_run_view( } else { None }; + let archive_teardown = if run_status_value == AgentOrgRunStatus::Archived { + crate::coordination::agent_org_archive::summary_for_run_with_connection( + &tx, + &context.run_id, + )? + } else { + None + }; let run_phase = if run_status_value == AgentOrgRunStatus::Paused && pause_handoff .as_ref() @@ -356,6 +366,7 @@ pub(super) fn build_agent_org_run_view( run_status, run_phase, pause_handoff, + archive_teardown, members, tasks, task_overview, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs index 12db147934..dea19cf623 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs @@ -80,9 +80,11 @@ fn prepare_command_run(status: &str) -> AgentOrgRunContext { id, org_id, coordinator_agent_id, root_session_id, org_snapshot_json, entry_mode, status, work_item_id, project_slug, routine_fire_id, summary, last_error, - created_at, updated_at, idled_at + created_at, updated_at, idled_at,archived_at,archive_receipt_id ) VALUES (?1, ?2, ?3, ?4, NULL, 'standalone_session', ?5, - NULL, NULL, NULL, NULL, NULL, ?6, ?6, NULL)", + NULL, NULL, NULL, NULL, NULL, ?6, ?6, NULL, + CASE WHEN ?5='archived' THEN ?6 ELSE NULL END, + CASE WHEN ?5='archived' THEN ?7 ELSE NULL END)", params![ &context.run_id, &context.org_id, @@ -90,6 +92,7 @@ fn prepare_command_run(status: &str) -> AgentOrgRunContext { context.root_session_id.as_deref(), status, &now, + format!("{}-archive-receipt", context.run_id), ], ) .expect("insert command test run"); @@ -465,8 +468,7 @@ fn run_phase_projects_completed_work_as_finalizing_then_idle() { ); } -#[test] -fn run_view_is_a_pure_read_and_does_not_advance_updated_at() { +fn assert_run_view_is_a_pure_read(status: &str) { let _sandbox = test_helpers::test_env::sandbox(); let conn = get_connection().expect("db connection"); crate::foundation::persistence::test_schema::ensure_agent_sessions_schema(&conn); @@ -498,7 +500,7 @@ fn run_view_is_a_pure_read_and_does_not_advance_updated_at() { .expect("runtime support schemas"); drop(conn); - let context = prepare_command_run("running"); + let context = prepare_command_run(status); crate::session::persistence::upsert_session( &crate::session::persistence::UnifiedSessionRecord { session_id: "root-shared-agent".to_string(), @@ -538,11 +540,21 @@ fn run_view_is_a_pure_read_and_does_not_advance_updated_at() { |row| row.get(0), ) .expect("read run timestamp after Run View"); - assert_eq!(view.run_status, "running"); + assert_eq!(view.run_status, status); assert_eq!(after_data_version, before_data_version); assert_eq!(after_updated_at, before_updated_at); } +#[test] +fn running_run_view_is_a_pure_read_and_does_not_advance_updated_at() { + assert_run_view_is_a_pure_read("running"); +} + +#[test] +fn archived_run_view_is_a_pure_read_and_does_not_advance_updated_at() { + assert_run_view_is_a_pure_read("archived"); +} + #[test] fn task_runtime_projects_execution_mode_on_the_wire() { let task = AgentOrgTaskRuntime { @@ -680,7 +692,7 @@ fn resume_wake_requires_unread_inbox() { #[test] fn archived_group_message_writes_neither_inbox_nor_intervention_clear() { let _sandbox = test_helpers::test_env::sandbox(); - let context = prepare_command_run("archived"); + let context = prepare_command_run("running"); AgentMemberInterventionStore::enter(EnterMemberInterventionParams { org_run_id: context.run_id.clone(), member_id: "member-planner".to_string(), @@ -690,6 +702,19 @@ fn archived_group_message_writes_neither_inbox_nor_intervention_clear() { ttl_secs: 60, }) .expect("enter intervention"); + let conn = get_connection().expect("db connection"); + conn.execute( + "UPDATE agent_org_runtime_runs + SET status='archived',activation_generation=activation_generation+1, + archived_at=?2,archive_receipt_id=?3 + WHERE id=?1", + params![ + &context.run_id, + chrono::Utc::now().to_rfc3339(), + format!("{}-group-test-archive-receipt", context.run_id) + ], + ) + .expect("archive test Run without clearing the corruption fixture"); let error = persist_group_chat_message( &context, @@ -700,7 +725,7 @@ fn archived_group_message_writes_neither_inbox_nor_intervention_clear() { ) .expect_err("Archived run rejects group message"); - assert!(error.contains("this status does not accept")); + assert!(error.contains("team_archived")); assert_eq!(inbox_count_for_member(&context, "member-planner"), 0); assert!( AgentMemberInterventionStore::active_for_member(&context.run_id, "member-planner") @@ -833,8 +858,15 @@ fn group_chat_history_pages_all_rows_and_preserves_long_display_text_after_reloa let conn = get_connection().expect("db connection"); conn.execute( - "UPDATE agent_org_runtime_runs SET status='archived' WHERE id=?1", - params![&context.run_id], + "UPDATE agent_org_runtime_runs + SET status='archived',activation_generation=activation_generation+1, + archived_at=?2,archive_receipt_id=?3 + WHERE id=?1", + params![ + &context.run_id, + chrono::Utc::now().to_rfc3339(), + format!("{}-history-archive-receipt", context.run_id) + ], ) .expect("archive run"); assert_eq!( @@ -2309,10 +2341,7 @@ fn return_to_work_rolls_back_intervention_clear_when_boundary_capture_fails() { #[test] fn group_chat_target_clear_exits_direct_intervention() { let _sandbox = test_helpers::test_env::sandbox(); - let conn = get_connection().expect("db connection"); - crate::coordination::agent_member_interventions::init_schema(&conn) - .expect("intervention schema"); - let context = context_with_shared_member_agent_id(); + let context = prepare_command_run("running"); AgentMemberInterventionStore::enter(EnterMemberInterventionParams { org_run_id: context.run_id.clone(), diff --git a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs index 11d58a0681..e908f30370 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs @@ -1,19 +1,19 @@ //! Persistence commands for session data. -use std::collections::HashSet; use std::sync::Arc; +use crate::coordination::agent_org_ownership::AgentOrgTeamOwnership as AgentOrgSessionDeletePlan; use crate::coordination::agent_org_runs::AgentOrgRunStore; use crate::interaction::plan_approval::persistence::PlanApprovalStore; use crate::persistence::db_helpers as shared; use crate::persistence::session_snapshots; use crate::session::persistence as session_persistence; -use crate::session::{SessionListFilter, SessionStatus}; +use crate::session::SessionListFilter; use crate::state::control_flow::CancelReason; use crate::state::{AgentAppState, AgentSession}; use crate::tools::file_history; use database::db::{get_connection, with_sessions_writer}; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; use super::common::review_session_ids; @@ -50,30 +50,12 @@ pub async fn agent_list_all_sessions() -> Result, String> .await } -const MAX_AGENT_ORG_DELETE_SESSIONS: usize = 1_024; - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeleteSessionReceipt { pub deleted_session_ids: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct AgentOrgSessionDeleteNode { - session_id: String, - parent_session_id: Option, - status: SessionStatus, - depth: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AgentOrgSessionDeletePlan { - run_id: String, - root_session_id: String, - run_status: crate::coordination::agent_org_runs::AgentOrgRunStatus, - sessions: Vec, -} - /// Delete a session and all related data. #[tauri::command] pub async fn agent_delete_session( @@ -102,25 +84,108 @@ pub async fn agent_delete_session( }); }; - if plan.run_status != crate::coordination::agent_org_runs::AgentOrgRunStatus::Archived { + Err(format!( + "agent_org_team_delete_required: session {} belongs to Agent Org Team {} and must use Team Delete", + session_id, plan.run_id + )) +} + +/// Permanently delete one already-Archived Team after its bounded runtime +/// teardown receipt proves every captured owner is quiesced. +#[tauri::command] +pub async fn agent_org_delete_team( + state: tauri::State<'_, AgentAppState>, + session_id: String, +) -> Result { + crate::coordination::agent_org_runs::require_agent_org_redesign()?; + let planned_session_id = session_id.clone(); + let plan = tokio::task::spawn_blocking(move || { + let conn = get_connection().map_err(|err| err.to_string())?; + load_agent_org_session_delete_plan(&conn, &planned_session_id) + }) + .await + .map_err(|err| format!("Team deletion planning worker failed: {err}"))?? + .ok_or_else(|| format!("agent_org_team_not_found: session {session_id} has no Team"))?; + + validate_agent_org_delete_ready(&plan)?; + let runtime_sessions = acquire_agent_org_runtime_delete_fence(&state, &plan).await?; + let planned_session_ids = plan + .sessions + .iter() + .map(|node| node.session_id.clone()) + .collect::>(); + let background_blockers = + crate::tools::impls::coding::exec::registry::execution_blockers_for_sessions( + &planned_session_ids, + 16, + ); + if !background_blockers.is_empty() { + for (_, session) in &runtime_sessions { + session.clear_team_delete_runtime_fence(); + } + let evidence = background_blockers + .iter() + .map(|job| { + format!( + "{}:{}:{}:{}", + job.session_id, job.kind, job.handle, job.execution_state + ) + }) + .collect::>() + .join(","); return Err(format!( - "Refusing to delete Agent Org run {}: Archive is required before Delete", + "team_background_jobs_not_quiesced: Team {} still owns executing background jobs: {evidence}", plan.run_id )); } - ensure_agent_org_runtime_sessions_idle(&state, &plan).await?; - let quiesced_runtime_session_ids = HashSet::new(); - - validate_agent_org_delete_ready(&plan, &quiesced_runtime_session_ids)?; - ensure_agent_org_runtime_sessions_idle(&state, &plan).await?; - let receipt = tokio::task::spawn_blocking(move || { - delete_agent_org_session_hierarchy(&plan, &quiesced_runtime_session_ids) - }) - .await - .map_err(|err| format!("Agent Org session deletion worker failed: {err}"))??; + let delete_plan = plan.clone(); + let delete_result = + tokio::task::spawn_blocking(move || delete_agent_org_session_hierarchy(&delete_plan)) + .await + .map_err(|err| format!("Agent Org Team deletion worker failed: {err}")) + .and_then(|result| result); + let receipt = match delete_result { + Ok(receipt) => receipt, + Err(error) => { + for (_, session) in &runtime_sessions { + session.clear_team_delete_runtime_fence(); + } + return Err(error); + } + }; + let purged_jobs = + crate::tools::impls::coding::exec::registry::purge_deleted_sessions( + &receipt.deleted_session_ids, + ) + .map_err(|error| { + format!( + "team_deleted_but_background_job_purge_failed: Team {} was deleted from the database but its in-memory job registry could not be purged: {error}", + plan.run_id + ) + })?; + if purged_jobs.live_jobs > 0 || purged_jobs.tombstones > 0 { + tracing::info!( + live_jobs = purged_jobs.live_jobs, + tombstones = purged_jobs.tombstones, + "Team Delete purged background-job registry state for physically deleted sessions" + ); + } state.remove_sessions(&receipt.deleted_session_ids).await; + let forgotten_memory_job_seals = receipt + .deleted_session_ids + .iter() + .filter(|session_id| { + crate::memory::background::forget_memory_job_seal_for_deleted_session(session_id) + }) + .count(); + if forgotten_memory_job_seals > 0 { + tracing::info!( + forgotten_memory_job_seals, + "Team Delete released memory-job seals for physically deleted sessions" + ); + } if let Some(app_handle) = state.app_handle.as_ref() { for deleted_session_id in &receipt.deleted_session_ids { crate::bus::event_pipeline_bridge::evict_session(app_handle, deleted_session_id); @@ -131,240 +196,58 @@ pub async fn agent_delete_session( fn load_agent_org_session_delete_plan( conn: &Connection, - root_session_id: &str, + session_id: &str, ) -> Result, String> { - let run_rows = { - let mut stmt = conn - .prepare( - "SELECT id, status - FROM agent_org_runtime_runs - WHERE root_session_id=?1 - ORDER BY id", - ) - .map_err(|err| err.to_string())?; - let rows = stmt - .query_map([root_session_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(|err| err.to_string())?; - rows.collect::, _>>() - .map_err(|err| err.to_string())? - }; + crate::coordination::agent_org_ownership::resolve_team_for_session(conn, session_id) +} +fn validate_agent_org_delete_ready(plan: &AgentOrgSessionDeletePlan) -> Result<(), String> { + let conn = get_connection().map_err(|error| error.to_string())?; + validate_agent_org_delete_ready_with_connection(&conn, plan) +} - let Some((run_id, run_status_raw)) = run_rows.first() else { - return Ok(None); - }; - if run_rows.len() != 1 { +fn validate_agent_org_delete_ready_with_connection( + conn: &Connection, + plan: &AgentOrgSessionDeletePlan, +) -> Result<(), String> { + if plan.run_status != crate::coordination::agent_org_runs::AgentOrgRunStatus::Archived { return Err(format!( - "Refusing to delete Agent Org root {root_session_id}: {} runs claim the same root", - run_rows.len() + "team_delete_requires_archived: Team {} status is {}", + plan.run_id, plan.run_status )); } - let run_status = crate::coordination::agent_org_runs::AgentOrgRunStatus::parse(run_status_raw) - .ok_or_else(|| { - format!( - "Refusing to delete Agent Org run {run_id}: unknown run status {run_status_raw:?}" - ) - })?; - - let mut stmt = conn - .prepare( - "WITH RECURSIVE descendants( - session_id, parent_session_id, status, depth, path, cycle - ) AS ( - SELECT session_id, - parent_session_id, - status, - 0, - '/' || hex(session_id) || '/', - 0 - FROM agent_sessions - WHERE session_id=?1 - UNION ALL - SELECT child.session_id, - child.parent_session_id, - child.status, - parent.depth + 1, - parent.path || hex(child.session_id) || '/', - instr(parent.path, '/' || hex(child.session_id) || '/') > 0 - FROM agent_sessions child - JOIN descendants parent - ON child.parent_session_id=parent.session_id - WHERE parent.cycle=0 - AND parent.depth < ?3 - ) - SELECT descendant.session_id, - descendant.parent_session_id, - descendant.status, - descendant.depth, - descendant.cycle, - ( - SELECT nested.id - FROM agent_org_runtime_runs nested - WHERE nested.id<>?2 - AND nested.root_session_id=descendant.session_id - ORDER BY nested.id - LIMIT 1 - ) AS nested_run_id, - EXISTS( - SELECT 1 - FROM agent_sessions child - WHERE child.parent_session_id=descendant.session_id - ) AS has_children - FROM descendants descendant", - ) - .map_err(|err| err.to_string())?; - let rows = stmt - .query_map( - params![ - root_session_id, - run_id, - MAX_AGENT_ORG_DELETE_SESSIONS as i64 - ], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, bool>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, bool>(6)?, - )) - }, + let receipt_id = plan.archive_receipt_id.as_deref().ok_or_else(|| { + format!( + "team_runtime_not_quiesced: Team {} has no Archive receipt", + plan.run_id ) - .map_err(|err| err.to_string())?; - - let mut sessions = Vec::new(); - let mut visited = std::collections::HashSet::new(); - for row in rows { - let (session_id, parent_session_id, status_raw, depth, cycle, nested_run_id, has_children) = - row.map_err(|err| err.to_string())?; - if cycle { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: session ancestry contains a cycle at {session_id}" - )); - } - if !visited.insert(session_id.clone()) { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: session hierarchy visits {session_id} more than once" - )); - } - if depth < 0 { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: invalid depth for {session_id}" - )); - } - let depth = usize::try_from(depth).map_err(|err| err.to_string())?; - if depth >= MAX_AGENT_ORG_DELETE_SESSIONS && has_children { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: session hierarchy exceeds {MAX_AGENT_ORG_DELETE_SESSIONS} nodes" - )); - } - if depth > 0 { - if let Some(nested_run_id) = nested_run_id { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: descendant session {session_id} is root of unsupported nested run {nested_run_id}" - )); - } - } - let status = SessionStatus::parse(&status_raw).ok_or_else(|| { - format!( - "Refusing to delete Agent Org run {run_id}: session {session_id} has unknown status {status_raw:?}" - ) - })?; - sessions.push(AgentOrgSessionDeleteNode { - session_id, - parent_session_id, - status, - depth, - }); - if sessions.len() > MAX_AGENT_ORG_DELETE_SESSIONS { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: session hierarchy exceeds {MAX_AGENT_ORG_DELETE_SESSIONS} nodes" - )); - } - } - if sessions.is_empty() - || sessions - .iter() - .all(|node| node.session_id != root_session_id) - { + })?; + if plan.archived_at.is_none() { return Err(format!( - "Refusing to delete Agent Org run {run_id}: root session {root_session_id} is missing" + "team_runtime_not_quiesced: Team {} has no Archive timestamp", + plan.run_id )); } - let depths = sessions - .iter() - .map(|node| (node.session_id.as_str(), node.depth)) - .collect::>(); - for node in &sessions { - if node.depth == 0 { - if node.session_id != root_session_id { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: unexpected depth-zero session {}", - node.session_id - )); - } - continue; - } - let parent_session_id = node.parent_session_id.as_deref().ok_or_else(|| { - format!( - "Refusing to delete Agent Org run {run_id}: descendant session {} has no parent", - node.session_id - ) - })?; - let parent_depth = depths.get(parent_session_id).ok_or_else(|| { - format!( - "Refusing to delete Agent Org run {run_id}: descendant session {} references missing parent {parent_session_id}", - node.session_id - ) - })?; - if parent_depth.saturating_add(1) != node.depth { - return Err(format!( - "Refusing to delete Agent Org run {run_id}: descendant session {} has inconsistent depth", - node.session_id - )); - } - } - - sessions.sort_by(|left, right| { - right - .depth - .cmp(&left.depth) - .then_with(|| left.session_id.cmp(&right.session_id)) - }); - Ok(Some(AgentOrgSessionDeletePlan { - run_id: run_id.clone(), - root_session_id: root_session_id.to_string(), - run_status, - sessions, - })) -} - -fn validate_agent_org_delete_ready( - plan: &AgentOrgSessionDeletePlan, - _quiesced_runtime_session_ids: &HashSet, -) -> Result<(), String> { - if plan.run_status != crate::coordination::agent_org_runs::AgentOrgRunStatus::Archived { + let summary = crate::coordination::agent_org_archive::summary_for_run_with_connection( + conn, + &plan.run_id, + )? + .ok_or_else(|| { + format!( + "team_runtime_not_quiesced: Team {} has no teardown receipt", + plan.run_id + ) + })?; + if summary.receipt_id != receipt_id + || summary.status != crate::coordination::agent_org_archive::ArchiveTeardownStatus::Quiesced + || summary.retained_runtime_count != 0 + { return Err(format!( - "Refusing to delete Agent Org run {}: run status is {}", + "team_runtime_not_quiesced: Team {} Archive teardown is {} with {} retained runtime(s)", plan.run_id, - plan.run_status.as_str() + summary.status.as_str(), + summary.retained_runtime_count )); } - - for node in &plan.sessions { - let allowed = node.status == SessionStatus::Idle || node.status.is_terminal(); - if !allowed { - return Err(format!( - "Refusing to delete Agent Org run {}: session {} status is {}", - plan.run_id, - node.session_id, - node.status.as_str() - )); - } - } Ok(()) } @@ -389,29 +272,37 @@ async fn agent_org_runtime_blockers( ) -> Vec { let mut blockers = Vec::new(); for (session_id, session) in runtime_sessions { + let runtime_lease = session.runtime_lease_identity().await; let scheduler_processing = session.scheduler.is_processing(); let pending_count = session.scheduler.pending_count(); let active_turn = session.active_turn.lock().await.is_some(); - if active_turn || scheduler_processing || pending_count > 0 { + if runtime_lease.is_some() || active_turn || scheduler_processing || pending_count > 0 { blockers.push(format!( - "{session_id}(active_turn={active_turn},scheduler_processing={scheduler_processing},pending={pending_count})" + "{session_id}(runtime_lease={},active_turn={active_turn},scheduler_processing={scheduler_processing},pending={pending_count})", + runtime_lease.is_some() )); } } blockers } -async fn ensure_agent_org_runtime_sessions_idle( +async fn acquire_agent_org_runtime_delete_fence( state: &AgentAppState, plan: &AgentOrgSessionDeletePlan, -) -> Result<(), String> { +) -> Result)>, String> { let runtime_sessions = agent_org_runtime_sessions(state, plan).await; + for (_, session) in &runtime_sessions { + session.begin_team_delete_runtime_fence().await; + } let blockers = agent_org_runtime_blockers(&runtime_sessions).await; if blockers.is_empty() { - Ok(()) + Ok(runtime_sessions) } else { + for (_, session) in &runtime_sessions { + session.clear_team_delete_runtime_fence(); + } Err(format!( - "Refusing to delete Agent Org run {}: active Rust runtime sessions: {}", + "team_runtime_not_quiesced: Team {} still owns in-memory runtime state: {}", plan.run_id, blockers.join(", ") )) @@ -420,7 +311,6 @@ async fn ensure_agent_org_runtime_sessions_idle( fn delete_agent_org_session_hierarchy( expected_plan: &AgentOrgSessionDeletePlan, - quiesced_runtime_session_ids: &HashSet, ) -> Result { for node in &expected_plan.sessions { session_persistence::prepare_session_delete(&node.session_id) @@ -445,7 +335,7 @@ fn delete_agent_org_session_hierarchy( expected_plan.run_id )); } - validate_agent_org_delete_ready(¤t_plan, quiesced_runtime_session_ids)?; + validate_agent_org_delete_ready_with_connection(&tx, ¤t_plan)?; for node in &expected_plan.sessions { session_persistence::delete_session_with_connection(&tx, &node.session_id) diff --git a/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs b/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs index 1d2c59c917..586906bddf 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs @@ -65,15 +65,45 @@ fn seed_session(session_id: &str, parent_session_id: Option<&str>) { fn seed_run_with_status(run_id: &str, root_session_id: &str, status: &str) { let conn = get_connection().expect("sandbox DB"); - conn.execute( - "INSERT INTO agent_org_runtime_runs ( - id, org_id, coordinator_agent_id, root_session_id, - entry_mode, status, created_at, updated_at - ) VALUES (?1, 'org-delete-test', 'coordinator-agent', ?2, - 'standalone_session', ?3, ?4, ?4)", - rusqlite::params![run_id, root_session_id, status, "2026-07-16T00:00:00Z"], - ) - .expect("seed run"); + let now = "2026-07-16T00:00:00Z"; + if status == "archived" { + let receipt_id = format!("archive-receipt-{run_id}"); + conn.execute( + "INSERT INTO agent_org_runtime_runs ( + id, org_id, coordinator_agent_id, root_session_id, + entry_mode, status, activation_generation, archived_at, + archive_receipt_id, created_at, updated_at + ) VALUES (?1, 'org-delete-test', 'coordinator-agent', ?2, + 'standalone_session', 'archived', 2, ?3, ?4, ?3, ?3)", + rusqlite::params![run_id, root_session_id, now, &receipt_id], + ) + .expect("seed archived run"); + conn.execute( + "INSERT INTO agent_org_runtime_archive_episodes ( + archive_receipt_id,org_run_id,archive_request_id, + archive_generation,teardown_status,teardown_attempt_count, + retained_runtime_count,deadline_at,archived_at,updated_at,quiesced_at + ) VALUES (?1,?2,?3,2,'quiesced',1,0,?4,?5,?5,?5)", + rusqlite::params![ + &receipt_id, + run_id, + format!("archive-request-{run_id}"), + "2026-07-16T00:01:00Z", + now, + ], + ) + .expect("seed quiesced Archive receipt"); + } else { + conn.execute( + "INSERT INTO agent_org_runtime_runs ( + id, org_id, coordinator_agent_id, root_session_id, + entry_mode, status, created_at, updated_at + ) VALUES (?1, 'org-delete-test', 'coordinator-agent', ?2, + 'standalone_session', ?3, ?4, ?4)", + rusqlite::params![run_id, root_session_id, status, now], + ) + .expect("seed run"); + } } fn seed_run(run_id: &str, root_session_id: &str) { @@ -140,6 +170,21 @@ fn seed_run_owned_rows(run_id: &str) { .expect("seed run task history"); } +fn seed_active_session_registry(session_id: &str) { + crate::session::file_registry::register_session( + &crate::session::file_registry::SessionRegistryEntry { + session_id: session_id.to_string(), + agent_type: "SDE Agent".to_string(), + model: "test-model".to_string(), + workspace_path: Some("/tmp/agent-org-delete-test".to_string()), + status: "running".to_string(), + started_at: "2026-07-16T00:00:00Z".to_string(), + last_updated_at: "2026-07-16T00:00:00Z".to_string(), + }, + ) + .expect("seed active-session registry"); +} + fn row_exists(table: &str, column: &str, value: &str) -> bool { get_connection() .expect("sandbox DB") @@ -169,6 +214,7 @@ fn session_hierarchy_delete_removes_all_rust_descendants_and_run_history() { seed_run("hierarchy-delete-other-run", unrelated_root); for session_id in [root, worker, grandchild, unrelated] { seed_session_owned_rows(session_id); + seed_active_session_registry(session_id); } seed_run_owned_rows("hierarchy-delete-run"); seed_run_owned_rows("hierarchy-delete-other-run"); @@ -178,8 +224,7 @@ fn session_hierarchy_delete_removes_all_rust_descendants_and_run_history() { .expect("plan hierarchy") .expect("root owns Agent Org run"); drop(conn); - let receipt = delete_agent_org_session_hierarchy(&plan, &HashSet::new()) - .expect("delete completed hierarchy"); + let receipt = delete_agent_org_session_hierarchy(&plan).expect("delete completed hierarchy"); assert_eq!( receipt.deleted_session_ids, @@ -226,10 +271,16 @@ fn session_hierarchy_delete_removes_all_rust_descendants_and_run_history() { "org_run_id", "hierarchy-delete-other-run" )); + let mut registered_session_ids = crate::session::file_registry::list_registered_sessions() + .into_iter() + .map(|entry| entry.session_id) + .collect::>(); + registered_session_ids.sort(); + assert_eq!(registered_session_ids, vec![unrelated.to_string()]); } #[test] -fn session_hierarchy_delete_worker_keeps_root_and_run() { +fn team_ownership_resolver_maps_worker_to_root_and_run() { let _sandbox = test_helpers::test_env::sandbox(); ensure_test_schemas(); let root = "hierarchy-worker-root"; @@ -240,16 +291,14 @@ fn session_hierarchy_delete_worker_keeps_root_and_run() { seed_run_owned_rows("hierarchy-worker-run"); let conn = get_connection().expect("sandbox DB"); - assert!( - load_agent_org_session_delete_plan(&conn, worker) - .expect("plan worker") - .is_none(), - "a worker must not be promoted to hierarchy root deletion" - ); + let plan = load_agent_org_session_delete_plan(&conn, worker) + .expect("plan worker") + .expect("worker must resolve to its Team"); + assert_eq!(plan.root_session_id, root); + assert_eq!(plan.run_id, "hierarchy-worker-run"); drop(conn); - session_persistence::delete_session(worker).expect("canonical single-session deletion"); - assert!(!row_exists("agent_sessions", "session_id", worker)); + assert!(row_exists("agent_sessions", "session_id", worker)); assert!(row_exists("agent_sessions", "session_id", root)); assert!(row_exists( "agent_org_runtime_runs", @@ -278,9 +327,9 @@ fn session_hierarchy_delete_requires_archived_without_mutating_active_run() { .expect("load running hierarchy") .expect("root owns run"); drop(conn); - let error = validate_agent_org_delete_ready(&plan, &HashSet::new()) + let error = validate_agent_org_delete_ready(&plan) .expect_err("Delete must fail closed before the Archive transition exists"); - assert!(error.contains("run status is running")); + assert!(error.contains("team_delete_requires_archived")); assert_eq!( get_connection() .expect("sandbox DB") @@ -301,6 +350,74 @@ fn session_hierarchy_delete_requires_archived_without_mutating_active_run() { )); } +#[test] +fn session_hierarchy_delete_rejects_retained_runtime_receipt() { + let _sandbox = test_helpers::test_env::sandbox(); + ensure_test_schemas(); + let root = "hierarchy-retained-root"; + let run_id = "hierarchy-retained-run"; + seed_session(root, None); + seed_run(run_id, root); + let conn = get_connection().expect("sandbox DB"); + conn.execute( + "UPDATE agent_org_runtime_archive_episodes + SET teardown_status='retained_runtime',teardown_attempt_count=3, + retained_runtime_count=1,quiesced_at=NULL, + last_error='archive_runtime_stop_timeout' + WHERE org_run_id=?1", + [run_id], + ) + .expect("mark retained runtime"); + let plan = load_agent_org_session_delete_plan(&conn, root) + .expect("load Team") + .expect("Team plan"); + let error = validate_agent_org_delete_ready_with_connection(&conn, &plan) + .expect_err("retained runtime must block Team Delete"); + assert!(error.starts_with("team_runtime_not_quiesced:")); + assert!(row_exists("agent_sessions", "session_id", root)); + assert!(row_exists("agent_org_runtime_runs", "id", run_id)); +} + +#[test] +fn orphaned_agent_org_member_marker_never_falls_back_to_generic_delete() { + let _sandbox = test_helpers::test_env::sandbox(); + ensure_test_schemas(); + let session_id = "hierarchy-orphaned-member"; + seed_session(session_id, None); + let conn = get_connection().expect("sandbox DB"); + conn.execute( + "UPDATE agent_sessions SET org_member_id='worker' WHERE session_id=?1", + [session_id], + ) + .expect("seed orphaned Agent Org marker"); + let error = load_agent_org_session_delete_plan(&conn, session_id) + .expect_err("orphaned Agent Org ownership must fail closed"); + assert!(error.starts_with("agent_org_ownership_ambiguous:")); + assert!(row_exists("agent_sessions", "session_id", session_id)); +} + +#[test] +fn orphaned_agent_org_root_with_marked_member_never_falls_back_to_generic_delete() { + let _sandbox = test_helpers::test_env::sandbox(); + ensure_test_schemas(); + let root = "hierarchy-orphaned-root"; + let member = "hierarchy-orphaned-root-member"; + seed_session(root, None); + seed_session(member, Some(root)); + let conn = get_connection().expect("sandbox DB"); + conn.execute( + "UPDATE agent_sessions SET org_member_id='worker' WHERE session_id=?1", + [member], + ) + .expect("seed orphaned Agent Org descendant marker"); + + let error = load_agent_org_session_delete_plan(&conn, root) + .expect_err("orphaned Agent Org root ownership must fail closed"); + assert!(error.starts_with("agent_org_ownership_ambiguous:")); + assert!(row_exists("agent_sessions", "session_id", root)); + assert!(row_exists("agent_sessions", "session_id", member)); +} + #[test] fn session_hierarchy_delete_blocks_resource_preflight_failures_before_database_changes() { let _sandbox = test_helpers::test_env::sandbox(); @@ -333,7 +450,7 @@ fn session_hierarchy_delete_blocks_resource_preflight_failures_before_database_c ) .expect("create active replay"); - let error = delete_agent_org_session_hierarchy(&plan, &HashSet::new()) + let error = delete_agent_org_session_hierarchy(&plan) .expect_err("active replay must block hierarchy deletion"); assert!(error.contains(worker)); assert!(error.contains("shell replay calls are active")); @@ -365,7 +482,7 @@ fn session_hierarchy_delete_blocks_resource_preflight_failures_before_database_c ], ) .expect("seed invalid worktree metadata"); - let error = delete_agent_org_session_hierarchy(&plan, &HashSet::new()) + let error = delete_agent_org_session_hierarchy(&plan) .expect_err("worktree validation failure must block hierarchy deletion"); assert!(error.contains(worker)); assert!(error.contains("repository path no longer exists")); @@ -436,7 +553,7 @@ fn session_hierarchy_delete_rejects_cycle_and_size_limit() { seed_run("hierarchy-limit-run", limit_root); let mut conn = get_connection().expect("sandbox DB"); let tx = conn.transaction().expect("seed oversized hierarchy"); - for index in 0..MAX_AGENT_ORG_DELETE_SESSIONS { + for index in 0..crate::coordination::agent_org_ownership::MAX_AGENT_ORG_OWNED_SESSIONS { let session_id = format!("hierarchy-limit-worker-{index:04}"); tx.execute( "INSERT INTO agent_sessions ( @@ -477,8 +594,8 @@ fn session_hierarchy_delete_rechecks_concurrent_structure_changes() { drop(conn); seed_session("hierarchy-recheck-late-worker", Some(root)); - let error = delete_agent_org_session_hierarchy(&plan, &HashSet::new()) - .expect_err("changed hierarchy must fail closed"); + let error = + delete_agent_org_session_hierarchy(&plan).expect_err("changed hierarchy must fail closed"); assert!(error.contains("changed before deletion")); for session_id in [root, worker, "hierarchy-recheck-late-worker"] { assert!(row_exists("agent_sessions", "session_id", session_id)); @@ -518,8 +635,8 @@ fn session_hierarchy_delete_rolls_back_on_midway_database_failure() { .expect("install failure trigger"); drop(conn); - let error = delete_agent_org_session_hierarchy(&plan, &HashSet::new()) - .expect_err("trigger must abort transaction"); + let error = + delete_agent_org_session_hierarchy(&plan).expect_err("trigger must abort transaction"); assert!(error.contains("injected hierarchy delete failure")); for session_id in [root, worker] { for table in [ @@ -592,7 +709,7 @@ fn session_hierarchy_delete_rolls_back_transaction_time_structure_changes() { .expect("install mutation trigger"); drop(conn); - let error = delete_agent_org_session_hierarchy(&plan, &HashSet::new()) + let error = delete_agent_org_session_hierarchy(&plan) .expect_err("transaction-time hierarchy mutation must abort"); assert!(error.contains("residual session hierarchy row")); assert!(row_exists("agent_sessions", "session_id", root)); diff --git a/src-tauri/crates/agent-core/src/state/control_flow.rs b/src-tauri/crates/agent-core/src/state/control_flow.rs index 3157546fd8..1ac2b65ac9 100644 --- a/src-tauri/crates/agent-core/src/state/control_flow.rs +++ b/src-tauri/crates/agent-core/src/state/control_flow.rs @@ -7,6 +7,7 @@ pub enum CancelReason { UserStop, ForceSend, OrgPause, + OrgArchive, AgentOrgDelete, ProgrammaticShutdown, SessionEviction, @@ -39,7 +40,10 @@ impl CancelReason { /// enumerates durable member rows, including lazy members that have never /// started, so absence is normal for `OrgPause` and must not corrupt them. pub const fn repairs_missing_session_as_failed(self) -> bool { - !matches!(self, Self::OrgPause | Self::AgentOrgDelete) + !matches!( + self, + Self::OrgPause | Self::OrgArchive | Self::AgentOrgDelete + ) } pub fn boundary_effect(self) -> TurnBoundaryEffect { @@ -68,6 +72,14 @@ impl CancelReason { discard_queued_messages: false, cancel_background_workers: true, }, + Self::OrgArchive => TurnBoundaryEffect { + keep_pre_turn_cancel_when_idle: true, + clear_pending_approvals: true, + persist_cancel_marker: false, + allow_crash_repair_on_next_turn: false, + discard_queued_messages: true, + cancel_background_workers: true, + }, Self::AgentOrgDelete => TurnBoundaryEffect { // The delete fence can land after the scheduler has claimed a // job but before that job registers `active_turn`. Keep the @@ -98,6 +110,7 @@ impl CancelReason { Self::UserStop => "user_stop", Self::ForceSend => "force_send", Self::OrgPause => "org_pause", + Self::OrgArchive => "org_archive", Self::AgentOrgDelete => "agent_org_delete", Self::ProgrammaticShutdown => "programmatic_shutdown", Self::SessionEviction => "session_eviction", @@ -113,6 +126,7 @@ mod tests { #[test] fn org_pause_does_not_fail_lazy_persisted_sessions() { assert!(!CancelReason::OrgPause.repairs_missing_session_as_failed()); + assert!(!CancelReason::OrgArchive.repairs_missing_session_as_failed()); assert!(!CancelReason::AgentOrgDelete.repairs_missing_session_as_failed()); assert!( CancelReason::AgentOrgDelete diff --git a/src-tauri/crates/agent-core/src/state/session_runtime.rs b/src-tauri/crates/agent-core/src/state/session_runtime.rs index dd8ce12ee3..f2588cc649 100644 --- a/src-tauri/crates/agent-core/src/state/session_runtime.rs +++ b/src-tauri/crates/agent-core/src/state/session_runtime.rs @@ -117,6 +117,15 @@ pub(crate) struct RuntimeTurnIdentity { pub turn_intent_id: Option, } +/// Exact lease snapshot used by Archive. Unlike Pause, Archive must also +/// release an initialized Provider that currently has no active Turn. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeLeaseIdentity { + pub runtime_lease_id: String, + pub dialog_turn_generation: Option, + pub turn_intent_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ActiveTurnIdentity { runtime_lease_id: Option, @@ -143,6 +152,11 @@ pub struct AgentSession { /// `None` briefly while the session is being registered before /// `ensure_session_initialized` completes. runtime: tokio::sync::RwLock>, + /// In-memory half of the Team Delete fence. The Archived database gate + /// prevents normal initialization, while this flag closes the final race + /// between Delete's last lease check and a stale initializer installing + /// its already-built runtime. + runtime_install_blocked: AtomicBool, // ── Execution Control ───────────────────────────────────────────────── /// Cancellation flag — set to `true` to abort the active turn. @@ -328,6 +342,7 @@ impl AgentSession { id, definition, runtime: tokio::sync::RwLock::new(None), + runtime_install_blocked: AtomicBool::new(false), compaction: tokio::sync::Mutex::new(CompactionState::default()), last_context_tokens: Arc::new(AtomicI64::new(0)), permission_manager, @@ -368,13 +383,34 @@ impl AgentSession { } /// Attach (or replace) the runtime after initialization completes. - pub async fn set_runtime(&self, runtime: Arc) -> String { + pub async fn set_runtime(&self, runtime: Arc) -> Result { let lease_id = uuid::Uuid::new_v4().to_string(); - *self.runtime.write().await = Some(RuntimeSlot { + let mut slot = self.runtime.write().await; + if self.runtime_install_blocked.load(Ordering::SeqCst) { + return Err("team_runtime_delete_in_progress: runtime installation is closed".into()); + } + *slot = Some(RuntimeSlot { lease_id: lease_id.clone(), runtime, }); - lease_id + Ok(lease_id) + } + + /// Close runtime installation before Team Delete checks the current slot. + /// Storing the fence before taking the read lock makes it race-safe with + /// `set_runtime`: either the installer wins and Delete observes its slot, + /// or Delete wins and the installer is rejected while holding the slot + /// write lock. + pub(crate) async fn begin_team_delete_runtime_fence(&self) { + self.runtime_install_blocked.store(true, Ordering::SeqCst); + // Synchronize with an installer that may already hold the write lock. + // The caller performs the complete runtime/turn/scheduler check after + // every Team session has installed this fence. + drop(self.runtime.read().await); + } + + pub(crate) fn clear_team_delete_runtime_fence(&self) { + self.runtime_install_blocked.store(false, Ordering::SeqCst); } /// Return the current runtime, if initialized. @@ -412,6 +448,39 @@ impl AgentSession { .and_then(|turn| turn.process_control.clone()) } + pub(crate) async fn runtime_lease_identity(&self) -> Option { + let slot = self.runtime.read().await; + let lease_id = slot.as_ref()?.lease_id.clone(); + let turn = self.active_turn_identity.read().clone(); + Some(RuntimeLeaseIdentity { + runtime_lease_id: lease_id, + dialog_turn_generation: turn.as_ref().and_then(|turn| { + (turn.runtime_lease_id.as_deref() == Some(slot.as_ref()?.lease_id.as_str())) + .then(|| turn.dialog_turn_generation.clone()) + }), + turn_intent_id: turn.and_then(|turn| { + (turn.runtime_lease_id.as_deref() == Some(slot.as_ref()?.lease_id.as_str())) + .then_some(turn.turn_intent_id) + .flatten() + }), + }) + } + + /// Release an idle or already-cancelled runtime only when the exact lease + /// captured by Archive is still current. A late Archive completion cannot + /// clear a replacement runtime. + pub(crate) async fn release_runtime_lease_if_current(&self, runtime_lease_id: &str) -> bool { + let mut slot = self.runtime.write().await; + if runtime_lease_identity_matches( + slot.as_ref().map(|current| current.lease_id.as_str()), + runtime_lease_id, + ) { + *slot = None; + return true; + } + false + } + /// Release only the runtime generation and dialog Turn captured by Pause. /// A stale completion is deliberately a no-op. pub(crate) async fn release_runtime_if_current( @@ -645,7 +714,7 @@ enum ShellCancellationScope { const fn shell_cancellation_scope(reason: CancelReason) -> ShellCancellationScope { match reason { - CancelReason::UserStop => ShellCancellationScope::Session, + CancelReason::UserStop | CancelReason::OrgArchive => ShellCancellationScope::Session, CancelReason::OrgPause => ShellCancellationScope::ActiveTurn, CancelReason::ForceSend | CancelReason::AgentOrgDelete @@ -667,10 +736,15 @@ fn runtime_release_identity_matches( && current_turn_generation == Some(expected_turn_generation) } +fn runtime_lease_identity_matches(current_lease_id: Option<&str>, expected_lease_id: &str) -> bool { + current_lease_id == Some(expected_lease_id) +} + #[cfg(test)] mod runtime_lease_tests { use super::{ - runtime_release_identity_matches, shell_cancellation_scope, ShellCancellationScope, + runtime_lease_identity_matches, runtime_release_identity_matches, shell_cancellation_scope, + ShellCancellationScope, }; use crate::state::control_flow::CancelReason; @@ -684,6 +758,10 @@ mod runtime_lease_tests { shell_cancellation_scope(CancelReason::OrgPause), ShellCancellationScope::ActiveTurn ); + assert_eq!( + shell_cancellation_scope(CancelReason::OrgArchive), + ShellCancellationScope::Session + ); assert_eq!( shell_cancellation_scope(CancelReason::ForceSend), ShellCancellationScope::None @@ -694,6 +772,13 @@ mod runtime_lease_tests { ); } + #[test] + fn archive_release_never_clears_a_replacement_runtime_lease() { + assert!(runtime_lease_identity_matches(Some("lease-a"), "lease-a")); + assert!(!runtime_lease_identity_matches(Some("lease-b"), "lease-a")); + assert!(!runtime_lease_identity_matches(None, "lease-a")); + } + #[test] fn release_requires_the_same_runtime_lease_and_dialog_generation() { assert!(runtime_release_identity_matches( diff --git a/src-tauri/src/api/agent/mod.rs b/src-tauri/src/api/agent/mod.rs index 83e8f44c10..95e217ade3 100644 --- a/src-tauri/src/api/agent/mod.rs +++ b/src-tauri/src/api/agent/mod.rs @@ -751,6 +751,10 @@ pub fn create_routes() -> Router { "/test/agent-org/pause/evidence", post(test::agent_org::test_agent_org_pause_evidence), ) + .route( + "/test/agent-org/runtime-evidence", + post(test::agent_org::test_agent_org_runtime_evidence), + ) .route( "/test/agent-org/simulate-app-restart", post(test::agent_org::test_agent_org_simulate_app_restart), diff --git a/src-tauri/src/api/agent/test/agent_org.rs b/src-tauri/src/api/agent/test/agent_org.rs index 0406a1dbd7..02cc88f3ef 100644 --- a/src-tauri/src/api/agent/test/agent_org.rs +++ b/src-tauri/src/api/agent/test/agent_org.rs @@ -1521,7 +1521,7 @@ pub async fn test_agent_org_run_view( pub async fn test_agent_org_durable_invariants( Json(body): Json, ) -> Json { - use rusqlite::{params, OptionalExtension}; + use rusqlite::params; let Some(obj) = body.as_object() else { return Json(serde_json::json!({ "ok": false, "error": "body must be an object" })); @@ -1537,6 +1537,8 @@ pub async fn test_agent_org_durable_invariants( }; let result = tokio::task::spawn_blocking(move || -> Result { + use rusqlite::OptionalExtension; + let conn = database::db::get_connection().map_err(|err| err.to_string())?; let run_row: Option<(String, Option)> = conn .query_row( @@ -1907,6 +1909,8 @@ pub async fn test_agent_org_session_delete_snapshot( } let result = tokio::task::spawn_blocking(move || -> Result { + use rusqlite::OptionalExtension; + let conn = database::db::get_connection().map_err(|err| err.to_string())?; let mut sessions = serde_json::Map::new(); for session_id in session_ids { @@ -1922,6 +1926,7 @@ pub async fn test_agent_org_session_delete_snapshot( sessions.insert(session_id, serde_json::Value::Bool(exists)); } let mut runs = serde_json::Map::new(); + let mut run_details = serde_json::Map::new(); for run_id in run_ids { let exists = conn .query_row( @@ -1930,12 +1935,42 @@ pub async fn test_agent_org_session_delete_snapshot( |row| row.get::<_, bool>(0), ) .map_err(|err| err.to_string())?; + if exists { + let detail: Option = conn + .query_row( + "SELECT run.status,run.activation_generation,run.archived_at, + run.archive_receipt_id,archive.teardown_status, + archive.teardown_attempt_count,archive.retained_runtime_count + FROM agent_org_runtime_runs run + LEFT JOIN agent_org_runtime_archive_episodes archive + ON archive.org_run_id=run.id + WHERE run.id=?1", + [&run_id], + |row| { + Ok(serde_json::json!({ + "status": row.get::<_, String>(0)?, + "activation_generation": row.get::<_, i64>(1)?, + "archived_at": row.get::<_, Option>(2)?, + "archive_receipt_id": row.get::<_, Option>(3)?, + "teardown_status": row.get::<_, Option>(4)?, + "teardown_attempt_count": row.get::<_, Option>(5)?, + "retained_runtime_count": row.get::<_, Option>(6)?, + })) + }, + ) + .optional() + .map_err(|err| err.to_string())?; + if let Some(detail) = detail { + run_details.insert(run_id.clone(), detail); + } + } runs.insert(run_id, serde_json::Value::Bool(exists)); } Ok(serde_json::json!({ "ok": true, "sessions": sessions, "runs": runs, + "run_details": run_details, })) }) .await; @@ -3050,6 +3085,124 @@ pub async fn test_agent_org_resume_run( } } +async fn collect_agent_org_runtime_evidence( + state: &agent_core::state::AgentAppState, + session_ids: &[String], +) -> serde_json::Value { + let mut active_runtime_count = 0usize; + let mut active_turns = Vec::new(); + let mut background_shells = Vec::new(); + for session_id in session_ids { + for (pid, command) in + agent_core::tools::impls::coding::exec::registry::list_shell_for_session(session_id) + { + background_shells.push(serde_json::json!({ + "session_id": session_id, + "pid": pid, + "command": command, + })); + } + let Some(session) = state.get_session(session_id).await else { + continue; + }; + if session.get_runtime().await.is_some() { + active_runtime_count += 1; + } + if let Some(dialog_turn_generation) = session.active_turn_id().await { + active_turns.push(serde_json::json!({ + "session_id": session_id, + "dialog_turn_generation": dialog_turn_generation, + })); + } + } + let background_jobs = + agent_core::tools::impls::coding::exec::registry::session_runtime_evidence(session_ids, 64); + let execution_blockers = + agent_core::tools::impls::coding::exec::registry::execution_blockers_for_sessions( + session_ids, + 64, + ); + let retained_tombstone_count = + agent_core::tools::impls::coding::exec::registry::retained_tombstone_count(session_ids); + serde_json::json!({ + "active_runtime_count": active_runtime_count, + "active_turns": active_turns, + "background_shells": background_shells, + "background_jobs": background_jobs, + "execution_blockers": execution_blockers, + "retained_tombstone_count": retained_tombstone_count, + }) +} + +/// `POST /test/agent-org/runtime-evidence` +/// +/// Read-only runtime, Turn and background-execution evidence for Archive and +/// Delete E2E. Product lifecycle actions must still use their real buttons +/// and Tauri commands. +pub async fn test_agent_org_runtime_evidence( + Json(body): Json, +) -> Json { + use tauri::Manager; + + let Some(org_run_id) = body + .get("org_run_id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + else { + return Json(serde_json::json!({ + "ok": false, + "error": "org_run_id is required (non-empty string)" + })); + }; + let query_run_id = org_run_id.clone(); + let durable = tokio::task::spawn_blocking(move || -> Result { + let run = agent_core::coordination::agent_org_runs::AgentOrgRunStore::load(&query_run_id)? + .ok_or_else(|| format!("agent_org_run_not_found: {query_run_id}"))?; + let session_ids = + agent_core::coordination::agent_org_archive::debug_owned_session_ids_for_run( + &query_run_id, + )?; + let archive = agent_core::coordination::agent_org_archive::summary_for_run(&query_run_id)?; + Ok(serde_json::json!({ + "org_run_id": query_run_id, + "run_status": run.status.as_str(), + "activation_generation": run.activation_generation, + "session_ids": session_ids, + "archive": archive, + })) + }) + .await; + let durable = match durable { + Err(error) => { + return Json(serde_json::json!({ + "ok": false, + "error": format!("spawn_blocking join error: {error}") + })) + } + Ok(Err(error)) => return Json(serde_json::json!({ "ok": false, "error": error })), + Ok(Ok(value)) => value, + }; + let Some(handle) = crate::api::get_app_handle() else { + return Json(serde_json::json!({ "ok": false, "error": "AppHandle not initialized" })); + }; + let state = handle.state::(); + let session_ids = durable + .get("session_ids") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect::>(); + let runtime = collect_agent_org_runtime_evidence(&state, &session_ids).await; + Json(serde_json::json!({ + "ok": true, + "durable": durable, + "runtime": runtime, + })) +} + /// `POST /test/agent-org/pause/evidence` /// /// Read-only evidence for the rendered Pause/Resume scenario. The endpoint @@ -3211,39 +3364,18 @@ pub async fn test_agent_org_pause_evidence( let session_ids = durable .get("session_ids") .and_then(serde_json::Value::as_array) - .cloned() - .unwrap_or_default(); - let mut active_runtime_count = 0usize; - let mut active_turns = Vec::new(); - let mut background_shells = Vec::new(); - for session_id in session_ids.iter().filter_map(serde_json::Value::as_str) { - for (pid, command) in - agent_core::tools::impls::coding::exec::registry::list_shell_for_session(session_id) - { - background_shells.push(serde_json::json!({ - "session_id": session_id, - "pid": pid, - "command": command, - })); - } - let Some(session) = state.get_session(session_id).await else { - continue; - }; - if session.get_runtime().await.is_some() { - active_runtime_count += 1; - if let Some(dialog_turn_generation) = session.active_turn_id().await { - active_turns.push(serde_json::json!({ - "session_id": session_id, - "dialog_turn_generation": dialog_turn_generation, - })); - } - } - } + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect::>(); + let runtime = collect_agent_org_runtime_evidence(&state, &session_ids).await; Json(serde_json::json!({ "ok": true, "durable": durable, - "active_runtime_count": active_runtime_count, - "active_turns": active_turns, - "background_shells": background_shells, + "active_runtime_count": runtime["active_runtime_count"].clone(), + "active_turns": runtime["active_turns"].clone(), + "background_shells": runtime["background_shells"].clone(), + "runtime": runtime, })) } diff --git a/src-tauri/src/app/setup_hook/state.rs b/src-tauri/src/app/setup_hook/state.rs index 2cd28a5ad6..cb14b774c3 100644 --- a/src-tauri/src/app/setup_hook/state.rs +++ b/src-tauri/src/app/setup_hook/state.rs @@ -150,6 +150,7 @@ pub(crate) fn init_core_state(app: &tauri::App) { tracing::info!("[JobWake] Job completion wake hook installed"); let agent_org_startup_state = unified_state.clone(); + let agent_org_archive_reconcile_state = unified_state.clone(); let housekeeper_compaction_state = unified_state.clone(); app.manage(unified_state); tracing::info!("[UnifiedAgent] Unified agent state initialized"); @@ -157,6 +158,13 @@ pub(crate) fn init_core_state(app: &tauri::App) { agent_core::core::session::launch::spawn_agent_org_startup_recovery(agent_org_startup_state); tracing::info!("[AgentOrgStartup] one-shot lifecycle recovery scheduled"); + if agent_core::coordination::agent_org_runs::agent_org_redesign_enabled() { + agent_core::state::commands::session::org_tasks::reconcile_pending_archive_teardowns( + agent_org_archive_reconcile_state, + ); + tracing::info!("[AgentOrgArchive] one-shot teardown reconciliation scheduled"); + } + agent_core::session::housekeeper_compaction::spawn(housekeeper_compaction_state); tracing::info!("[HousekeeperCompaction] opt-in MiniCPM context worker initialized"); } diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 6d3012ac16..df6f24ccb1 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -812,6 +812,7 @@ agent_core::state::commands::session::org_tasks::agent_org_send_group_chat_messa agent_core::state::commands::session::org_tasks::agent_org_send_user_message_to_member, agent_core::state::commands::session::org_tasks::agent_org_pause_run, agent_core::state::commands::session::org_tasks::agent_org_resume_run, +agent_core::state::commands::session::org_tasks::agent_org_archive_run, agent_core::specialization::policies::policies_list, agent_core::specialization::policies::policies_read, agent_core::specialization::policies::policies_create, @@ -1044,6 +1045,7 @@ agent_core::state::commands::agent_load_messages, agent_core::state::commands::agent_get_session, agent_core::state::commands::agent_list_all_sessions, agent_core::state::commands::agent_delete_session, +agent_core::state::commands::agent_org_delete_team, agent_core::state::commands::agent_clear_messages, agent_core::state::commands::agent_truncate_after_message, agent_core::state::commands::agent_check_snapshot_changes, diff --git a/src/api/tauri/agent/orgTasks.ts b/src/api/tauri/agent/orgTasks.ts index bdcc03b82d..57b1422a3d 100644 --- a/src/api/tauri/agent/orgTasks.ts +++ b/src/api/tauri/agent/orgTasks.ts @@ -1,5 +1,7 @@ import { invokeTauri } from "@src/util/platform/tauri/init"; +import type { DeleteSessionReceipt } from "./types"; + export const AGENT_ORG_USER_SENDER_ID = "_user" as const; export const AGENT_ORG_TASK_STATUS = { @@ -135,6 +137,7 @@ export interface AgentOrgRunView { runStatus: AgentOrgRunStatus; runPhase: AgentOrgRunPhase; pauseHandoff?: AgentOrgPauseHandoffSummary | null; + archiveTeardown?: AgentOrgArchiveTeardownSummary | null; currentMemberId?: string | null; members: AgentOrgRunMemberView[]; tasks: AgentOrgTask[]; @@ -152,6 +155,32 @@ export interface AgentOrgPauseHandoffSummary { timedOutCount: number; } +export interface AgentOrgArchiveTeardownSummary { + receiptId: string; + status: "pending" | "quiesced" | "retained_runtime"; + attemptCount: number; + retainedRuntimeCount: number; + deadlineAt: string; +} + +export interface ArchiveRunOutcome { + requestId: string; + runId: string; + receiptId: string; + transitioned: boolean; + archiveGeneration: number; + archivedAt: string; + cancellations: { + tasks: number; + turns: number; + inboxDeliveries: number; + planApprovals: number; + interventions: number; + pauseContinuations: number; + }; + teardown: AgentOrgArchiveTeardownSummary; +} + export interface PauseRunOutcome { requestId: string; runId: string; @@ -571,3 +600,23 @@ export async function resumeAgentOrgRun( publishAgentOrgStateChange(sessionId); return outcome; } + +export async function archiveAgentOrgRun( + sessionId: string, + requestId: string = crypto.randomUUID() +): Promise { + const outcome = await invokeTauri( + "agent_org_archive_run", + { sessionId, requestId } + ); + publishAgentOrgStateChange(sessionId); + return outcome; +} + +export async function deleteAgentOrgTeam( + sessionId: string +): Promise { + return invokeTauri("agent_org_delete_team", { + sessionId, + }); +} diff --git a/src/engines/ChatPanel/AgentOrgArchivedComposer.tsx b/src/engines/ChatPanel/AgentOrgArchivedComposer.tsx new file mode 100644 index 0000000000..e55f412866 --- /dev/null +++ b/src/engines/ChatPanel/AgentOrgArchivedComposer.tsx @@ -0,0 +1,42 @@ +import React, { memo } from "react"; +import { useTranslation } from "react-i18next"; + +import { COMPOSER_BOTTOM_DOCK_PADDING_CLASS } from "@src/config/composerStackTokens"; +import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; + +import SessionReadOnlyBar from "./InputArea/components/SessionReadOnlyBar"; + +interface AgentOrgArchivedComposerProps { + composerRef: React.Ref; +} + +const AgentOrgArchivedComposer: React.FC = memo( + ({ composerRef }) => { + const { t } = useTranslation("sessions"); + return ( +
+
+
+ +
+
+ ); + } +); + +AgentOrgArchivedComposer.displayName = "AgentOrgArchivedComposer"; + +export default AgentOrgArchivedComposer; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.test.ts index d3ef946edc..8c2c767936 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; -import { resolveConversationHistoryPageIndex } from "../useChatNavigationController"; +import { + isAgentOrgOverviewInteractionTarget, + resolveConversationHistoryPageIndex, +} from "../useChatNavigationController"; + +afterEach(() => { + document.body.replaceChildren(); +}); const pages = [ { @@ -53,3 +61,24 @@ describe("resolveConversationHistoryPageIndex", () => { ).toBe(1); }); }); + +describe("isAgentOrgOverviewInteractionTarget", () => { + it("treats a portalled Overview modal and its text nodes as owned UI", () => { + const portal = document.createElement("div"); + portal.className = "agent-org-overview-owned-overlay"; + const label = document.createElement("label"); + label.textContent = "I understand this deletion is permanent."; + portal.appendChild(label); + document.body.appendChild(portal); + + expect(isAgentOrgOverviewInteractionTarget(label)).toBe(true); + expect(isAgentOrgOverviewInteractionTarget(label.firstChild)).toBe(true); + }); + + it("still treats unrelated page content as outside the Overview", () => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + + expect(isAgentOrgOverviewInteractionTarget(outside)).toBe(false); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts index 3d9aa39e7f..b15f8594ab 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts @@ -12,6 +12,22 @@ import type { UseChatHistoryStateReturn } from "./useChatHistoryState"; type ProjectionModel = ReturnType; type TurnPage = ProjectionModel["pages"][number]; +const AGENT_ORG_OVERVIEW_INTERACTION_SELECTOR = + "[data-agent-org-overview-panel], [data-agent-org-overview-trigger], .agent-org-overview-owned-overlay"; + +export function isAgentOrgOverviewInteractionTarget( + target: EventTarget | null +): boolean { + if (!(target instanceof Node)) return false; + const element = + target instanceof Element + ? target + : target.parentNode instanceof Element + ? target.parentNode + : null; + return Boolean(element?.closest(AGENT_ORG_OVERVIEW_INTERACTION_SELECTOR)); +} + export function resolveConversationHistoryPageIndex({ activeGroupIndex, currentPageIndex, @@ -82,21 +98,7 @@ export function useChatNavigationController({ useEffect(() => { if (!agentOrgOverviewOpen) return; const handlePointerDown = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Node)) return; - const element = - target instanceof Element - ? target - : target.parentNode instanceof Element - ? target.parentNode - : null; - if ( - element?.closest( - "[data-agent-org-overview-panel], [data-agent-org-overview-trigger]" - ) - ) { - return; - } + if (isAgentOrgOverviewInteractionTarget(event.target)) return; setAgentOrgOverviewOpen(false); }; diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index c9f10f1da8..19a77ce82f 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -64,6 +64,7 @@ import { isImportedHistorySession, } from "@src/util/session/sessionDispatch"; +import AgentOrgArchivedComposer from "./AgentOrgArchivedComposer"; import { ChatSessionContext } from "./ChatSessionContext"; import { ChatViewComposerSection } from "./ChatViewComposerSection"; import type { ChatViewComposerSectionProps } from "./ChatViewComposerSection.types"; @@ -611,7 +612,15 @@ const ChatView: React.FC = memo( /> } - composer={} + composer={ + showMainComposer && agentOrgRunView?.runStatus === "archived" ? ( + + ) : ( + + ) + } /> ); diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx index c303b1d6ad..adc3ede9ea 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx @@ -8,18 +8,28 @@ import { type AgentOrgRunView, type AgentOrgTaskPage, type AgentOrgTaskStatus, + archiveAgentOrgRun, + deleteAgentOrgTeam, getAgentOrgTaskPage, pauseAgentOrgRun, resumeAgentOrgRun, } from "@src/api/tauri/agent"; import Button from "@src/components/Button"; +import Checkbox from "@src/components/Checkbox"; +import Message from "@src/components/Message"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { removeForkRelayEntry } from "@src/features/TeamCollaboration/forkSession"; import { createLogger } from "@src/hooks/logger"; +import { useAppNavigation } from "@src/hooks/navigation/useAppNavigation"; import { useRefreshSpin } from "@src/hooks/ui"; import { + Alert01Icon, + ArchiveIcon, ArrowDown01Icon, ArrowRight01Icon, CancelCircleIcon, CheckmarkCircle01Icon, + Delete02Icon, HierarchyCircle01Icon, HugeiconsIcon, InboxIcon, @@ -29,7 +39,16 @@ import { UserCircleIcon, WorkHistoryIcon, } from "@src/icons"; -import { activeSessionIdAtom } from "@src/store/session"; +import Modal from "@src/scaffold/ModalSystem"; +import { applyRustSessionDeleteReceipt } from "@src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt"; +import { closeSessionChatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { activeSessionIdAtom, removeSession } from "@src/store/session"; +import { + clearPendingFileOpensForSession, + disposeWorkstationWorkspaceAtom, +} from "@src/store/workstation/tabs"; +import { clearPendingCodeEditorTabForSession } from "@src/store/workstation/tabs/pendingCodeEditorTab"; +import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; import AgentOrgPlanApprovalCard from "./AgentOrgPlanApprovalCard"; import { AgentOrgTaskList } from "./AgentOrgTaskList"; @@ -62,6 +81,10 @@ const AgentOrgOverviewPanel: React.FC = memo( const { t } = useTranslation("sessions"); const [expanded, setExpanded] = useState(true); const [isTogglingPause, setIsTogglingPause] = useState(false); + const [isArchiving, setIsArchiving] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [deleteConfirmed, setDeleteConfirmed] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const [historyExpanded, setHistoryExpanded] = useState(false); const [historyStatus, setHistoryStatus] = useState( AGENT_ORG_TASK_STATUS.COMPLETED @@ -95,19 +118,31 @@ const AgentOrgOverviewPanel: React.FC = memo( }, []); useEffect(() => { + const archived = view?.runStatus === "archived"; historyRequestIdRef.current += 1; - setHistoryExpanded(false); - setHistoryStatus(AGENT_ORG_TASK_STATUS.COMPLETED); + setHistoryExpanded(archived); + setHistoryStatus( + archived + ? AGENT_ORG_TASK_STATUS.CANCELLED + : AGENT_ORG_TASK_STATUS.COMPLETED + ); setHistoryPage(null); setHistoryLoading(false); setHistoryError(false); - }, [currentRunId, currentSessionId]); + setDeleteModalOpen(false); + setDeleteConfirmed(false); + }, [currentRunId, currentSessionId, view?.runStatus]); const handleRefresh = useCallback(() => onRefresh(), [onRefresh]); const { spinClass, handleClick: handleRefreshClick } = useRefreshSpin( handleRefresh, false ); const setActiveSessionId = useSetAtom(activeSessionIdAtom); + const { goToNewSession } = useAppNavigation(); + const disposeWorkstationWorkspace = useSetAtom( + disposeWorkstationWorkspaceAtom + ); + const closeSessionChatPanelTabs = useSetAtom(closeSessionChatPanelTabsAtom); const loadHistoryPage = useCallback( async ( @@ -185,6 +220,12 @@ const AgentOrgOverviewPanel: React.FC = memo( const isRunning = view?.runStatus === "running"; const isPaused = view?.runStatus === "paused"; + const isArchived = view?.runStatus === "archived"; + const canArchive = + view?.runStatus === "running" || + view?.runStatus === "paused" || + view?.runStatus === "idle" || + view?.runStatus === "failed"; const runPhaseLabel = view ? t(`planner.agentOrgOverview.phase.${view.runPhase}`, { defaultValue: view.runPhase.split("_").join(" "), @@ -246,6 +287,126 @@ const AgentOrgOverviewPanel: React.FC = memo( } }, [beginPauseToggle, currentSessionId, finishPauseToggle, onRefresh]); + const handleArchiveRun = useCallback(async () => { + if (!currentSessionId || !canArchive || isArchiving) return; + const confirmed = await confirmDestructiveAction({ + title: t("planner.agentOrgOverview.archiveTitle", { + defaultValue: "Archive this Team?", + }), + message: isRunning + ? t("planner.agentOrgOverview.archiveWorkingWarning", { + defaultValue: + "Archive is permanent. Tasks currently being executed will be cancelled, and the Team will become read-only.", + }) + : t("planner.agentOrgOverview.archiveWarning", { + defaultValue: + "Archive is permanent. The Team will become read-only and cannot be resumed.", + }), + okLabel: t("planner.agentOrgOverview.archiveRun", { + defaultValue: "Archive", + }), + cancelLabel: t("common:actions.cancel"), + }); + if (!confirmed) return; + setIsArchiving(true); + try { + await archiveAgentOrgRun(currentSessionId); + await onRefresh(); + } catch (archiveError) { + logger.error("Failed to Archive Agent Team:", archiveError); + Message.error( + t("planner.agentOrgOverview.archiveFailed", { + defaultValue: "Failed to Archive Team", + }) + ); + } finally { + setIsArchiving(false); + } + }, [canArchive, currentSessionId, isArchiving, isRunning, onRefresh, t]); + + const closeDeleteModal = useCallback(() => { + if (isDeleting) return; + setDeleteModalOpen(false); + setDeleteConfirmed(false); + }, [isDeleting]); + + const handleDeleteTeam = useCallback(async () => { + if (!currentSessionId || !isArchived || !deleteConfirmed || isDeleting) + return; + setIsDeleting(true); + try { + const receipt = await deleteAgentOrgTeam(currentSessionId); + const cleanup = { + removeSession, + removeForkRelayEntry, + disposeWorkstationWorkspace, + clearPendingFileOpens: clearPendingFileOpensForSession, + clearPendingCodeEditorTab: clearPendingCodeEditorTabForSession, + evictEventStore: (deletedSessionId: string) => + eventStoreProxy.evictSession(deletedSessionId), + }; + const requiresNavigationReset = await applyRustSessionDeleteReceipt({ + requestedSessionId: currentSessionId, + activeSessionId: currentSessionId, + isAgentOrgRoot: view?.context.rootSessionId === currentSessionId, + receipt, + cleanup: { + ...cleanup, + closeSessionTabs: closeSessionChatPanelTabs, + }, + }); + cleanup.removeSession(currentSessionId); + cleanup.removeForkRelayEntry(currentSessionId); + cleanup.disposeWorkstationWorkspace(currentSessionId); + cleanup.clearPendingFileOpens(currentSessionId); + cleanup.clearPendingCodeEditorTab(currentSessionId); + setDeleteModalOpen(false); + // Closing the active Team tab already activates one safe neighbour (or + // Launchpad). Only reset navigation when the deleted session had no + // Chat Panel tab to own that transition, such as a WorkStation-only + // presentation. + if (requiresNavigationReset) goToNewSession(); + } catch (deleteError) { + logger.error("Failed to delete Archived Agent Team:", deleteError); + Message.error( + t("planner.agentOrgOverview.deleteFailed", { + defaultValue: "Failed to delete Team", + }) + ); + } finally { + setIsDeleting(false); + } + }, [ + currentSessionId, + closeSessionChatPanelTabs, + deleteConfirmed, + disposeWorkstationWorkspace, + goToNewSession, + isArchived, + isDeleting, + t, + view?.context.rootSessionId, + ]); + + useEffect(() => { + if ( + isArchived && + historyExpanded && + historyPage === null && + !historyLoading && + !historyError + ) { + void loadHistoryPage(AGENT_ORG_TASK_STATUS.CANCELLED); + } + }, [ + historyExpanded, + historyError, + historyLoading, + historyPage, + isArchived, + loadHistoryPage, + ]); + if (!view && !error) return null; const completedTasks = view?.taskOverview.completed ?? 0; @@ -394,6 +555,31 @@ const AgentOrgOverviewPanel: React.FC = memo( } /> )} + {canArchive && ( +
)} + + {isArchived && view.archiveTeardown && ( +
+ {view.archiveTeardown.status === "pending" + ? t("planner.agentOrgOverview.archiveTeardownPending", { + defaultValue: + "Archived. Runtime shutdown is still finishing in the background.", + }) + : view.archiveTeardown.status === "retained_runtime" + ? t("planner.agentOrgOverview.archiveTeardownRetained", { + count: view.archiveTeardown.retainedRuntimeCount, + defaultValue: + "Archived, but {{count}} runtime could not be released. Delete remains blocked.", + }) + : t("planner.agentOrgOverview.archiveTeardownQuiesced", { + defaultValue: + "Archived and fully stopped. Permanent deletion is now available.", + })} +
+ )} + + {isArchived && ( +
+
+ + {t("planner.agentOrgOverview.dangerZone", { + defaultValue: "Danger Zone", + })} +
+
+ {t("planner.agentOrgOverview.deleteDescription", { + defaultValue: + "Permanently delete this Team and all of its sessions and history.", + })} +
+ +
+ )} )} + + + + + + } + > +
+ {t("planner.agentOrgOverview.deleteWarning", { + defaultValue: + "This permanently deletes every Team session and its history. This action cannot be undone.", + })} +
+ + {t("planner.agentOrgOverview.deleteAcknowledge", { + defaultValue: "I understand this deletion is permanent.", + })} + +
); } diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts index a086fcd3ed..bdbd4b554c 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts @@ -28,14 +28,50 @@ const mocks = vi.hoisted(() => ({ getAnnotations: vi.fn(), pause: vi.fn(), resume: vi.fn(), + archive: vi.fn(), + deleteTeam: vi.fn(), + confirmDestructive: vi.fn(), + applyDeleteReceipt: vi.fn(), + evictSession: vi.fn(), + goToNewSession: vi.fn(), + removeSession: vi.fn(), })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock("jotai", () => ({ useSetAtom: () => vi.fn() })); -vi.mock("@src/store/session", () => ({ activeSessionIdAtom: {} })); +vi.mock("jotai", async (importOriginal) => ({ + ...(await importOriginal()), + useSetAtom: () => vi.fn(), +})); +vi.mock("@src/store/session", () => ({ + activeSessionIdAtom: {}, + removeSession: mocks.removeSession, +})); +vi.mock("@src/hooks/navigation/useAppNavigation", () => ({ + useAppNavigation: () => ({ goToNewSession: mocks.goToNewSession }), +})); +vi.mock("@src/store/workstation/tabs", () => ({ + clearPendingFileOpensForSession: vi.fn(), + disposeWorkstationWorkspaceAtom: {}, +})); +vi.mock("@src/store/workstation/tabs/pendingCodeEditorTab", () => ({ + clearPendingCodeEditorTabForSession: vi.fn(), +})); +vi.mock("@src/features/TeamCollaboration/forkSession", () => ({ + removeForkRelayEntry: vi.fn(), +})); +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { evictSession: mocks.evictSession }, +})); +vi.mock( + "@src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt", + () => ({ applyRustSessionDeleteReceipt: mocks.applyDeleteReceipt }) +); +vi.mock("@src/util/dialogs/confirmDestructiveAction", () => ({ + confirmDestructiveAction: mocks.confirmDestructive, +})); vi.mock("@src/hooks/logger", () => ({ createLogger: () => ({ error: vi.fn() }), })); @@ -68,6 +104,51 @@ vi.mock("@src/api/tauri/agent", () => ({ getAgentOrgTaskAnnotationPage: mocks.getAnnotations, pauseAgentOrgRun: mocks.pause, resumeAgentOrgRun: mocks.resume, + archiveAgentOrgRun: mocks.archive, + deleteAgentOrgTeam: mocks.deleteTeam, +})); + +vi.mock("@src/components/Checkbox", () => ({ + default: ({ + checked, + disabled, + onCheckedChange, + children, + }: { + checked?: boolean; + disabled?: boolean; + onCheckedChange?: (checked: boolean) => void; + children?: React.ReactNode; + }) => + createElement( + "label", + null, + createElement("input", { + type: "checkbox", + checked, + disabled, + onChange: (event: React.ChangeEvent) => + onCheckedChange?.(event.target.checked), + }), + children + ), +})); + +vi.mock("@src/scaffold/ModalSystem", () => ({ + default: ({ + visible, + children, + footer, + }: { + visible: boolean; + children?: React.ReactNode; + footer?: React.ReactNode; + }) => + visible ? createElement("div", { role: "dialog" }, children, footer) : null, +})); + +vi.mock("@src/components/Message", () => ({ + default: { error: vi.fn() }, })); vi.mock("@src/components/Button", () => ({ @@ -193,6 +274,14 @@ describe("Agent Org Task panel", () => { mocks.getAnnotations.mockReset(); mocks.pause.mockReset(); mocks.resume.mockReset(); + mocks.archive.mockReset(); + mocks.deleteTeam.mockReset(); + mocks.confirmDestructive.mockReset(); + mocks.applyDeleteReceipt.mockReset(); + mocks.evictSession.mockReset(); + mocks.goToNewSession.mockReset(); + mocks.removeSession.mockReset(); + mocks.applyDeleteReceipt.mockResolvedValue(true); }); afterEach(() => { @@ -390,6 +479,231 @@ describe("Agent Org Task panel", () => { } }); + it("warns that Working tasks are cancelled before Archive", async () => { + mocks.confirmDestructive.mockResolvedValue(true); + mocks.archive.mockResolvedValue({ + requestId: "archive-request", + runId: "run-task-panel", + receiptId: "archive-receipt", + transitioned: true, + archiveGeneration: 2, + archivedAt: "2026-08-23T00:00:00Z", + cancellations: { + tasks: 1, + turns: 1, + inboxDeliveries: 0, + planApprovals: 0, + interventions: 0, + pauseContinuations: 0, + }, + teardown: { + receiptId: "archive-receipt", + status: "pending", + attemptCount: 0, + retainedRuntimeCount: 0, + deadlineAt: "2026-08-23T00:01:00Z", + }, + }); + const onRefresh = vi.fn().mockResolvedValue(undefined); + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view: runView(), + error: null, + currentSessionId: "root-session", + onRefresh, + }) + ); + }); + await act(async () => { + container + .querySelector( + '[data-testid="agent-org-overview-archive-button"]' + ) + ?.click(); + }); + expect(mocks.confirmDestructive).toHaveBeenCalledWith( + expect.objectContaining({ + message: "planner.agentOrgOverview.archiveWorkingWarning", + }) + ); + expect(mocks.archive).toHaveBeenCalledWith("root-session"); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("shows Archive only for Idle, Working, Paused, and Failed Teams", async () => { + mocks.getPage.mockResolvedValue({ + bucket: "history", + status: "cancelled", + tasks: [], + hasMore: false, + }); + for (const status of ["idle", "running", "paused", "failed"] as const) { + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view: { ...runView(), runStatus: status }, + error: null, + currentSessionId: "root-session", + onRefresh: vi.fn().mockResolvedValue(undefined), + }) + ); + }); + expect( + container.querySelector( + '[data-testid="agent-org-overview-archive-button"]' + ) + ).not.toBeNull(); + } + for (const status of ["starting", "archived"] as const) { + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view: { + ...runView(), + runStatus: status, + runPhase: status, + archiveTeardown: + status === "archived" + ? { + receiptId: "archive-receipt", + status: "retained_runtime", + attemptCount: 3, + retainedRuntimeCount: 1, + deadlineAt: "2026-08-23T00:01:00Z", + } + : undefined, + }, + error: null, + currentSessionId: "root-session", + onRefresh: vi.fn().mockResolvedValue(undefined), + }) + ); + }); + expect( + container.querySelector( + '[data-testid="agent-org-overview-archive-button"]' + ) + ).toBeNull(); + } + }); + + it("keeps Team Delete blocked when Archive retained a runtime", async () => { + mocks.getPage.mockResolvedValue({ + bucket: "history", + status: "cancelled", + tasks: [], + hasMore: false, + }); + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view: { + ...runView(), + runStatus: "archived", + runPhase: "archived", + archiveTeardown: { + receiptId: "archive-receipt", + status: "retained_runtime", + attemptCount: 3, + retainedRuntimeCount: 2, + deadlineAt: "2026-08-23T00:01:00Z", + }, + }, + error: null, + currentSessionId: "root-session", + onRefresh: vi.fn().mockResolvedValue(undefined), + }) + ); + }); + expect( + container + .querySelector('[data-testid="agent-org-archive-teardown-status"]') + ?.getAttribute("data-teardown-status") + ).toBe("retained_runtime"); + expect( + container.querySelector( + '[data-testid="agent-org-overview-delete-button"]' + )?.disabled + ).toBe(true); + }); + + it("opens Archived history by default and requires checkbox confirmation for Team Delete", async () => { + mocks.getPage.mockResolvedValue({ + bucket: "history", + status: "cancelled", + tasks: [task("archived-cancelled", "cancelled")], + hasMore: false, + }); + mocks.deleteTeam.mockResolvedValue({ + deletedSessionIds: ["member-session", "root-session"], + }); + mocks.applyDeleteReceipt.mockResolvedValue(false); + const view: AgentOrgRunView = { + ...runView(), + runStatus: "archived", + runPhase: "archived", + archiveTeardown: { + receiptId: "archive-receipt", + status: "quiesced", + attemptCount: 1, + retainedRuntimeCount: 0, + deadlineAt: "2026-08-23T00:01:00Z", + }, + }; + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view, + error: null, + currentSessionId: "root-session", + onRefresh: vi.fn().mockResolvedValue(undefined), + }) + ); + }); + expect(mocks.getPage).toHaveBeenCalledWith( + expect.objectContaining({ status: "cancelled" }) + ); + expect( + container.querySelector( + '[data-testid="agent-org-overview-resume-button"]' + ) + ).toBeNull(); + expect( + container.querySelector( + '[data-testid="agent-org-overview-archive-button"]' + ) + ).toBeNull(); + const openDelete = container.querySelector( + '[data-testid="agent-org-overview-delete-button"]' + ); + expect(openDelete?.disabled).toBe(false); + await act(async () => openDelete?.click()); + const confirmDelete = document.querySelector( + '[data-testid="agent-org-delete-confirm-button"]' + ); + expect(confirmDelete?.disabled).toBe(true); + await act(async () => { + document + .querySelector( + 'div[role="dialog"] input[type="checkbox"]' + ) + ?.click(); + }); + expect(confirmDelete?.disabled).toBe(false); + await act(async () => confirmDelete?.click()); + expect(mocks.deleteTeam).toHaveBeenCalledWith("root-session"); + expect(mocks.applyDeleteReceipt).toHaveBeenCalledWith( + expect.objectContaining({ + requestedSessionId: "root-session", + receipt: { + deletedSessionIds: ["member-session", "root-session"], + }, + }) + ); + expect(mocks.goToNewSession).not.toHaveBeenCalled(); + }); + it("discards a late History response after switching teams", async () => { const oldPage = deferred<{ bucket: "history"; diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index b527cbf461..e9ad09791e 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -2455,6 +2455,22 @@ "memberSessions": "Mitgliedersitzungen", "pauseRun": "Ausführung pausieren", "resumeRun": "Ausführung fortsetzen", + "archiveRun": "Team archivieren", + "archiveTitle": "Dieses Team archivieren?", + "archiveWorkingWarning": "Die Archivierung ist endgültig. Laufende Aufgaben werden abgebrochen und das Team wird schreibgeschützt.", + "archiveWarning": "Die Archivierung ist endgültig. Das Team wird schreibgeschützt und kann nicht fortgesetzt werden.", + "archivedReadOnly": "Archiviert – Verlauf ist schreibgeschützt", + "archiveFailed": "Team konnte nicht archiviert werden", + "archiveTeardownPending": "Archiviert. Laufzeiten werden im Hintergrund noch beendet.", + "archiveTeardownRetained": "Archiviert, aber {{count}} Laufzeit konnte nicht freigegeben werden. Löschen bleibt gesperrt.", + "archiveTeardownQuiesced": "Archiviert und vollständig beendet. Dauerhaftes Löschen ist jetzt möglich.", + "dangerZone": "Gefahrenbereich", + "deleteDescription": "Dieses Team mit allen Sitzungen und Verläufen dauerhaft löschen.", + "deleteTeam": "Team löschen", + "deleteTitle": "Dieses Team dauerhaft löschen?", + "deleteWarning": "Alle Teamsitzungen und Verläufe werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", + "deleteAcknowledge": "Ich verstehe, dass diese Löschung dauerhaft ist.", + "deleteFailed": "Team konnte nicht gelöscht werden", "viewCoordinatorHistory": "Coordinator-Chatverlauf anzeigen", "phase": { "starting": "Wird gestartet", diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 999312be45..e282f1eae1 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -2562,6 +2562,22 @@ "memberSessions": "Member sessions", "pauseRun": "Pause run", "resumeRun": "Resume run", + "archiveRun": "Archive Team", + "archiveTitle": "Archive this Team?", + "archiveWorkingWarning": "Archive is permanent. Tasks currently being executed will be cancelled, and the Team will become read-only.", + "archiveWarning": "Archive is permanent. The Team will become read-only and cannot be resumed.", + "archivedReadOnly": "Archived — history is read-only", + "archiveFailed": "Failed to Archive Team", + "archiveTeardownPending": "Archived. Runtime shutdown is still finishing in the background.", + "archiveTeardownRetained": "Archived, but {{count}} runtime could not be released. Delete remains blocked.", + "archiveTeardownQuiesced": "Archived and fully stopped. Permanent deletion is now available.", + "dangerZone": "Danger Zone", + "deleteDescription": "Permanently delete this Team and all of its sessions and history.", + "deleteTeam": "Delete Team", + "deleteTitle": "Permanently delete this Team?", + "deleteWarning": "This permanently deletes every Team session and its history. This action cannot be undone.", + "deleteAcknowledge": "I understand this deletion is permanent.", + "deleteFailed": "Failed to delete Team", "viewCoordinatorHistory": "View coordinator chat history", "planApproval": { "title": "Plan ready for approval", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index bc1cdc7146..1df84fc9d8 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -2457,6 +2457,22 @@ "memberSessions": "Sesiones de miembros", "pauseRun": "Pausar ejecución", "resumeRun": "Reanudar ejecución", + "archiveRun": "Archivar equipo", + "archiveTitle": "¿Archivar este equipo?", + "archiveWorkingWarning": "Archivar es permanente. Las tareas en ejecución se cancelarán y el equipo quedará en modo de solo lectura.", + "archiveWarning": "Archivar es permanente. El equipo quedará en modo de solo lectura y no podrá reanudarse.", + "archivedReadOnly": "Archivado: el historial es de solo lectura", + "archiveFailed": "No se pudo archivar el equipo", + "archiveTeardownPending": "Archivado. Los procesos aún se están cerrando en segundo plano.", + "archiveTeardownRetained": "Archivado, pero no se pudo liberar {{count}} proceso. La eliminación sigue bloqueada.", + "archiveTeardownQuiesced": "Archivado y detenido por completo. Ya se puede eliminar permanentemente.", + "dangerZone": "Zona de peligro", + "deleteDescription": "Eliminar permanentemente este equipo, sus sesiones y su historial.", + "deleteTeam": "Eliminar equipo", + "deleteTitle": "¿Eliminar permanentemente este equipo?", + "deleteWarning": "Esto elimina permanentemente todas las sesiones y el historial del equipo. No se puede deshacer.", + "deleteAcknowledge": "Entiendo que esta eliminación es permanente.", + "deleteFailed": "No se pudo eliminar el equipo", "viewCoordinatorHistory": "Ver historial del chat del coordinador", "phase": { "starting": "Iniciando", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index e0a12ed49e..968763f4b1 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -2457,6 +2457,22 @@ "memberSessions": "Sessions des membres", "pauseRun": "Mettre en pause", "resumeRun": "Reprendre l'exécution", + "archiveRun": "Archiver l’équipe", + "archiveTitle": "Archiver cette équipe ?", + "archiveWorkingWarning": "L’archivage est irréversible. Les tâches en cours seront annulées et l’équipe passera en lecture seule.", + "archiveWarning": "L’archivage est irréversible. L’équipe passera en lecture seule et ne pourra pas être reprise.", + "archivedReadOnly": "Archivée — historique en lecture seule", + "archiveFailed": "Échec de l’archivage de l’équipe", + "archiveTeardownPending": "Archivée. L’arrêt des processus se termine en arrière-plan.", + "archiveTeardownRetained": "Archivée, mais {{count}} processus n’a pas pu être libéré. La suppression reste bloquée.", + "archiveTeardownQuiesced": "Archivée et complètement arrêtée. La suppression définitive est maintenant disponible.", + "dangerZone": "Zone dangereuse", + "deleteDescription": "Supprimer définitivement cette équipe, toutes ses sessions et son historique.", + "deleteTeam": "Supprimer l’équipe", + "deleteTitle": "Supprimer définitivement cette équipe ?", + "deleteWarning": "Toutes les sessions et l’historique de l’équipe seront définitivement supprimés. Cette action est irréversible.", + "deleteAcknowledge": "Je comprends que cette suppression est définitive.", + "deleteFailed": "Échec de la suppression de l’équipe", "viewCoordinatorHistory": "Voir l’historique du coordinateur", "phase": { "starting": "Démarrage", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index 8556c4a980..4c56a07fe6 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -2456,6 +2456,22 @@ "memberSessions": "メンバーセッション", "pauseRun": "実行を一時停止", "resumeRun": "実行を再開", + "archiveRun": "チームをアーカイブ", + "archiveTitle": "このチームをアーカイブしますか?", + "archiveWorkingWarning": "アーカイブは元に戻せません。実行中のタスクはキャンセルされ、チームは読み取り専用になります。", + "archiveWarning": "アーカイブは元に戻せません。チームは読み取り専用になり、再開できません。", + "archivedReadOnly": "アーカイブ済み — 履歴は読み取り専用です", + "archiveFailed": "チームをアーカイブできませんでした", + "archiveTeardownPending": "アーカイブ済みです。バックグラウンドでランタイムを終了しています。", + "archiveTeardownRetained": "アーカイブ済みですが、{{count}} 個のランタイムを解放できませんでした。削除は引き続きブロックされます。", + "archiveTeardownQuiesced": "アーカイブ済みで完全に停止しました。完全削除が可能です。", + "dangerZone": "危険な操作", + "deleteDescription": "このチームとすべてのセッションおよび履歴を完全に削除します。", + "deleteTeam": "チームを削除", + "deleteTitle": "このチームを完全に削除しますか?", + "deleteWarning": "チームのすべてのセッションと履歴が完全に削除されます。元に戻せません。", + "deleteAcknowledge": "この削除が永続的であることを理解しました。", + "deleteFailed": "チームを削除できませんでした", "viewCoordinatorHistory": "Coordinator のチャット履歴を表示", "phase": { "starting": "開始中", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index ce98f8bbbf..f61e482d71 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -2457,6 +2457,22 @@ "memberSessions": "Member 세션", "pauseRun": "실행 일시 중지", "resumeRun": "실행 재개", + "archiveRun": "팀 보관", + "archiveTitle": "이 팀을 보관할까요?", + "archiveWorkingWarning": "보관은 되돌릴 수 없습니다. 실행 중인 작업이 취소되고 팀은 읽기 전용이 됩니다.", + "archiveWarning": "보관은 되돌릴 수 없습니다. 팀은 읽기 전용이 되며 다시 시작할 수 없습니다.", + "archivedReadOnly": "보관됨 — 기록은 읽기 전용입니다", + "archiveFailed": "팀을 보관하지 못했습니다", + "archiveTeardownPending": "보관되었습니다. 백그라운드에서 런타임을 종료하고 있습니다.", + "archiveTeardownRetained": "보관되었지만 런타임 {{count}}개를 해제하지 못했습니다. 삭제는 계속 차단됩니다.", + "archiveTeardownQuiesced": "보관 및 완전 종료되었습니다. 이제 영구 삭제할 수 있습니다.", + "dangerZone": "위험 영역", + "deleteDescription": "이 팀과 모든 세션 및 기록을 영구 삭제합니다.", + "deleteTeam": "팀 삭제", + "deleteTitle": "이 팀을 영구 삭제할까요?", + "deleteWarning": "팀의 모든 세션과 기록이 영구 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "deleteAcknowledge": "이 삭제가 영구적임을 이해합니다.", + "deleteFailed": "팀을 삭제하지 못했습니다", "viewCoordinatorHistory": "Coordinator 채팅 기록 보기", "phase": { "starting": "시작 중", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index f8222e3afd..52201a9281 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -2519,6 +2519,22 @@ "memberSessions": "Sesje członków", "pauseRun": "Wstrzymaj wykonanie", "resumeRun": "Wznów wykonanie", + "archiveRun": "Archiwizuj zespół", + "archiveTitle": "Zarchiwizować ten zespół?", + "archiveWorkingWarning": "Archiwizacja jest nieodwracalna. Trwające zadania zostaną anulowane, a zespół będzie tylko do odczytu.", + "archiveWarning": "Archiwizacja jest nieodwracalna. Zespół będzie tylko do odczytu i nie będzie można go wznowić.", + "archivedReadOnly": "Zarchiwizowano — historia tylko do odczytu", + "archiveFailed": "Nie udało się zarchiwizować zespołu", + "archiveTeardownPending": "Zarchiwizowano. Środowiska są nadal zatrzymywane w tle.", + "archiveTeardownRetained": "Zarchiwizowano, ale nie zwolniono {{count}} środowiska. Usuwanie pozostaje zablokowane.", + "archiveTeardownQuiesced": "Zarchiwizowano i całkowicie zatrzymano. Można już trwale usunąć.", + "dangerZone": "Strefa niebezpieczna", + "deleteDescription": "Trwale usuń ten zespół, wszystkie sesje i historię.", + "deleteTeam": "Usuń zespół", + "deleteTitle": "Trwale usunąć ten zespół?", + "deleteWarning": "Wszystkie sesje i historia zespołu zostaną trwale usunięte. Tej operacji nie można cofnąć.", + "deleteAcknowledge": "Rozumiem, że to usunięcie jest trwałe.", + "deleteFailed": "Nie udało się usunąć zespołu", "viewCoordinatorHistory": "Pokaż historię czatu koordynatora", "phase": { "starting": "Uruchamianie", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index b07175e2aa..12b54d7e31 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -2481,6 +2481,22 @@ "memberSessions": "Sessões de membros", "pauseRun": "Pausar execução", "resumeRun": "Retomar execução", + "archiveRun": "Arquivar equipe", + "archiveTitle": "Arquivar esta equipe?", + "archiveWorkingWarning": "O arquivamento é permanente. As tarefas em execução serão canceladas e a equipe ficará somente leitura.", + "archiveWarning": "O arquivamento é permanente. A equipe ficará somente leitura e não poderá ser retomada.", + "archivedReadOnly": "Arquivada — histórico somente leitura", + "archiveFailed": "Falha ao arquivar a equipe", + "archiveTeardownPending": "Arquivada. Os processos ainda estão sendo encerrados em segundo plano.", + "archiveTeardownRetained": "Arquivada, mas {{count}} processo não pôde ser liberado. A exclusão continua bloqueada.", + "archiveTeardownQuiesced": "Arquivada e totalmente encerrada. A exclusão permanente já está disponível.", + "dangerZone": "Zona de perigo", + "deleteDescription": "Excluir permanentemente esta equipe, todas as sessões e o histórico.", + "deleteTeam": "Excluir equipe", + "deleteTitle": "Excluir esta equipe permanentemente?", + "deleteWarning": "Isso exclui permanentemente todas as sessões e o histórico da equipe. A ação não pode ser desfeita.", + "deleteAcknowledge": "Entendo que esta exclusão é permanente.", + "deleteFailed": "Falha ao excluir a equipe", "viewCoordinatorHistory": "Ver histórico do chat do coordenador", "phase": { "starting": "Iniciando", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index ca3bd8eaaf..31368daf57 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -2501,6 +2501,22 @@ "memberSessions": "Сессии участников", "pauseRun": "Приостановить выполнение", "resumeRun": "Возобновить выполнение", + "archiveRun": "Архивировать команду", + "archiveTitle": "Архивировать эту команду?", + "archiveWorkingWarning": "Архивирование необратимо. Выполняемые задачи будут отменены, а команда перейдет в режим только для чтения.", + "archiveWarning": "Архивирование необратимо. Команда перейдет в режим только для чтения, возобновить ее будет нельзя.", + "archivedReadOnly": "В архиве — история только для чтения", + "archiveFailed": "Не удалось архивировать команду", + "archiveTeardownPending": "Команда архивирована. Среды выполнения еще завершаются в фоне.", + "archiveTeardownRetained": "Команда архивирована, но не удалось освободить {{count}} среду. Удаление по-прежнему заблокировано.", + "archiveTeardownQuiesced": "Команда архивирована и полностью остановлена. Теперь доступно окончательное удаление.", + "dangerZone": "Опасная зона", + "deleteDescription": "Окончательно удалить эту команду, все ее сеансы и историю.", + "deleteTeam": "Удалить команду", + "deleteTitle": "Окончательно удалить эту команду?", + "deleteWarning": "Все сеансы и история команды будут удалены навсегда. Это действие нельзя отменить.", + "deleteAcknowledge": "Я понимаю, что удаление необратимо.", + "deleteFailed": "Не удалось удалить команду", "viewCoordinatorHistory": "Показать историю чата координатора", "phase": { "starting": "Запуск", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index d57039bc10..6c8d1f768b 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -2458,6 +2458,22 @@ "memberSessions": "Üye oturumları", "pauseRun": "Çalıştırmayı duraklat", "resumeRun": "Çalıştırmayı devam ettir", + "archiveRun": "Ekibi arşivle", + "archiveTitle": "Bu ekip arşivlensin mi?", + "archiveWorkingWarning": "Arşivleme kalıcıdır. Çalışan görevler iptal edilir ve ekip salt okunur olur.", + "archiveWarning": "Arşivleme kalıcıdır. Ekip salt okunur olur ve yeniden başlatılamaz.", + "archivedReadOnly": "Arşivlendi — geçmiş salt okunur", + "archiveFailed": "Ekip arşivlenemedi", + "archiveTeardownPending": "Arşivlendi. Çalışma ortamları arka planda durduruluyor.", + "archiveTeardownRetained": "Arşivlendi ancak {{count}} çalışma ortamı serbest bırakılamadı. Silme engelli kalır.", + "archiveTeardownQuiesced": "Arşivlendi ve tamamen durduruldu. Kalıcı silme artık kullanılabilir.", + "dangerZone": "Tehlikeli Bölge", + "deleteDescription": "Bu ekibi, tüm oturumlarını ve geçmişini kalıcı olarak sil.", + "deleteTeam": "Ekibi sil", + "deleteTitle": "Bu ekip kalıcı olarak silinsin mi?", + "deleteWarning": "Ekibin tüm oturumları ve geçmişi kalıcı olarak silinir. Bu işlem geri alınamaz.", + "deleteAcknowledge": "Bu silme işleminin kalıcı olduğunu anlıyorum.", + "deleteFailed": "Ekip silinemedi", "viewCoordinatorHistory": "Koordinatör sohbet geçmişini görüntüle", "phase": { "starting": "Başlatılıyor", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index 2070617d9a..0ac596bd62 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -2454,6 +2454,22 @@ "memberSessions": "Phiên thành viên", "pauseRun": "Tạm dừng thực thi", "resumeRun": "Tiếp tục thực thi", + "archiveRun": "Lưu trữ nhóm", + "archiveTitle": "Lưu trữ nhóm này?", + "archiveWorkingWarning": "Lưu trữ là vĩnh viễn. Các tác vụ đang chạy sẽ bị hủy và nhóm sẽ chuyển sang chỉ đọc.", + "archiveWarning": "Lưu trữ là vĩnh viễn. Nhóm sẽ chuyển sang chỉ đọc và không thể tiếp tục.", + "archivedReadOnly": "Đã lưu trữ — lịch sử chỉ đọc", + "archiveFailed": "Không thể lưu trữ nhóm", + "archiveTeardownPending": "Đã lưu trữ. Môi trường chạy vẫn đang được dừng trong nền.", + "archiveTeardownRetained": "Đã lưu trữ nhưng không thể giải phóng {{count}} môi trường chạy. Việc xóa vẫn bị chặn.", + "archiveTeardownQuiesced": "Đã lưu trữ và dừng hoàn toàn. Giờ có thể xóa vĩnh viễn.", + "dangerZone": "Vùng nguy hiểm", + "deleteDescription": "Xóa vĩnh viễn nhóm này cùng mọi phiên và lịch sử.", + "deleteTeam": "Xóa nhóm", + "deleteTitle": "Xóa vĩnh viễn nhóm này?", + "deleteWarning": "Thao tác này xóa vĩnh viễn mọi phiên và lịch sử của nhóm. Không thể hoàn tác.", + "deleteAcknowledge": "Tôi hiểu việc xóa này là vĩnh viễn.", + "deleteFailed": "Không thể xóa nhóm", "viewCoordinatorHistory": "Xem lịch sử trò chuyện của điều phối viên", "phase": { "starting": "Đang bắt đầu", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index d5488f69fa..47dfc60e08 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -2472,6 +2472,22 @@ "memberSessions": "成員會話", "pauseRun": "暫停執行", "resumeRun": "恢復執行", + "archiveRun": "封存團隊", + "archiveTitle": "封存這個團隊?", + "archiveWorkingWarning": "封存無法復原。正在執行的任務會被取消,團隊之後將變為唯讀。", + "archiveWarning": "封存無法復原。團隊將變為唯讀,且無法恢復執行。", + "archivedReadOnly": "已封存——歷史記錄為唯讀", + "archiveFailed": "團隊封存失敗", + "archiveTeardownPending": "已封存,背景仍在關閉執行環境。", + "archiveTeardownRetained": "已封存,但有 {{count}} 個執行環境未能釋放,仍無法刪除。", + "archiveTeardownQuiesced": "已封存並完全停止,現在可以永久刪除。", + "dangerZone": "危險區域", + "deleteDescription": "永久刪除這個團隊及其所有工作階段與歷史記錄。", + "deleteTeam": "刪除團隊", + "deleteTitle": "永久刪除這個團隊?", + "deleteWarning": "這會永久刪除團隊的所有工作階段與歷史記錄,且無法復原。", + "deleteAcknowledge": "我明白此次刪除是永久的。", + "deleteFailed": "團隊刪除失敗", "viewCoordinatorHistory": "查看 Coordinator 對話記錄", "phase": { "starting": "正在啟動", diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index c6f6e2fa29..429c026309 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -2527,6 +2527,22 @@ "memberSessions": "成员会话", "pauseRun": "暂停运行", "resumeRun": "恢复运行", + "archiveRun": "归档团队", + "archiveTitle": "归档这个团队?", + "archiveWorkingWarning": "归档不可撤销。当前正在执行的任务会被取消,团队随后将变为只读。", + "archiveWarning": "归档不可撤销。团队将变为只读,且无法恢复运行。", + "archivedReadOnly": "已归档——历史记录为只读", + "archiveFailed": "团队归档失败", + "archiveTeardownPending": "已归档,后台仍在关闭运行时。", + "archiveTeardownRetained": "已归档,但有 {{count}} 个运行时未能释放,仍无法删除。", + "archiveTeardownQuiesced": "已归档并完全停止,现在可以永久删除。", + "dangerZone": "危险区域", + "deleteDescription": "永久删除这个团队及其所有会话和历史记录。", + "deleteTeam": "删除团队", + "deleteTitle": "永久删除这个团队?", + "deleteWarning": "这会永久删除团队的所有会话和历史记录,且无法撤销。", + "deleteAcknowledge": "我明白此次删除是永久的。", + "deleteFailed": "团队删除失败", "viewCoordinatorHistory": "查看 Coordinator 对话历史", "planApproval": { "title": "计划等待审批", diff --git a/src/scaffold/NavigationSidebar/connectors/__tests__/rustSessionDeleteReceipt.test.ts b/src/scaffold/NavigationSidebar/connectors/__tests__/rustSessionDeleteReceipt.test.ts index b04dc3b1fe..b940bede79 100644 --- a/src/scaffold/NavigationSidebar/connectors/__tests__/rustSessionDeleteReceipt.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/__tests__/rustSessionDeleteReceipt.test.ts @@ -10,6 +10,7 @@ function cleanupSpies() { clearPendingFileOpens: vi.fn(), clearPendingCodeEditorTab: vi.fn(), evictEventStore: vi.fn().mockResolvedValue(undefined), + closeSessionTabs: vi.fn().mockReturnValue(false), }; } @@ -42,9 +43,14 @@ describe("applyRustSessionDeleteReceipt", () => { ["worker-b"], ["root"], ]); + expect(cleanup.closeSessionTabs).toHaveBeenCalledWith([ + "worker-a", + "worker-b", + "root", + ]); }); - it("leaves ordinary SDE cleanup on the existing single-session path", async () => { + it("closes the ordinary SDE tab while leaving data cleanup on the existing path", async () => { const cleanup = cleanupSpies(); const deletedActiveSession = await applyRustSessionDeleteReceipt({ @@ -64,6 +70,25 @@ describe("applyRustSessionDeleteReceipt", () => { expect(cleanup.clearPendingFileOpens).not.toHaveBeenCalled(); expect(cleanup.clearPendingCodeEditorTab).not.toHaveBeenCalled(); expect(cleanup.evictEventStore).not.toHaveBeenCalled(); + expect(cleanup.closeSessionTabs).toHaveBeenCalledWith(["ordinary-session"]); + }); + + it("does not request a second navigation when closing the active tab selected a safe fallback", async () => { + const cleanup = cleanupSpies(); + cleanup.closeSessionTabs.mockReturnValue(true); + + const requiresNavigationReset = await applyRustSessionDeleteReceipt({ + requestedSessionId: "root", + activeSessionId: "root", + isAgentOrgRoot: true, + receipt: { + deletedSessionIds: ["worker", "root"], + }, + cleanup, + }); + + expect(requiresNavigationReset).toBe(false); + expect(cleanup.closeSessionTabs).toHaveBeenCalledWith(["worker", "root"]); }); it("deduplicates malformed duplicate IDs before local cleanup", async () => { @@ -82,5 +107,6 @@ describe("applyRustSessionDeleteReceipt", () => { expect(cleanup.removeSession).toHaveBeenCalledTimes(1); expect(cleanup.removeSession).toHaveBeenCalledWith("worker"); expect(cleanup.evictEventStore.mock.calls).toEqual([["worker"], ["root"]]); + expect(cleanup.closeSessionTabs).toHaveBeenCalledWith(["worker", "root"]); }); }); diff --git a/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts b/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts index ab026cfbf1..a03df52758 100644 --- a/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts +++ b/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts @@ -7,6 +7,7 @@ interface RustSessionDeleteCleanup { clearPendingFileOpens: (sessionId: string) => void; clearPendingCodeEditorTab: (sessionId: string) => void; evictEventStore: (sessionId: string) => Promise; + closeSessionTabs: (sessionIds: readonly string[]) => boolean; } interface ApplyRustSessionDeleteReceiptOptions { @@ -20,9 +21,10 @@ interface ApplyRustSessionDeleteReceiptOptions { /** * Apply the additional local cleanup described by a Rust deletion receipt. * - * The requested row keeps the sidebar's existing single-session cleanup path. - * Only descendant IDs are handled here, so an ordinary SDE receipt containing - * one ID has no new cleanup side effects. + * The requested row keeps the sidebar's existing single-session data cleanup + * path. Chat tabs are receipt-owned, however, so every deleted Root/Member is + * removed together before any caller decides whether a separate navigation + * reset is still necessary. */ export async function applyRustSessionDeleteReceipt({ requestedSessionId, @@ -47,5 +49,6 @@ export async function applyRustSessionDeleteReceipt({ ); } - return deletedSessionIds.includes(activeSessionId); + const closedActiveSessionTab = cleanup.closeSessionTabs(deletedSessionIds); + return deletedSessionIds.includes(activeSessionId) && !closedActiveSessionTab; } diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts index a931ed8027..7fae74df27 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarContextMenu.ts @@ -187,6 +187,12 @@ export function useWorkstationSidebarContextMenu({ }); } + // Team roots use the explicit Archived Overview Danger Zone. Generic + // Session Delete is intentionally absent so it cannot bypass Archive or + // the quiesced-runtime receipt. + if (session?.agentOrgId) { + return [...primaryItems, pinItem]; + } return [...primaryItems, pinItem, { item: "Separator" }, deleteItem]; }, [ diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts index 7960827bda..7a31026641 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts @@ -34,6 +34,7 @@ import { clearCliTurnLifecycleSession } from "@src/hooks/cliSession/cliTurnLifec import { createLogger } from "@src/hooks/logger"; import type { GoToNewSessionOptions } from "@src/hooks/navigation/useAppNavigation"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; +import { closeSessionChatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { SESSION_SIDEBAR_PAGE_SIZE, type Session, @@ -152,6 +153,7 @@ export function useWorkstationSidebarHandlers({ }, [disposeWorkstationTabsWorkspace, disposeEditorCacheForSession] ); + const closeSessionChatPanelTabs = useSetAtom(closeSessionChatPanelTabsAtom); const pagination = useAtomValue(sessionPaginationAtom); const cloudAuth = useAtomValue(org2CloudAuthAtom); const setCloudAuth = useSetAtom(org2CloudAuthAtom); @@ -175,7 +177,7 @@ export function useWorkstationSidebarHandlers({ return; } const session = sessionMap.get(sessionId); - let deletedActiveRustSession = false; + let requiresNavigationReset = false; const forkedFrom = session ? getSessionForkedFrom(session) : undefined; // Cloud retraction targets, mirroring the engine's publish targets: // a fork publishes only to its source org; an ordinary session @@ -218,11 +220,13 @@ export function useWorkstationSidebarHandlers({ if (isCliSession(sessionId)) { await invokeTauri("cli_agent_delete", { sessionId }); clearCliTurnLifecycleSession(sessionId); + requiresNavigationReset = sessionId === activeSessionId; } else if (isHumanSession(sessionId)) { await deleteHumanSession(sessionId); + requiresNavigationReset = sessionId === activeSessionId; } else { const receipt = await deleteSession(sessionId); - deletedActiveRustSession = await applyRustSessionDeleteReceipt({ + requiresNavigationReset = await applyRustSessionDeleteReceipt({ requestedSessionId: sessionId, activeSessionId, isAgentOrgRoot: Boolean(session?.agentOrgId), @@ -242,6 +246,7 @@ export function useWorkstationSidebarHandlers({ { deletedSessionId, error } ) ), + closeSessionTabs: closeSessionChatPanelTabs, }, }); } @@ -251,9 +256,7 @@ export function useWorkstationSidebarHandlers({ clearPendingFileOpensForSession(sessionId); clearPendingCodeEditorTabForSession(sessionId); - if (sessionId === activeSessionId || deletedActiveRustSession) { - goToNewSession(); - } + if (requiresNavigationReset) goToNewSession(); } catch (error) { log.error("[WorkstationSidebar] Failed to delete session:", error); Message.error(tCommon("sessions:chat.failedToDeleteSession")); @@ -264,6 +267,7 @@ export function useWorkstationSidebarHandlers({ cloudAuth, setCloudAuth, cloudOrgs, + closeSessionChatPanelTabs, disposeWorkstationWorkspace, goToNewSession, onCloseChatPanelTab, diff --git a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts index c3c2671687..ae657b522d 100644 --- a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts +++ b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts @@ -56,6 +56,7 @@ import { closeChatPanelTabAtom, closeOtherChatPanelTabsAtom, closeProjectOrgChatPanelTabsAtom, + closeSessionChatPanelTabsAtom, closeWorkItemChatPanelTabAtom, isChatPanelTabStationAvailable, normalizePersistedChatPanelTabsState, @@ -126,6 +127,7 @@ async function loadChatPanelTabAtoms() { closeChatPanelTabAtom, closeOtherChatPanelTabsAtom, closeProjectOrgChatPanelTabsAtom, + closeSessionChatPanelTabsAtom, closeWorkItemChatPanelTabAtom, createChatPanelTerminalAtom, kanbanDetailPanelVisibleAtom, @@ -356,6 +358,91 @@ describe("closeChatPanelTabAtom", () => { }); }); +describe("closeSessionChatPanelTabsAtom", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + localStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("closes every deleted Team tab atomically and activates one safe neighbour", async () => { + const { + activeSessionIdAtom, + activateChatPanelTabAtom, + chatPanelTabsAtom, + closeChatPanelTabAtom, + closeSessionChatPanelTabsAtom, + openSessionInNewChatTabAtom, + store, + } = await loadChatPanelTabAtoms(); + const launchpadId = store.get(chatPanelTabsAtom).activeTabId; + const rootTabId = store.set(openSessionInNewChatTabAtom, { + sessionId: "deleted-root", + sessionName: "Deleted Root", + }); + store.set(closeChatPanelTabAtom, launchpadId); + const memberTabId = store.set(openSessionInNewChatTabAtom, { + sessionId: "deleted-member", + sessionName: "Deleted Member", + }); + const safeTabId = store.set(openSessionInNewChatTabAtom, { + sessionId: "safe-session", + sessionName: "Safe session", + }); + store.set(activateChatPanelTabAtom, memberTabId); + + const activeTabClosed = store.set(closeSessionChatPanelTabsAtom, [ + "deleted-root", + "deleted-member", + "deleted-member", + ]); + + const state = store.get(chatPanelTabsAtom); + expect(activeTabClosed).toBe(true); + expect(state.tabs.map((tab) => tab.id)).toEqual([ + "launchpad-default", + safeTabId, + ]); + expect(state.tabs.some((tab) => tab.id === rootTabId)).toBe(false); + expect(state.activeTabId).toBe("launchpad-default"); + expect(store.get(activeSessionIdAtom)).toBeNull(); + }); + + it("preserves the active tab when only background session tabs were deleted", async () => { + const { + activeSessionIdAtom, + chatPanelTabsAtom, + closeSessionChatPanelTabsAtom, + openSessionInNewChatTabAtom, + store, + } = await loadChatPanelTabAtoms(); + store.set(openSessionInNewChatTabAtom, { + sessionId: "deleted-root", + sessionName: "Deleted Root", + }); + const safeTabId = store.set(openSessionInNewChatTabAtom, { + sessionId: "safe-session", + sessionName: "Safe session", + }); + + const activeTabClosed = store.set(closeSessionChatPanelTabsAtom, [ + "deleted-root", + ]); + + const state = store.get(chatPanelTabsAtom); + expect(activeTabClosed).toBe(false); + expect(state.tabs.some((tab) => tab.sessionId === "deleted-root")).toBe( + false + ); + expect(state.activeTabId).toBe(safeTabId); + expect(store.get(activeSessionIdAtom)).toBe("safe-session"); + }); +}); + describe("closeOtherChatPanelTabsAtom", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts b/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts index ce40a58740..420d62967c 100644 --- a/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts +++ b/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts @@ -115,6 +115,67 @@ export const closeChatPanelTabAtom = atom(null, (get, set, tabId: string) => { }); closeChatPanelTabAtom.debugLabel = "closeChatPanelTab"; +/** + * Close every Chat Panel tab owned by sessions that were durably deleted. + * + * A Team deletion receipt can contain the Root and several Members, and more + * than one of them may be open. Remove the whole set in one state transition + * so activating a fallback can never briefly re-select another deleted + * session between per-tab closes. + */ +export const closeSessionChatPanelTabsAtom = atom( + null, + (get, set, sessionIds: readonly string[]): boolean => { + if (sessionIds.length === 0) return false; + const deletedSessionIds = new Set(sessionIds); + const state = get(chatPanelTabsAtom); + const tabsToClose = new Set( + state.tabs + .filter( + (tab) => + tab.type === "session" && + Boolean(tab.sessionId && deletedSessionIds.has(tab.sessionId)) + ) + .map((tab) => tab.id) + ); + if (tabsToClose.size === 0) return false; + + const activeIndex = state.tabs.findIndex( + (tab) => tab.id === state.activeTabId + ); + const activeTabClosed = tabsToClose.has(state.activeTabId); + const remainingTabs = state.tabs.filter((tab) => !tabsToClose.has(tab.id)); + + const rememberedSessionId = get(workstationActiveSessionIdAtom); + if (rememberedSessionId && deletedSessionIds.has(rememberedSessionId)) { + set(workstationActiveSessionIdAtom, null); + } + + if (!activeTabClosed) { + set(chatPanelTabsAtom, { ...state, tabs: remainingTabs }); + return false; + } + + const fallbackTab = + state.tabs + .slice(0, Math.max(0, activeIndex)) + .reverse() + .find((tab) => !tabsToClose.has(tab.id)) ?? + state.tabs + .slice(Math.max(0, activeIndex + 1)) + .find((tab) => !tabsToClose.has(tab.id)); + const nextTab = fallbackTab ?? buildDefaultLaunchpadTab(); + const nextTabs = fallbackTab ? remainingTabs : [nextTab]; + set(chatPanelTabsAtom, { + tabs: nextTabs, + activeTabId: nextTab.id, + }); + set(activateChatPanelTabAtom, nextTab.id); + return true; + } +); +closeSessionChatPanelTabsAtom.debugLabel = "closeSessionChatPanelTabs"; + /** Close the singleton organization tab, or clear its legacy surface mirrors. */ export const closeOrganizationChatPanelTabAtom = atom(null, (get, set) => { const tab = get(chatPanelTabsAtom).tabs.find( diff --git a/src/store/chatPanel/chatPanelTabsAtom.ts b/src/store/chatPanel/chatPanelTabsAtom.ts index 7eb781ea77..b2e1c71b5b 100644 --- a/src/store/chatPanel/chatPanelTabsAtom.ts +++ b/src/store/chatPanel/chatPanelTabsAtom.ts @@ -14,6 +14,7 @@ export { closeOtherChatPanelTabsAtom, closeProjectOrgChatPanelTabsAtom, closeRevokedCloudChannelChatPanelTabsAtom, + closeSessionChatPanelTabsAtom, closeWorkItemChatPanelTabAtom, nextChatPanelTabAtom, patchChatPanelWorkItemTabAtom, diff --git a/tests/e2e/specs/core/agent-org-pause-resume-live.spec.mjs b/tests/e2e/specs/core/agent-org-pause-resume-live.spec.mjs index bf7cb2b62f..ddb973f3a6 100644 --- a/tests/e2e/specs/core/agent-org-pause-resume-live.spec.mjs +++ b/tests/e2e/specs/core/agent-org-pause-resume-live.spec.mjs @@ -36,6 +36,7 @@ const MEMBER_ID = "pause-worker"; const MARKER_ROUND = ROUND.replaceAll("-", "_"); const PROCESS_MARKER = `ORGII_PAUSE_LIVE_${MARKER_ROUND}`; const FINALITY_MARKER = `ORGII_RESUME_FINALITY_${MARKER_ROUND}`; +const ARCHIVE_MARKER = `ORGII_ARCHIVE_LIVE_${MARKER_ROUND}`; const ORGII_HOME = process.env.E2E_ORGII_HOME ?? ""; async function postJson(pathname, body = {}, timeoutMs = 15_000) { @@ -248,7 +249,7 @@ async function assertFeatureGatePreflight() { function assertLiveInputs() { if (!ROUND) throw new Error("E2E_AGENT_ORG_LIVE_ROUND_ID is required"); if (!ORGII_HOME) throw new Error("E2E_ORGII_HOME is required"); - if (!new Set(["pause", "resume", "task-smoke"]).has(PHASE)) { + if (!new Set(["pause", "resume", "archive", "task-smoke"]).has(PHASE)) { throw new Error(`unsupported E2E_AGENT_ORG_LIVE_PHASE=${PHASE}`); } if (PROVIDER_MODE === "mock") { @@ -256,6 +257,200 @@ function assertLiveInputs() { } } +function archiveConvergenceSnapshot(runId) { + const run = sqlLiteral(runId); + return sqliteRow(` + SELECT + (SELECT status FROM agent_org_runtime_runs WHERE id=${run}) AS run_status, + (SELECT teardown_status FROM agent_org_runtime_archive_episodes + WHERE org_run_id=${run}) AS teardown_status, + (SELECT teardown_attempt_count FROM agent_org_runtime_archive_episodes + WHERE org_run_id=${run}) AS teardown_attempt_count, + (SELECT retained_runtime_count FROM agent_org_runtime_archive_episodes + WHERE org_run_id=${run}) AS retained_runtime_count, + (SELECT COUNT(*) FROM agent_org_runtime_tasks + WHERE org_run_id=${run}) AS task_count, + (SELECT COUNT(*) FROM agent_org_runtime_tasks + WHERE org_run_id=${run} AND status NOT IN ('completed','failed','cancelled')) AS open_task_count, + (SELECT COUNT(*) FROM session_turn_intents + WHERE org_run_id=${run} AND status IN ('queued','running')) AS active_intent_count, + (SELECT COUNT(*) FROM agent_org_runtime_turn_contexts + WHERE org_run_id=${run}) AS context_count, + (SELECT COUNT(*) FROM agent_org_runtime_inbox + WHERE org_run_id=${run}) AS inbox_count, + (SELECT COUNT(*) FROM agent_messages message + JOIN agent_org_runtime_member_materializations member + ON member.session_id=message.session_id + WHERE member.org_run_id=${run}) AS member_message_count, + (SELECT MAX(updated_at) FROM agent_org_runtime_tasks + WHERE org_run_id=${run}) AS latest_task_updated_at + `); +} + +async function runArchivePhase() { + const account = await getApiAccount(); + const model = selectPreferredModel(account); + await seedOneMemberOrg(); + await configureCreatorForAgentOrg({ account, model, agentOrgId: ORG_ID }); + await selectRenderedAgentOrg(ORG_ID); + + const backgroundCommand = [ + "trap '' TERM;", + `sh -c 'trap "" TERM; while :; do sleep 120; done' & child=$!;`, + `printf '${ARCHIVE_MARKER} parent=%s child=%s\\n' "$$" "$child";`, + "wait", + ].join(" "); + const prompt = [ + `This is live Archive acceptance round ${ROUND}.`, + `Use task_graph_create exactly once to create exactly one Task assigned to ${MEMBER_ID}.`, + `The Task subject must contain ${ARCHIVE_MARKER}.`, + "Its description must tell the Member to call run_shell exactly once with mode=background", + `and this exact command: ${backgroundCommand}`, + "After starting it, keep the Task in progress and do not kill or await the shell.", + "Do not run the command as coordinator and do not create another Task.", + ].join(" "); + const sessionId = await sendFromRenderedCreator(prompt); + if (!sessionId) + throw new Error("live Archive launch produced no root Session"); + + const view = await waitForAgentOrgRunView( + sessionId, + (candidate) => + candidate?.runStatus === "running" && + candidate?.tasks?.length === 1 && + candidate.tasks[0]?.owner === MEMBER_ID, + "real Provider created one assigned Archive Task", + REPLY_TIMEOUT_MS * 2 + ); + const runId = view.context.runId; + let before = null; + let targetShell = null; + await browser.waitUntil( + async () => { + before = await postJson("/agent/test/agent-org/runtime-evidence", { + org_run_id: runId, + }); + targetShell = before.runtime.background_shells.find((shell) => + String(shell.command).includes(ARCHIVE_MARKER) + ); + return ( + Boolean(targetShell) && + before.runtime.execution_blockers.some( + (job) => job.handle === String(targetShell.pid) + ) && + processGroupSnapshot(targetShell.pid).length >= 3 + ); + }, + { + timeout: REPLY_TIMEOUT_MS * 2, + interval: 250, + timeoutMsg: `real Provider Member never started the Archive parent/child process group: ${JSON.stringify(before)}`, + } + ); + const processRows = processGroupSnapshot(targetShell.pid); + const knownPids = processRows.map((row) => row.pid); + const replayFiles = filesContainingMarker( + join(ORGII_HOME, "shell-replays"), + ARCHIVE_MARKER + ); + if (replayFiles.length !== 1) { + throw new Error( + `Archive command did not start exactly once: ${JSON.stringify(replayFiles)}` + ); + } + + await openAgentOrgOverviewPanel("real Provider Archive control"); + await execJS("window.__orgiiE2EAutoConfirmDestructive = true; return true;"); + const archiveButton = await visibleProductButton( + '[data-testid="agent-org-overview-archive-button"]', + "data-e2e-live-archive" + ); + const archiveStartedAt = Date.now(); + await archiveButton.click(); + await browser.waitUntil( + async () => + execJS(` + const panel = document.querySelector('[data-testid="agent-org-overview-panel"]'); + return panel?.getAttribute('data-run-phase') === 'archived' + && !!document.querySelector('[data-testid="agent-org-archived-composer"]'); + `), + { + timeout: RENDER_TIMEOUT_MS, + interval: 50, + timeoutMsg: "Archive did not project the immediate read-only UI", + } + ); + const readOnlyProjectionMs = Date.now() - archiveStartedAt; + + let quiesced = null; + await browser.waitUntil( + async () => { + quiesced = await postJson("/agent/test/agent-org/runtime-evidence", { + org_run_id: runId, + }); + return ( + quiesced.durable.run_status === "archived" && + quiesced.durable.archive?.status === "quiesced" && + quiesced.durable.archive?.retainedRuntimeCount === 0 && + quiesced.runtime.active_runtime_count === 0 && + quiesced.runtime.active_turns.length === 0 && + quiesced.runtime.background_jobs.length === 0 && + quiesced.runtime.execution_blockers.length === 0 + ); + }, + { + timeout: 65_000, + interval: 100, + timeoutMsg: `Archive receipt became read-only but never proved runtime/job finality: ${JSON.stringify(quiesced)}`, + } + ); + const quiescenceMs = Date.now() - archiveStartedAt; + const survivors = processGroupSnapshot(targetShell.pid); + const liveKnownPids = knownPids.filter(pidExists); + if (survivors.length > 0 || liveKnownPids.length > 0) { + throw new Error( + `Archive left parent/child processes alive: ${JSON.stringify({ survivors, liveKnownPids, processRows })}` + ); + } + + const quietBefore = archiveConvergenceSnapshot(runId); + await browser.pause(5_000); + const quietEvidence = await postJson( + "/agent/test/agent-org/runtime-evidence", + { org_run_id: runId } + ); + const quietAfter = archiveConvergenceSnapshot(runId); + if ( + JSON.stringify(quietBefore) !== JSON.stringify(quietAfter) || + quietEvidence.runtime.active_runtime_count !== 0 || + quietEvidence.runtime.active_turns.length !== 0 || + quietEvidence.runtime.background_jobs.length !== 0 || + processGroupSnapshot(targetShell.pid).length !== 0 || + filesContainingMarker(join(ORGII_HOME, "shell-replays"), ARCHIVE_MARKER) + .length !== 1 + ) { + throw new Error( + `late Provider, Task, Inbox, message, replay, or process activity appeared after Archive: ${JSON.stringify({ quietBefore, quietAfter, quietEvidence })}` + ); + } + + console.info( + `[agent-org-live-archive-evidence] ${JSON.stringify({ + round: ROUND, + provider: { accountId: account.id, accountName: account.name, model }, + runId, + taskId: view.tasks[0]?.id, + processGroupId: targetShell.pid, + processRows, + replayFiles, + readOnlyProjectionMs, + quiescenceMs, + retainedTombstoneCount: quietEvidence.runtime.retained_tombstone_count, + quiet: quietAfter, + })}` + ); +} + async function seedOneMemberOrg() { await removeAgentOrgsByName(ORG_NAME); await postJson("/agent/test/agent-org/seed", { @@ -761,7 +956,7 @@ async function runTaskSmokePhase() { ); } -describe("Agent Org Pause/Resume live Provider process ownership", function () { +describe("Agent Org lifecycle live Provider process ownership", function () { before(async () => { assertLiveInputs(); await waitForApp(); @@ -772,6 +967,7 @@ describe("Agent Org Pause/Resume live Provider process ownership", function () { this.timeout(900_000); if (PHASE === "pause") return runPausePhase(); if (PHASE === "resume") return runResumePhase(); + if (PHASE === "archive") return runArchivePhase(); return runTaskSmokePhase(); }); }); diff --git a/tests/e2e/specs/core/agent-org-session-delete-ui.spec.mjs b/tests/e2e/specs/core/agent-org-session-delete-ui.spec.mjs index 56901a4959..bb5639800e 100644 --- a/tests/e2e/specs/core/agent-org-session-delete-ui.spec.mjs +++ b/tests/e2e/specs/core/agent-org-session-delete-ui.spec.mjs @@ -1,14 +1,14 @@ /* global describe, before, it, browser, process */ -import { execFileSync } from "node:child_process"; - import { RENDER_TIMEOUT_MS, execJS, invokeE2E, + openAgentOrgOverviewPanel, openRenderedSidebarSession, unwrap, waitForApp, } from "../../support/core/agentOrgUiDriver.mjs"; +import { selectPersonalScopeFromSidebar } from "../../support/core/cloudOrgUiDriver.mjs"; const E2E_BASE_URL = `http://127.0.0.1:${process.env.E2E_IDE_SERVER_PORT ?? "13847"}`; const RUN_ID = Date.now(); @@ -35,9 +35,9 @@ async function postJson(pathname, body = {}, timeoutMs = 15_000) { async function seedHierarchy({ label, - rootStatus = "completed", - runStatus = "completed", - workerStatus = "completed", + rootStatus = "idle", + runStatus = "running", + workerStatus = "idle", nested = false, }) { const rootSessionId = `sdeagent-e2e-delete-${label}-root-${RUN_ID}`; @@ -79,14 +79,52 @@ async function seedHierarchy({ } async function refreshAndWaitForSidebarRow(sessionId) { + unwrap( + await invokeE2E("primeSidebarEntityCache"), + `primeSidebarEntityCache(${sessionId})` + ); unwrap( await invokeE2E("seedSidebarSession", { sessionId, name: `Agent Org delete ${sessionId}`, - status: "completed", + status: "idle", }), `seedSidebarSession(${sessionId})` ); + await ( + await browser.$('[data-testid="sidebar-session-filter-button"]') + ).click(); + await browser.waitUntil( + async () => + execJS( + `return !!document.querySelector('[data-testid="sidebar-refresh-sessions"]');` + ), + { + timeout: RENDER_TIMEOUT_MS, + interval: 100, + timeoutMsg: "sidebar refresh action did not render", + } + ); + await (await browser.$('[data-testid="sidebar-refresh-sessions"]')).click(); + let rosterState = null; + await browser.waitUntil( + async () => { + const inspected = unwrap( + await invokeE2E("inspectSidebarPagination", [sessionId]), + `inspectSidebarPagination(${sessionId})` + ); + rosterState = inspected.pagination?.agent_org_root ?? null; + return rosterState?.sessionIds?.includes(sessionId) === true; + }, + { + // The rendered Refresh action also scans all enabled external-history + // sources before publishing the authoritative native roster. On a cold + // packaged-app launch that scan can cross the ordinary 20s render bound. + timeout: RENDER_TIMEOUT_MS * 3, + interval: 250, + timeoutMsg: `sidebar refresh never published Agent Org root ${sessionId}: ${JSON.stringify(rosterState)}`, + } + ); const selector = `[data-testid="sidebar-session-item-${sessionId}"]`; await browser.waitUntil( async () => @@ -94,45 +132,11 @@ async function refreshAndWaitForSidebarRow(sessionId) { { timeout: RENDER_TIMEOUT_MS, interval: 200, - timeoutMsg: `sidebar row ${sessionId} did not render`, + timeoutMsg: `sidebar row ${sessionId} did not render after roster publication`, } ); } -async function chooseDeleteFromRenderedSidebarMenu(sessionId) { - const rowSelector = `[data-testid="sidebar-session-item-${sessionId}"]`; - const moreSelector = `[data-testid="sidebar-session-more-${sessionId}"]`; - const row = await browser.$(rowSelector); - await row.moveTo(); - - let opened = false; - for (let attempt = 0; attempt < 2 && !opened; attempt += 1) { - await (await browser.$(moreSelector)).click(); - opened = await browser - .waitUntil( - async () => - execJS( - `return document.querySelector(${JSON.stringify(moreSelector)})?.getAttribute('aria-pressed') === 'true';` - ), - { timeout: 2_000, interval: 100 } - ) - .catch(() => false); - } - if (!opened) { - throw new Error(`native sidebar menu did not open for ${sessionId}`); - } - - // WebDriver key actions target the WebView rather than the macOS menu - // process. Native menus support type-to-select, so select the uniquely - // named Delete item and confirm it with real OS key events. - execFileSync("osascript", [ - "-e", - 'tell application "System Events" to keystroke "d"', - "-e", - 'tell application "System Events" to key code 36', - ]); -} - async function persistenceSnapshot(sessionIds, runIds) { return postJson("/agent/test/agent-org/session-delete/snapshot", { session_ids: sessionIds, @@ -140,11 +144,173 @@ async function persistenceSnapshot(sessionIds, runIds) { }); } +async function acknowledgePermanentDeleteLikeUser() { + const acknowledgementSelector = 'div[role="dialog"] [data-checkbox]'; + let point = null; + await browser.waitUntil( + async () => { + point = await execJS(` + const label = document.querySelector(${JSON.stringify(acknowledgementSelector)}); + if (!label) return null; + const rect = label.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const hit = document.elementFromPoint(x, y); + return { + x, + y, + visible: rect.width > 0 && rect.height > 0, + hitOwned: !!hit?.closest?.('[data-checkbox]'), + }; + `); + return point?.visible === true && point?.hitOwned === true; + }, + { + timeout: RENDER_TIMEOUT_MS, + interval: 100, + timeoutMsg: `Delete acknowledgement was not pointer-clickable: ${JSON.stringify(point)}`, + } + ); + + // Exercise the visible label through a real pointer sequence. The previous + // shortcut clicked the hidden native input, skipped mousedown, and therefore + // missed the portal/outside-click regression found in the packaged app. + await browser + .action("pointer") + .move({ x: point.x, y: point.y }) + .down() + .up() + .perform(); + + const state = await execJS(` + const dialog = document.querySelector('div[role="dialog"]'); + const input = dialog?.querySelector('[data-checkbox-input]'); + const confirm = dialog?.querySelector('[data-testid="agent-org-delete-confirm-button"]'); + return { + dialogOpen: !!dialog, + overviewOpen: !!document.querySelector('[data-testid="agent-org-overview-panel"]'), + checked: input?.checked === true, + confirmEnabled: !!confirm && !confirm.disabled, + }; + `); + if ( + !state.dialogOpen || + !state.overviewOpen || + !state.checked || + !state.confirmEnabled + ) { + throw new Error( + `Visible Delete acknowledgement collapsed or failed to enable confirmation: ${JSON.stringify(state)}` + ); + } +} + async function deleteHierarchyAndAssertGone(hierarchy) { await refreshAndWaitForSidebarRow(hierarchy.rootSessionId); await openRenderedSidebarSession(hierarchy.rootSessionId); + await openAgentOrgOverviewPanel( + `Agent Org delete ${hierarchy.rootSessionId}` + ); - await chooseDeleteFromRenderedSidebarMenu(hierarchy.rootSessionId); + await browser.waitUntil( + async () => + execJS( + `return !!document.querySelector('[data-testid="agent-org-overview-archive-button"]');` + ), + { + timeout: RENDER_TIMEOUT_MS, + interval: 200, + timeoutMsg: "Archive action did not render", + } + ); + await execJS("window.__orgiiE2EAutoConfirmDestructive = true; return true;"); + await ( + await browser.$('[data-testid="agent-org-overview-archive-button"]') + ).click(); + + await browser.waitUntil( + async () => + execJS( + `return document.querySelector('[data-testid="agent-org-overview-panel"]')?.getAttribute("data-run-phase") === "archived";` + ), + { + timeout: RENDER_TIMEOUT_MS, + interval: 200, + timeoutMsg: "Archive did not project Archived immediately", + } + ); + await browser.waitUntil( + async () => + execJS( + `return !!document.querySelector('[data-testid="agent-org-archived-composer"]') && document.querySelector('[data-testid="agent-org-task-history-toggle"]')?.getAttribute("aria-expanded") === "true";` + ), + { + timeout: RENDER_TIMEOUT_MS, + interval: 200, + timeoutMsg: "Archived read-only composer/history did not render", + } + ); + + let archivedSnapshot = null; + await browser.waitUntil( + async () => { + archivedSnapshot = await persistenceSnapshot( + [hierarchy.rootSessionId, ...hierarchy.workerSessionIds], + [hierarchy.runId] + ); + const detail = archivedSnapshot.run_details[hierarchy.runId]; + return ( + detail?.status === "archived" && + detail?.activation_generation >= 2 && + Boolean(detail?.archived_at) && + Boolean(detail?.archive_receipt_id) && + detail?.teardown_status === "quiesced" && + detail?.retained_runtime_count === 0 + ); + }, + { + timeout: 60_000, + interval: 200, + timeoutMsg: `Archive fence did not reach quiesced: ${JSON.stringify(archivedSnapshot)}`, + } + ); + + // Archive is intentionally committed before its bounded teardown finishes. + // Refresh through the real product control after the durable receipt proves + // quiescence so the Danger Zone consumes the final projected state. + await ( + await browser.$('[data-testid="agent-org-overview-refresh-button"]') + ).click(); + await browser.waitUntil( + async () => + execJS( + `return document.querySelector('[data-testid="agent-org-archive-teardown-status"]')?.getAttribute("data-teardown-status") === "quiesced";` + ), + { + timeout: RENDER_TIMEOUT_MS, + interval: 200, + timeoutMsg: "Archive teardown receipt did not project as quiesced", + } + ); + + await browser.waitUntil( + async () => + execJS( + `const button=document.querySelector('[data-testid="agent-org-overview-delete-button"]'); return !!button && !button.disabled;` + ), + { + timeout: RENDER_TIMEOUT_MS, + interval: 200, + timeoutMsg: "Team Delete stayed blocked after runtime quiescence", + } + ); + await ( + await browser.$('[data-testid="agent-org-overview-delete-button"]') + ).click(); + await acknowledgePermanentDeleteLikeUser(); + await ( + await browser.$('[data-testid="agent-org-delete-confirm-button"]') + ).click(); const rootSelector = `[data-testid="sidebar-session-item-${hierarchy.rootSessionId}"]`; await browser.waitUntil( @@ -158,6 +324,25 @@ async function deleteHierarchyAndAssertGone(hierarchy) { timeoutMsg: "deleted Agent Org root remained in the sidebar", } ); + const deletedTabTitle = `Agent Org delete ${hierarchy.rootSessionId}`; + const deletedSurfaceState = await execJS(` + const deletedTitle = ${JSON.stringify(deletedTabTitle)}; + return { + deletedTabVisible: [...document.querySelectorAll('[role="tab"]')] + .some((tab) => tab.getAttribute('title') === deletedTitle), + deletedOverviewVisible: !!document.querySelector('[data-testid="agent-org-overview-panel"]'), + launchpadVisible: !!document.querySelector('[data-testid="chat-panel-start-page"]'), + }; + `); + if ( + deletedSurfaceState.deletedTabVisible || + deletedSurfaceState.deletedOverviewVisible || + !deletedSurfaceState.launchpadVisible + ) { + throw new Error( + `deleted Team left a stale Chat Panel surface: ${JSON.stringify(deletedSurfaceState)}` + ); + } const snapshot = await persistenceSnapshot( [hierarchy.rootSessionId, ...hierarchy.workerSessionIds], [hierarchy.runId] @@ -168,23 +353,43 @@ async function deleteHierarchyAndAssertGone(hierarchy) { ]) { if (snapshot.sessions[sessionId] !== false) { throw new Error( - `deleted Rust session remained durable: ${sessionId} ${JSON.stringify(snapshot)}` + `deleted Team session remained durable: ${sessionId} ${JSON.stringify(snapshot)}` ); } } if (snapshot.runs[hierarchy.runId] !== false) { throw new Error( - `deleted run remained durable: ${JSON.stringify(snapshot)}` + `deleted Team remained durable: ${JSON.stringify(snapshot)}` ); } } - -describe("Agent Org Rust session hierarchy deletion rendered UI", () => { +describe("Agent Org irreversible Archive and Team Delete rendered UI", () => { before(async () => { await waitForApp(); + unwrap( + await invokeE2E("navigateTo", "/orgii/workstation/code"), + "navigateTo(Agent Org Archive/Delete)" + ); + // WebKit localStorage is keyed by the packaged app identity rather than + // E2E_ORGII_HOME. Normalize a cloud scope left by another app run before + // asserting local Agent Org rows. + await selectPersonalScopeFromSidebar(); + await (await browser.$('[data-testid="sidebar-view-sessions"]')).click(); + await browser.waitUntil( + async () => + execJS( + `return document.querySelector('[data-testid="sidebar-view-sessions"]')?.getAttribute('aria-current') === 'page';` + ), + { + timeout: RENDER_TIMEOUT_MS, + interval: 100, + timeoutMsg: + "Agent Org Archive/Delete Sessions sidebar did not activate", + } + ); }); - it("deletes the completed root and all Rust workers through the real sidebar menu", async () => { + it("archives through Overview, becomes read-only, then deletes through Danger Zone", async () => { const hierarchy = await seedHierarchy({ label: "completed", nested: true, @@ -217,7 +422,7 @@ describe("Agent Org Rust session hierarchy deletion rendered UI", () => { } }); - it("stops a running run and deletes its Rust hierarchy through the real sidebar menu", async () => { + it("archives a Working Team before deleting its full Rust hierarchy", async () => { const hierarchy = await seedHierarchy({ label: "running", rootStatus: "idle", @@ -228,7 +433,7 @@ describe("Agent Org Rust session hierarchy deletion rendered UI", () => { await deleteHierarchyAndAssertGone(hierarchy); }); - it("deletes a paused run and its Rust hierarchy through the real sidebar menu", async () => { + it("archives a Paused Team before deleting its full Rust hierarchy", async () => { const hierarchy = await seedHierarchy({ label: "paused", rootStatus: "paused",