From 3245e76e6295ec3b90de297e1b9ebd49731fc16e Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sun, 23 Aug 2026 15:07:02 +0800 Subject: [PATCH 1/8] feat(agent-core): add durable team archive fence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit the irreversible Archived state, generation bump, cancellation facts, and bounded teardown receipts atomically. Capture canonical Root and Member ownership so retries are idempotent and later deletion can prove the same Team boundary.\n\nVerification:\n- cargo test -q -p agent_core -- --test-threads=1 — 3,193 passed on the complete PR6 working tree\n- cargo clippy -p agent_core --all-targets -- -D warnings — passed on the complete PR6 working tree\n- cargo fmt --all -- --check — passed Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../coordination/agent_inbox/message_tests.rs | 11 +- .../coordination/agent_inbox/store_drain.rs | 57 +- .../coordination/agent_inbox/store_read.rs | 6 + .../coordination/agent_inbox/store_write.rs | 17 +- .../core/coordination/agent_inbox/tests.rs | 2 + .../agent_member_interventions.rs | 21 + .../core/coordination/agent_org_archive.rs | 693 ++++++++++++++++ .../coordination/agent_org_archive_tests.rs | 782 ++++++++++++++++++ .../core/coordination/agent_org_ownership.rs | 334 ++++++++ .../agent_org_plan_approvals/store.rs | 15 +- .../agent_org_plan_approvals/transitions.rs | 7 +- .../coordination/agent_org_runs/helpers.rs | 18 +- .../core/coordination/agent_org_runs/mod.rs | 26 +- .../agent_org_runs/store/completion.rs | 4 +- .../agent_org_runs/store/lifecycle.rs | 10 +- .../agent_org_runs/store/queries.rs | 12 +- .../agent_org_runs/store/starting.rs | 2 + .../coordination/agent_org_tasks/actor.rs | 38 +- .../coordination/agent_org_tasks/store/fsm.rs | 52 +- .../agent_org_tasks/store/validation.rs | 4 +- .../coordination/agent_org_tasks/tests.rs | 11 +- .../coordination/agent_org_turn_contexts.rs | 29 + .../agent-core/src/core/coordination/mod.rs | 4 + .../src/core/coordination/schema.rs | 24 +- .../agent-core/src/state/session_runtime.rs | 6 +- 25 files changed, 2131 insertions(+), 54 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/coordination/agent_org_archive.rs create mode 100644 src-tauri/crates/agent-core/src/core/coordination/agent_org_archive_tests.rs create mode 100644 src-tauri/crates/agent-core/src/core/coordination/agent_org_ownership.rs 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..7cecbbfe8e --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_archive.rs @@ -0,0 +1,693 @@ +//! 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) +} + +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_plan_approvals/store.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs index 1e5fb07486..55bb209c3d 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs @@ -455,9 +455,9 @@ impl AgentOrgPlanApprovalStore { ) .map_err(|err| err.to_string())?; if run_status != "running" { - return Err(format!( - "agent_org_run_not_mutable: run {} is {run_status}", - approval.org_run_id + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + &approval.org_run_id, + &run_status, )); } let resolved_at = chrono::Utc::now().to_rfc3339(); @@ -620,8 +620,9 @@ impl AgentOrgPlanApprovalStore { Ok(report) } - /// Cancel approvals whose parent run is gone or terminal. A paused run is - /// resumable and must keep its pending approval intact. + /// Cancel approvals whose parent run is gone or failed. Archive owns its + /// cancellation atomically with the irreversible fence; this reconciler + /// must never mutate an already-Archived Team. pub fn cancel_pending_for_terminal_or_missing_runs() -> 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..6e025ea5a5 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 @@ -518,6 +518,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}" @@ -1043,6 +1049,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 +1083,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 +1127,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 +1540,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/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/state/session_runtime.rs b/src-tauri/crates/agent-core/src/state/session_runtime.rs index dd8ce12ee3..1541bb3e72 100644 --- a/src-tauri/crates/agent-core/src/state/session_runtime.rs +++ b/src-tauri/crates/agent-core/src/state/session_runtime.rs @@ -645,7 +645,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 @@ -684,6 +684,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 From e7edd68b99ddd26644fa275dde9d3188b2c09ad2 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sun, 23 Aug 2026 15:10:42 +0800 Subject: [PATCH 2/8] feat(agent-core): bound archived runtime teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject post-Archive writes at Task, Inbox, Turn, Provider, Wake, recovery, and Resume boundaries. Release captured Root and Member runtimes with exact-lease checks, three bounded rounds, and one-shot startup reconciliation without a persistent watchdog. Verification: - cargo test -q -p agent_core -- --test-threads=1 — 3,193 passed on the complete PR6 working tree - cargo clippy -p agent_core --all-targets -- -D warnings — passed on the complete PR6 working tree - packaged BuildFast Archive races, retained-runtime deadline, restart, and ten-runtime parallel teardown — passed Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../src/core/coordination/agent_org_pause.rs | 13 +- .../coordination/agent_org_watchdog/tests.rs | 11 +- .../agent-core/src/core/providers/e2e_fake.rs | 29 ++ .../src/core/session/persistence/messages.rs | 229 ++++++++++++ .../src/core/session/persistence/mod.rs | 8 +- .../agent-core/src/core/session/turn/entry.rs | 1 + .../core/session/turn/event_handler/mod.rs | 21 +- .../core/session/turn/processor/execute.rs | 5 + .../orchestration/agent_org/inbox_repair.rs | 11 +- .../agent_org/send_message/persistence.rs | 7 +- .../agent_org/send_message/tests.rs | 15 +- .../tools/impls/orchestration/member_idle.rs | 8 +- .../db_helpers/messages/builders.rs | 34 +- .../persistence/db_helpers/messages/mod.rs | 5 +- .../foundation/persistence/db_helpers/mod.rs | 138 ++++--- src-tauri/crates/agent-core/src/init/mod.rs | 137 ++++++- .../agent-core/src/init/runtime_assemble.rs | 6 +- .../state/commands/session/message/tests.rs | 16 +- .../commands/session/org_tasks/group_chat.rs | 9 +- .../commands/session/org_tasks/lifecycle.rs | 340 ++++++++++++++++++ .../commands/session/org_tasks/run_view.rs | 11 + .../state/commands/session/org_tasks/tests.rs | 57 ++- .../agent-core/src/state/control_flow.rs | 16 +- .../agent-core/src/state/session_runtime.rs | 89 ++++- 24 files changed, 1084 insertions(+), 132 deletions(-) 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 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/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/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index e0d1b46487..ba0b7ce320 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_context_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,173 @@ 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 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/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/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/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..7513e70451 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,299 @@ 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> { + let Some(session) = state.get_session(&target.session_id).await else { + return persist_archive_teardown_attempt(target, None, None, true, None).await; + }; + session.cancel_active_turn(CancelReason::OrgArchive).await; + let Some(captured) = session.runtime_lease_identity().await else { + return persist_archive_teardown_attempt(target, None, None, true, None).await; + }; + let lease_id = captured.runtime_lease_id.clone(); + let turn_generation = captured.dialog_turn_generation.clone(); + + let released = tokio::time::timeout(round_timeout, async { + loop { + let current = session.runtime_lease_identity().await; + match current { + None => return Ok::(true), + Some(current) if current.runtime_lease_id != 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(&lease_id).await); + } + Some(_) => tokio::time::sleep(DRAIN_OBSERVATION_INTERVAL).await, + } + } + }) + .await; + + match released { + Ok(Ok(true)) => { + persist_archive_teardown_attempt(target, Some(lease_id), turn_generation, true, None) + .await + } + Ok(Ok(false)) => { + persist_archive_teardown_attempt( + target, + Some(lease_id), + turn_generation, + false, + Some("archive_runtime_release_stale".to_string()), + ) + .await + } + Ok(Err(error)) => { + persist_archive_teardown_attempt( + target, + Some(lease_id), + turn_generation, + false, + Some(error), + ) + .await + } + Err(_) => { + persist_archive_teardown_attempt( + target, + Some(lease_id), + turn_generation, + false, + Some("archive_runtime_stop_timeout".to_string()), + ) + .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/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 1541bb3e72..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( @@ -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; @@ -698,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( From f1becf4c1cab96bce94daf98e4e1267cdcfaba46 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sun, 23 Aug 2026 15:11:07 +0800 Subject: [PATCH 3/8] feat(agent-core): guard physical team deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every Agent Org Root or Member away from generic Session Delete. Allow the dedicated Team Delete only after Archived persistence, a quiesced Archive receipt, unchanged ownership, and zero current runtime leases are all proven. Verification: - cargo test -q -p agent_core -- --test-threads=1 — 3,193 passed on the complete PR6 working tree - generic Root and Member Delete fail-closed tests — passed - isolated packaged-app Team Delete preserved unrelated Team data — passed in four repeated rendered runs Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../commands/session/persistence_tests.rs | 155 ++++++++++++++---- 1 file changed, 125 insertions(+), 30 deletions(-) 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..f5423486b9 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) { @@ -178,8 +208,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, @@ -229,7 +258,7 @@ fn session_hierarchy_delete_removes_all_rust_descendants_and_run_history() { } #[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 +269,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 +305,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 +328,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 +428,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 +460,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 +531,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 +572,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 +613,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 +687,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)); From ed5f7960a717c972deb9c6368cabf9fee9612540 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sun, 23 Aug 2026 15:11:47 +0800 Subject: [PATCH 4/8] feat(agent-org): expose archive and team delete controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Archive and guarded Team Delete through Tauri, make every Archived composer read-only, keep history visible, surface bounded teardown diagnostics, and require a second danger-zone confirmation before physical deletion. Verification: - pnpm test — 1,116 files and 8,771 tests passed on the frozen PR6 tree - pnpm run lint — passed with five pre-existing ProjectManager warnings and zero errors - rendered Archive/Delete E2E — passed three consecutive full runs plus one final smoke - BuildFast packaged-app Computer Use and SQLite evidence — passed for PR5 baseline, fake Provider, and configured real Provider Pre-commit hook ran. Total eslint: 5, total circular: 0 --- src-tauri/src/api/agent/test/agent_org.rs | 37 ++- src-tauri/src/app/setup_hook/state.rs | 8 + src-tauri/src/commands/handler_list.inc | 2 + src/api/tauri/agent/orgTasks.ts | 49 +++ .../ChatPanel/AgentOrgArchivedComposer.tsx | 42 +++ src/engines/ChatPanel/ChatView.tsx | 11 +- .../components/AgentOrgOverviewPanel.tsx | 311 ++++++++++++++++- .../components/AgentOrgTaskPanel.test.ts | 312 +++++++++++++++++- src/i18n/locales/de/sessions.json | 16 + src/i18n/locales/en/sessions.json | 16 + src/i18n/locales/es/sessions.json | 16 + src/i18n/locales/fr/sessions.json | 16 + src/i18n/locales/ja/sessions.json | 16 + src/i18n/locales/ko/sessions.json | 16 + src/i18n/locales/pl/sessions.json | 16 + src/i18n/locales/pt/sessions.json | 16 + src/i18n/locales/ru/sessions.json | 16 + src/i18n/locales/tr/sessions.json | 16 + src/i18n/locales/vi/sessions.json | 16 + src/i18n/locales/zh-Hant/sessions.json | 16 + src/i18n/locales/zh/sessions.json | 16 + .../useWorkstationSidebarContextMenu.ts | 6 + .../core/agent-org-session-delete-ui.spec.mjs | 171 +++++++--- 23 files changed, 1102 insertions(+), 55 deletions(-) create mode 100644 src/engines/ChatPanel/AgentOrgArchivedComposer.tsx diff --git a/src-tauri/src/api/agent/test/agent_org.rs b/src-tauri/src/api/agent/test/agent_org.rs index 0406a1dbd7..86a0f2a128 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; 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..26c68605de --- /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/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..a2e53dce56 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,15 @@ 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 { 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 +80,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 +117,30 @@ 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 loadHistoryPage = useCallback( async ( @@ -185,6 +218,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 +285,122 @@ 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), + }; + await applyRustSessionDeleteReceipt({ + requestedSessionId: currentSessionId, + activeSessionId: currentSessionId, + isAgentOrgRoot: view?.context.rootSessionId === currentSessionId, + receipt, + cleanup, + }); + cleanup.removeSession(currentSessionId); + cleanup.removeForkRelayEntry(currentSessionId); + cleanup.disposeWorkstationWorkspace(currentSessionId); + cleanup.clearPendingFileOpens(currentSessionId); + cleanup.clearPendingCodeEditorTab(currentSessionId); + setDeleteModalOpen(false); + // Team Delete removes the current Root and every Member. Use the same + // complete navigation reset as ordinary Session Delete so the + // WorkStation active-session projection cannot repopulate a deleted + // Root after this component unmounts. + 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, + 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 +549,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..a99234aa03 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts @@ -28,6 +28,13 @@ 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", () => ({ @@ -35,7 +42,33 @@ vi.mock("react-i18next", () => ({ })); vi.mock("jotai", () => ({ useSetAtom: () => vi.fn() })); -vi.mock("@src/store/session", () => ({ activeSessionIdAtom: {} })); +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 +101,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, + onChange, + children, + }: { + checked?: boolean; + disabled?: boolean; + onChange?: (checked: boolean) => void; + children?: React.ReactNode; + }) => + createElement( + "label", + null, + createElement("input", { + type: "checkbox", + checked, + disabled, + onChange: (event: React.ChangeEvent) => + onChange?.(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 +271,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 +476,230 @@ 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"], + }); + 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).toHaveBeenCalledOnce(); + }); + 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/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/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..21618a6321 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,10 +1,9 @@ /* global describe, before, it, browser, process */ -import { execFileSync } from "node:child_process"; - import { RENDER_TIMEOUT_MS, execJS, invokeE2E, + openAgentOrgOverviewPanel, openRenderedSidebarSession, unwrap, waitForApp, @@ -35,9 +34,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 +78,33 @@ 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(); const selector = `[data-testid="sidebar-session-item-${sessionId}"]`; await browser.waitUntil( async () => @@ -99,40 +117,6 @@ async function refreshAndWaitForSidebarRow(sessionId) { ); } -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, @@ -143,8 +127,83 @@ async function persistenceSnapshot(sessionIds, runIds) { async function deleteHierarchyAndAssertGone(hierarchy) { await refreshAndWaitForSidebarRow(hierarchy.rootSessionId); await openRenderedSidebarSession(hierarchy.rootSessionId); + await openAgentOrgOverviewPanel( + `Agent Org delete ${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", + } + ); - await chooseDeleteFromRenderedSidebarMenu(hierarchy.rootSessionId); + const archivedSnapshot = await persistenceSnapshot( + [hierarchy.rootSessionId, ...hierarchy.workerSessionIds], + [hierarchy.runId] + ); + const detail = archivedSnapshot.run_details[hierarchy.runId]; + if ( + detail?.status !== "archived" || + detail?.activation_generation < 2 || + !detail?.archived_at || + !detail?.archive_receipt_id + ) { + throw new Error( + `Archive fence/receipt missing: ${JSON.stringify(archivedSnapshot)}` + ); + } + + 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 (await browser.$('div[role="dialog"] input[type="checkbox"]')).click(); + await ( + await browser.$('[data-testid="agent-org-delete-confirm-button"]') + ).click(); const rootSelector = `[data-testid="sidebar-session-item-${hierarchy.rootSessionId}"]`; await browser.waitUntil( @@ -168,23 +227,39 @@ 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)" + ); + 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 +292,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 +303,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", From 73f1bb10d709d3165f58c8ae07645e7553bdb93f Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sun, 23 Aug 2026 21:17:59 +0800 Subject: [PATCH 5/8] fix(agent-org): harden archived lifecycle cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packaged-app testing exposed two terminal-boundary gaps: late session callbacks could still reach persistence after Archive, and Team Delete removed sidebar ownership without closing cached Team tabs or unregistering session marker files. Route late callbacks through the Archived fence, keep the destructive confirmation modal stable, close every deleted Root/Member tab atomically, select one safe fallback view, and unregister each committed deletion from the file registry. Verification: - pnpm test — 1,116 files and 8,776 tests passed - RUST_TEST_THREADS=1 pnpm run cargo:test — 1,113 passed and 1 ignored - cargo test -p agent_core — 3,197 passed and 2 ignored - cargo clippy -p agent_core --all-targets -- -D warnings — passed - rendered Archive/Delete E2E — 3 frozen-code runs and 9 UI paths passed - BuildFast packaged-app Computer Use — real Provider Archive/Delete and restart passed Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../src/core/session/persistence/crud/ops.rs | 5 + .../turn/processor/post_turn_dispatch.rs | 49 ++++- .../src/specialization/memory/background.rs | 181 +++++++++++++++++- .../state/commands/session/message/send.rs | 42 +++- .../commands/session/org_tasks/lifecycle.rs | 86 +++++---- .../commands/session/persistence_tests.rs | 22 +++ .../useChatNavigationController.test.ts | 33 +++- .../hooks/useChatNavigationController.ts | 32 ++-- .../components/AgentOrgOverviewPanel.tsx | 23 ++- .../components/AgentOrgTaskPanel.test.ts | 8 +- .../rustSessionDeleteReceipt.test.ts | 28 ++- .../connectors/rustSessionDeleteReceipt.ts | 11 +- .../useWorkstationSidebarHandlers.ts | 14 +- .../__tests__/chatPanelTabsAtom.test.ts | 84 ++++++++ .../chatPanel/chatPanelTabLifecycleAtoms.ts | 61 ++++++ src/store/chatPanel/chatPanelTabsAtom.ts | 1 + .../core/agent-org-session-delete-ui.spec.mjs | 82 +++++++- 17 files changed, 685 insertions(+), 77 deletions(-) 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/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/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/org_tasks/lifecycle.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/lifecycle.rs index 7513e70451..ce58195f68 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 @@ -349,42 +349,70 @@ async fn teardown_archive_target( target: ArchiveTeardownTarget, round_timeout: Duration, ) -> Result<(), String> { - let Some(session) = state.get_session(&target.session_id).await else { - return persist_archive_teardown_attempt(target, None, None, true, None).await; - }; - session.cancel_active_turn(CancelReason::OrgArchive).await; - let Some(captured) = session.runtime_lease_identity().await else { - return persist_archive_teardown_attempt(target, None, None, true, None).await; + // 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; + } + let captured = match session.as_ref() { + Some(session) => session.runtime_lease_identity().await, + None => None, }; - let lease_id = captured.runtime_lease_id.clone(); - let turn_generation = captured.dialog_turn_generation.clone(); + 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 { - loop { - let current = session.runtime_lease_identity().await; - match current { - None => return Ok::(true), - Some(current) if current.runtime_lease_id != 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(&lease_id).await); + 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, } - 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 (runtime_result, ()) = tokio::join!(runtime_release, memory_idle); + runtime_result }) .await; match released { Ok(Ok(true)) => { - persist_archive_teardown_attempt(target, Some(lease_id), turn_generation, true, None) - .await + persist_archive_teardown_attempt(target, lease_id, turn_generation, true, None).await } Ok(Ok(false)) => { persist_archive_teardown_attempt( target, - Some(lease_id), + lease_id, turn_generation, false, Some("archive_runtime_release_stale".to_string()), @@ -392,22 +420,16 @@ async fn teardown_archive_target( .await } Ok(Err(error)) => { - persist_archive_teardown_attempt( - target, - Some(lease_id), - turn_generation, - false, - Some(error), - ) - .await + persist_archive_teardown_attempt(target, lease_id, turn_generation, false, Some(error)) + .await } Err(_) => { persist_archive_teardown_attempt( target, - Some(lease_id), + lease_id, turn_generation, false, - Some("archive_runtime_stop_timeout".to_string()), + Some("archive_runtime_or_background_stop_timeout".to_string()), ) .await } 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 f5423486b9..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 @@ -170,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") @@ -199,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"); @@ -255,6 +271,12 @@ 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] 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/InputArea/components/AgentOrgOverviewPanel.tsx b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx index a2e53dce56..2c35d23603 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx @@ -41,6 +41,7 @@ import { } from "@src/icons"; 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, @@ -141,6 +142,7 @@ const AgentOrgOverviewPanel: React.FC = memo( const disposeWorkstationWorkspace = useSetAtom( disposeWorkstationWorkspaceAtom ); + const closeSessionChatPanelTabs = useSetAtom(closeSessionChatPanelTabsAtom); const loadHistoryPage = useCallback( async ( @@ -343,12 +345,15 @@ const AgentOrgOverviewPanel: React.FC = memo( evictEventStore: (deletedSessionId: string) => eventStoreProxy.evictSession(deletedSessionId), }; - await applyRustSessionDeleteReceipt({ + const requiresNavigationReset = await applyRustSessionDeleteReceipt({ requestedSessionId: currentSessionId, activeSessionId: currentSessionId, isAgentOrgRoot: view?.context.rootSessionId === currentSessionId, receipt, - cleanup, + cleanup: { + ...cleanup, + closeSessionTabs: closeSessionChatPanelTabs, + }, }); cleanup.removeSession(currentSessionId); cleanup.removeForkRelayEntry(currentSessionId); @@ -356,11 +361,11 @@ const AgentOrgOverviewPanel: React.FC = memo( cleanup.clearPendingFileOpens(currentSessionId); cleanup.clearPendingCodeEditorTab(currentSessionId); setDeleteModalOpen(false); - // Team Delete removes the current Root and every Member. Use the same - // complete navigation reset as ordinary Session Delete so the - // WorkStation active-session projection cannot repopulate a deleted - // Root after this component unmounts. - goToNewSession(); + // 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( @@ -373,6 +378,7 @@ const AgentOrgOverviewPanel: React.FC = memo( } }, [ currentSessionId, + closeSessionChatPanelTabs, deleteConfirmed, disposeWorkstationWorkspace, goToNewSession, @@ -915,6 +921,9 @@ const AgentOrgOverviewPanel: React.FC = memo( title={t("planner.agentOrgOverview.deleteTitle", { defaultValue: "Permanently delete this Team?", })} + // Modal portals to document.body; keep it inside Overview's + // document-level outside-click boundary for real pointer events. + className="agent-org-overview-owned-overlay" width={420} maskClosable={!isDeleting} closable={!isDeleting} diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts index a99234aa03..5fdf8404bf 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts @@ -41,7 +41,10 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock("jotai", () => ({ useSetAtom: () => vi.fn() })); +vi.mock("jotai", async (importOriginal) => ({ + ...(await importOriginal()), + useSetAtom: () => vi.fn(), +})); vi.mock("@src/store/session", () => ({ activeSessionIdAtom: {}, removeSession: mocks.removeSession, @@ -635,6 +638,7 @@ describe("Agent Org Task panel", () => { mocks.deleteTeam.mockResolvedValue({ deletedSessionIds: ["member-session", "root-session"], }); + mocks.applyDeleteReceipt.mockResolvedValue(false); const view: AgentOrgRunView = { ...runView(), runStatus: "archived", @@ -697,7 +701,7 @@ describe("Agent Org Task panel", () => { }, }) ); - expect(mocks.goToNewSession).toHaveBeenCalledOnce(); + expect(mocks.goToNewSession).not.toHaveBeenCalled(); }); it("discards a late History response after switching teams", async () => { 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/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..e393d41c43 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,88 @@ 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([safeTabId]); + expect(state.tabs.some((tab) => tab.id === rootTabId)).toBe(false); + expect(state.activeTabId).toBe(safeTabId); + expect(store.get(activeSessionIdAtom)).toBe("safe-session"); + }); + + 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-session-delete-ui.spec.mjs b/tests/e2e/specs/core/agent-org-session-delete-ui.spec.mjs index 21618a6321..6f234b8481 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 @@ -124,6 +124,67 @@ 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); @@ -200,7 +261,7 @@ async function deleteHierarchyAndAssertGone(hierarchy) { await ( await browser.$('[data-testid="agent-org-overview-delete-button"]') ).click(); - await (await browser.$('div[role="dialog"] input[type="checkbox"]')).click(); + await acknowledgePermanentDeleteLikeUser(); await ( await browser.$('[data-testid="agent-org-delete-confirm-button"]') ).click(); @@ -217,6 +278,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] From cc24c92bca5f5d326eb67c921908882798927172 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Mon, 24 Aug 2026 17:10:32 +0800 Subject: [PATCH 6/8] fix(agent-org): persist final reply after task completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskExecution assistant persistence reused the execution-admission gate, so a Turn that completed its own Task lost its final reply because terminal Tasks are not runnable. Split the phase-specific validation while preserving the shared live Turn authority and Archive transaction fence. Allow completed or failed Tasks only when the latest terminal event belongs to the exact owner and Turn; keep cancellation, reassignment, stale generations, and new execution admission closed. Verification: - cargo test -p agent_core — 3,215 passed, 2 ignored - cargo clippy -p agent_core --all-targets -- -D warnings — passed - pnpm test — 1,116 files and 8,777 tests passed Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../coordination/agent_org_turn_contexts.rs | 145 +++++++++- .../agent_org_turn_contexts/tests.rs | 268 ++++++++++++++++++ .../src/core/session/persistence/messages.rs | 193 ++++++++++++- 3 files changed, 604 insertions(+), 2 deletions(-) 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 6e025ea5a5..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 @@ -566,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( @@ -574,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( @@ -585,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. @@ -900,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( 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/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index ba0b7ce320..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 @@ -348,7 +348,7 @@ pub fn save_agent_org_assistant_msg_for_turn( let tx = conn .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|error| error.to_string())?; - crate::coordination::agent_org_turn_contexts::revalidate_context_with_connection( + crate::coordination::agent_org_turn_contexts::revalidate_assistant_persistence_with_connection( &tx, session_id, turn_intent_id, @@ -1244,6 +1244,197 @@ mod tests { 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(); From 170ac3af50cc4e22fda5648c6014b47c177038a5 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Tue, 25 Aug 2026 09:48:38 +0800 Subject: [PATCH 7/8] fix(agent-core): drain archived background jobs before delete Archive receipts could become quiesced after a job was marked killed even though its process tree, replay pipeline, or subagent task was still executing. Team Delete also left retained background-job registry state after the durable hierarchy was removed. Add exact Session indexes and execution-finality barriers, make Archive wait for runtime, memory, and background owners, and make Delete fail closed before commit and purge only after a successful transaction. Add deterministic race coverage, rendered lifecycle coverage, and read-only WebDriver evidence. Verification: - cargo test -p agent_core: 3230 passed, 2 ignored - cargo clippy -p agent_core --all-targets -- -D warnings: passed - cargo fmt --all -- --check: passed - pnpm test: 8777 passed - pnpm run lint: passed with 0 errors and 5 pre-existing warnings - pnpm run check:circular: blocked by the existing Madge raw-import resolver failure - WEBDRIVER=1 pnpm run tauri:build:fast: passed - BuildFast real-provider Archive x3, Pause/Resume, Task smoke, restart, and UI Delete: passed Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../core/coordination/agent_org_archive.rs | 12 + .../core/tools/impls/coding/exec/registry.rs | 139 +++-- .../coding/exec/registry/session_lifecycle.rs | 553 ++++++++++++++++++ .../core/tools/tests/job_registry_tests.rs | 177 ++++++ .../commands/session/org_tasks/lifecycle.rs | 45 +- .../src/state/commands/session/persistence.rs | 414 +++++-------- src-tauri/src/api/agent/mod.rs | 4 + src-tauri/src/api/agent/test/agent_org.rs | 159 ++++- .../components/AgentOrgOverviewPanel.tsx | 2 +- .../components/AgentOrgTaskPanel.test.ts | 6 +- .../__tests__/chatPanelTabsAtom.test.ts | 9 +- .../core/agent-org-pause-resume-live.spec.mjs | 200 ++++++- .../core/agent-org-session-delete-ui.spec.mjs | 80 ++- 13 files changed, 1435 insertions(+), 365 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry/session_lifecycle.rs 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 index 7cecbbfe8e..6186bada4d 100644 --- 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 @@ -544,6 +544,18 @@ pub fn summary_for_run(run_id: &str) -> Result, S 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, 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/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/state/commands/session/org_tasks/lifecycle.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/lifecycle.rs index ce58195f68..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 @@ -366,6 +366,7 @@ async fn teardown_archive_target( 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, @@ -400,8 +401,23 @@ async fn teardown_archive_target( }; let memory_idle = crate::memory::background::wait_for_memory_jobs_for_session_idle(&target.session_id); - let (runtime_result, ()) = tokio::join!(runtime_release, memory_idle); - runtime_result + 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; @@ -424,14 +440,23 @@ async fn teardown_archive_target( .await } Err(_) => { - persist_archive_teardown_attempt( - target, - lease_id, - turn_generation, - false, - Some("archive_runtime_or_background_stop_timeout".to_string()), - ) - .await + 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 } } } 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/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 86a0f2a128..02cc88f3ef 100644 --- a/src-tauri/src/api/agent/test/agent_org.rs +++ b/src-tauri/src/api/agent/test/agent_org.rs @@ -3085,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 @@ -3246,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/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx index 2c35d23603..adc3ede9ea 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx @@ -965,7 +965,7 @@ const AgentOrgOverviewPanel: React.FC = memo( {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 5fdf8404bf..bdbd4b554c 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts @@ -112,12 +112,12 @@ vi.mock("@src/components/Checkbox", () => ({ default: ({ checked, disabled, - onChange, + onCheckedChange, children, }: { checked?: boolean; disabled?: boolean; - onChange?: (checked: boolean) => void; + onCheckedChange?: (checked: boolean) => void; children?: React.ReactNode; }) => createElement( @@ -128,7 +128,7 @@ vi.mock("@src/components/Checkbox", () => ({ checked, disabled, onChange: (event: React.ChangeEvent) => - onChange?.(event.target.checked), + onCheckedChange?.(event.target.checked), }), children ), diff --git a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts index e393d41c43..ae657b522d 100644 --- a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts +++ b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts @@ -403,10 +403,13 @@ describe("closeSessionChatPanelTabsAtom", () => { const state = store.get(chatPanelTabsAtom); expect(activeTabClosed).toBe(true); - expect(state.tabs.map((tab) => tab.id)).toEqual([safeTabId]); + 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(safeTabId); - expect(store.get(activeSessionIdAtom)).toBe("safe-session"); + expect(state.activeTabId).toBe("launchpad-default"); + expect(store.get(activeSessionIdAtom)).toBeNull(); }); it("preserves the active tab when only background session tabs were deleted", async () => { 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 6f234b8481..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 @@ -8,6 +8,7 @@ import { 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(); @@ -105,6 +106,25 @@ async function refreshAndWaitForSidebarRow(sessionId) { } ); 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 () => @@ -112,7 +132,7 @@ 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`, } ); } @@ -231,21 +251,47 @@ async function deleteHierarchyAndAssertGone(hierarchy) { } ); - const archivedSnapshot = await persistenceSnapshot( - [hierarchy.rootSessionId, ...hierarchy.workerSessionIds], - [hierarchy.runId] + 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", + } ); - const detail = archivedSnapshot.run_details[hierarchy.runId]; - if ( - detail?.status !== "archived" || - detail?.activation_generation < 2 || - !detail?.archived_at || - !detail?.archive_receipt_id - ) { - throw new Error( - `Archive fence/receipt missing: ${JSON.stringify(archivedSnapshot)}` - ); - } await browser.waitUntil( async () => @@ -324,6 +370,10 @@ describe("Agent Org irreversible Archive and Team Delete rendered UI", () => { 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 () => From 2ad7731d35772f904efab8da2faf47a8fd8687c5 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Wed, 2 Sep 2026 00:42:56 +0800 Subject: [PATCH 8/8] style(agent-org): align archived composer with tailwind v4 --- src/engines/ChatPanel/AgentOrgArchivedComposer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engines/ChatPanel/AgentOrgArchivedComposer.tsx b/src/engines/ChatPanel/AgentOrgArchivedComposer.tsx index 26c68605de..e55f412866 100644 --- a/src/engines/ChatPanel/AgentOrgArchivedComposer.tsx +++ b/src/engines/ChatPanel/AgentOrgArchivedComposer.tsx @@ -16,12 +16,12 @@ const AgentOrgArchivedComposer: React.FC = memo( return (