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 aa266e7e16..81298361d0 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 @@ -8,37 +8,6 @@ use super::{ AgentOrgRunStatus, DEFAULT_COORDINATOR_DISPLAY_NAME, }; -/// Single-column lookup for the parent of `session_id` in persisted runtime -/// session tables. Used by `context_for_session_with_parent_walk` to avoid -/// pulling full session rows on every hop — the walk only needs the -/// `parent_session_id` string. -/// -/// Returns `Ok(None)` for both "session does not exist" and "session exists -/// but has no parent". Both cases terminate the walk identically; -/// distinguishing them would not change the resolver outcome. -pub(super) fn parent_session_id_of(session_id: &str) -> SqliteResult> { - let conn = get_connection()?; - let parent = conn - .query_row( - "SELECT parent_session_id FROM agent_sessions WHERE session_id = ?1", - params![session_id], - |row| row.get::<_, Option>(0), - ) - .optional()? - .flatten(); - if parent.is_some() { - return Ok(parent); - } - - conn.query_row( - "SELECT parent_session_id FROM code_sessions WHERE session_id = ?1", - params![session_id], - |row| row.get::<_, Option>(0), - ) - .optional() - .map(|outer| outer.flatten()) -} - pub(super) fn load_by_id(run_id: &str) -> SqliteResult> { let conn = get_connection()?; conn.query_row( @@ -56,7 +25,9 @@ pub(super) fn load_by_id(run_id: &str) -> SqliteResult last_error, created_at, updated_at, - completed_at + completed_at, + continued_from_run_id, + originating_message_id FROM agent_org_runs WHERE id = ?1 LIMIT 1", @@ -66,36 +37,6 @@ pub(super) fn load_by_id(run_id: &str) -> SqliteResult .optional() } -pub(super) fn load_by_root_session( - root_session_id: &str, -) -> SqliteResult> { - let conn = get_connection()?; - conn.query_row( - "SELECT 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, - completed_at - FROM agent_org_runs - WHERE root_session_id = ?1 - ORDER BY created_at DESC - LIMIT 1", - params![root_session_id], - row_to_run, - ) - .optional() -} - pub(super) fn row_to_run(row: &rusqlite::Row<'_>) -> SqliteResult { let entry_mode_raw: String = row.get(5)?; let status_raw: String = row.get(6)?; @@ -129,6 +70,8 @@ pub(super) fn row_to_run(row: &rusqlite::Row<'_>) -> SqliteResult SqliteRe last_error, created_at, updated_at, - completed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + completed_at, + continued_from_run_id, + originating_message_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", params![ &run.id, &run.org_id, @@ -229,6 +174,8 @@ pub(super) fn insert_run(conn: &Connection, run: &AgentOrgRunRecord) -> SqliteRe &run.created_at, &run.updated_at, run.completed_at.as_deref(), + run.continued_from_run_id.as_deref(), + run.originating_message_id.as_deref(), ], )?; Ok(()) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/migration.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/migration.rs new file mode 100644 index 0000000000..05d98223d2 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/migration.rs @@ -0,0 +1,664 @@ +use std::collections::HashMap; + +use crate::definitions::orgs::OrgDefinition; +use rusqlite::{params, Connection, OptionalExtension, Result as SqliteResult}; + +use super::helpers::flatten_members; +use super::{AgentOrgRunSessionRole, COORDINATOR_MEMBER_ID}; + +const MAX_ANOMALY_SAMPLES: usize = 20; +const MAX_IDS_PER_ANOMALY: usize = 5; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct AgentOrgRunMigrationReport { + pub(super) coordinator_mappings_backfilled: usize, + pub(super) coordinator_mapping_conflicts: usize, + pub(super) worker_mappings_backfilled: usize, + pub(super) ambiguous_worker_sessions: usize, + pub(super) unsupported_cli_sessions: usize, + pub(super) anomaly_samples: Vec, +} + +impl AgentOrgRunMigrationReport { + pub(crate) fn log(&self) { + if self.coordinator_mapping_conflicts == 0 + && self.ambiguous_worker_sessions == 0 + && self.unsupported_cli_sessions == 0 + && self.anomaly_samples.is_empty() + { + tracing::debug!( + coordinator_mappings_backfilled = self.coordinator_mappings_backfilled, + worker_mappings_backfilled = self.worker_mappings_backfilled, + "[agent_org_runs] exact ownership migration complete" + ); + return; + } + tracing::warn!( + coordinator_mappings_backfilled = self.coordinator_mappings_backfilled, + coordinator_mapping_conflicts = self.coordinator_mapping_conflicts, + worker_mappings_backfilled = self.worker_mappings_backfilled, + ambiguous_worker_sessions = self.ambiguous_worker_sessions, + unsupported_cli_sessions = self.unsupported_cli_sessions, + anomaly_samples = ?self.anomaly_samples, + "[agent_org_runs] exact ownership migration left unsupported or ambiguous legacy rows unmapped" + ); + } + + fn record_anomaly(&mut self, message: String) { + if self.anomaly_samples.len() < MAX_ANOMALY_SAMPLES { + self.anomaly_samples.push(message); + } + } +} + +pub(super) fn init_schema(conn: &Connection) -> SqliteResult { + let tx = conn.unchecked_transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS agent_org_runs ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + coordinator_agent_id TEXT NOT NULL, + root_session_id TEXT, + org_snapshot_json TEXT, + entry_mode TEXT NOT NULL, + status TEXT NOT NULL, + work_item_id TEXT, + project_slug TEXT, + routine_fire_id TEXT, + summary TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + continued_from_run_id TEXT REFERENCES agent_org_runs(id) ON DELETE SET NULL, + originating_message_id TEXT + );", + )?; + add_column_if_missing( + &tx, + "agent_org_runs", + "continued_from_run_id", + "TEXT REFERENCES agent_org_runs(id) ON DELETE SET NULL", + )?; + add_column_if_missing(&tx, "agent_org_runs", "originating_message_id", "TEXT")?; + + let (duplicate_live_root_count, duplicate_live_roots) = duplicate_live_root_samples(&tx)?; + if !duplicate_live_roots.is_empty() { + return Err(migration_error(format!( + "duplicate live Agent Org runs detected for {duplicate_live_root_count} roots; no ownership migration was applied: {}", + duplicate_live_roots.join(", ") + ))); + } + + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS agent_org_run_sessions ( + org_run_id TEXT NOT NULL REFERENCES agent_org_runs(id) ON DELETE CASCADE, + member_id TEXT NOT NULL, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('coordinator', 'worker')), + created_at TEXT NOT NULL, + PRIMARY KEY (org_run_id, member_id), + UNIQUE (org_run_id, session_id), + CHECK ( + (role='coordinator' AND member_id='coordinator') OR + (role='worker' AND member_id<>'coordinator') + ) + ); + CREATE INDEX IF NOT EXISTS idx_agent_org_runs_org_updated + ON agent_org_runs(org_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_agent_org_runs_root_session + ON agent_org_runs(root_session_id); + CREATE INDEX IF NOT EXISTS idx_agent_org_runs_work_item + ON agent_org_runs(work_item_id); + CREATE INDEX IF NOT EXISTS idx_agent_org_runs_status + ON agent_org_runs(status); + CREATE INDEX IF NOT EXISTS idx_agent_org_runs_root_timeline + ON agent_org_runs(root_session_id, created_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_agent_org_runs_root_updated + ON agent_org_runs(root_session_id, updated_at DESC, id DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_org_runs_root_originating_message + ON agent_org_runs(root_session_id, originating_message_id) + WHERE root_session_id IS NOT NULL AND originating_message_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_org_runs_one_live_per_root + ON agent_org_runs(root_session_id) + WHERE root_session_id IS NOT NULL + AND status IN ('starting', 'running', 'paused'); + CREATE INDEX IF NOT EXISTS idx_agent_org_run_sessions_session + ON agent_org_run_sessions(session_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_org_run_sessions_worker_session + ON agent_org_run_sessions(session_id) + WHERE role='worker'; + CREATE TRIGGER IF NOT EXISTS trg_agent_org_run_sessions_insert_role_collision + BEFORE INSERT ON agent_org_run_sessions + WHEN EXISTS ( + SELECT 1 FROM agent_org_run_sessions existing + WHERE existing.session_id=NEW.session_id + AND (existing.role='worker' OR NEW.role='worker') + ) + BEGIN + SELECT RAISE(ABORT, 'Agent Org worker session already has a run ownership'); + END; + CREATE TRIGGER IF NOT EXISTS trg_agent_org_run_sessions_update_role_collision + BEFORE UPDATE OF session_id, role ON agent_org_run_sessions + WHEN EXISTS ( + SELECT 1 FROM agent_org_run_sessions existing + WHERE existing.session_id=NEW.session_id + AND (existing.org_run_id<>OLD.org_run_id OR existing.member_id<>OLD.member_id) + AND (existing.role='worker' OR NEW.role='worker') + ) + BEGIN + SELECT RAISE(ABORT, 'Agent Org worker session already has a run ownership'); + END;", + )?; + + let report = backfill_ownership(&tx)?; + tx.commit()?; + Ok(report) +} + +fn table_exists(conn: &Connection, table: &str) -> SqliteResult { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + ) +} + +fn column_exists(conn: &Connection, table: &str, column: &str) -> SqliteResult { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + for row in rows { + if row? == column { + return Ok(true); + } + } + Ok(false) +} + +fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + declaration: &str, +) -> SqliteResult<()> { + if !column_exists(conn, table, column)? { + conn.execute_batch(&format!( + "ALTER TABLE {table} ADD COLUMN {column} {declaration};" + ))?; + } + Ok(()) +} + +fn duplicate_live_root_samples(conn: &Connection) -> SqliteResult<(i64, Vec)> { + let total = conn.query_row( + "SELECT COUNT(*) FROM ( + SELECT root_session_id FROM agent_org_runs + WHERE root_session_id IS NOT NULL + AND status IN ('starting', 'running', 'paused') + GROUP BY root_session_id HAVING COUNT(*) > 1 + )", + [], + |row| row.get(0), + )?; + let mut stmt = conn.prepare( + "SELECT root_session_id, COUNT(*) + FROM agent_org_runs + WHERE root_session_id IS NOT NULL + AND status IN ('starting', 'running', 'paused') + GROUP BY root_session_id + HAVING COUNT(*) > 1 + ORDER BY root_session_id + LIMIT ?1", + )?; + let roots = stmt + .query_map([MAX_ANOMALY_SAMPLES as i64], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })? + .collect::>>()?; + let mut samples = Vec::with_capacity(roots.len()); + for (root, live_count) in roots { + let mut ids_stmt = conn.prepare( + "SELECT id FROM agent_org_runs + WHERE root_session_id=?1 AND status IN ('starting', 'running', 'paused') + ORDER BY id LIMIT ?2", + )?; + let ids = ids_stmt + .query_map(params![&root, MAX_IDS_PER_ANOMALY as i64], |row| { + row.get::<_, String>(0) + })? + .collect::>>()?; + samples.push(format!( + "root={root} live_count={live_count} run_ids_sample=[{}]", + ids.join(",") + )); + } + Ok((total, samples)) +} + +fn migration_error(message: String) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + std::io::Error::new(std::io::ErrorKind::InvalidData, message).into(), + ) +} + +fn backfill_ownership(conn: &Connection) -> SqliteResult { + let mut report = AgentOrgRunMigrationReport::default(); + backfill_coordinators(conn, &mut report)?; + backfill_rust_workers(conn, &mut report)?; + report_unsupported_cli_sessions(conn, &mut report)?; + Ok(report) +} + +fn backfill_coordinators( + conn: &Connection, + report: &mut AgentOrgRunMigrationReport, +) -> SqliteResult<()> { + let mut stmt = conn.prepare( + "SELECT id, root_session_id, created_at + FROM agent_org_runs + WHERE root_session_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM agent_org_run_sessions mapping + WHERE mapping.org_run_id=agent_org_runs.id + AND mapping.member_id='coordinator' + AND mapping.session_id=agent_org_runs.root_session_id + AND mapping.role='coordinator' + ) + ORDER BY created_at, id", + )?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()?; + drop(stmt); + + for (run_id, session_id, created_at) in rows { + let existing: Option<(String, String)> = conn + .query_row( + "SELECT session_id, role FROM agent_org_run_sessions + WHERE org_run_id=?1 AND member_id=?2", + params![&run_id, COORDINATOR_MEMBER_ID], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((existing_session_id, role)) = existing { + if existing_session_id != session_id + || role != AgentOrgRunSessionRole::Coordinator.as_str() + { + report.coordinator_mapping_conflicts += 1; + report.record_anomaly(format!( + "coordinator mapping conflict run={run_id} expected_session={session_id} existing_session={existing_session_id} role={role}" + )); + } + continue; + } + let conflicting_worker: bool = conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM agent_org_run_sessions + WHERE session_id=?1 AND role='worker' + )", + [&session_id], + |row| row.get(0), + )?; + if conflicting_worker { + report.coordinator_mapping_conflicts += 1; + report.record_anomaly(format!( + "coordinator session is already owned as worker run={run_id} session={session_id}" + )); + continue; + } + conn.execute( + "INSERT INTO agent_org_run_sessions ( + org_run_id, member_id, session_id, role, created_at + ) VALUES (?1, ?2, ?3, 'coordinator', ?4)", + params![run_id, COORDINATOR_MEMBER_ID, session_id, created_at], + )?; + report.coordinator_mappings_backfilled += 1; + } + Ok(()) +} + +#[derive(Debug)] +struct RustWorkerCandidate { + session_id: String, + member_id: String, + agent_definition_id: String, + root_session_id: String, + created_at: String, +} + +fn backfill_rust_workers( + conn: &Connection, + report: &mut AgentOrgRunMigrationReport, +) -> SqliteResult<()> { + if !table_exists(conn, "agent_sessions")? + || !column_exists(conn, "agent_sessions", "org_member_id")? + || !column_exists(conn, "agent_sessions", "parent_session_id")? + || !column_exists(conn, "agent_sessions", "agent_definition_id")? + { + return Ok(()); + } + + let mut stmt = conn.prepare( + "SELECT session_id, org_member_id, agent_definition_id, parent_session_id, created_at + FROM agent_sessions + WHERE org_member_id IS NOT NULL + AND org_member_id<>'coordinator' + AND parent_session_id IS NOT NULL + AND agent_definition_id IS NOT NULL + AND parent_session_id IN ( + SELECT root_session_id FROM agent_org_runs + WHERE root_session_id IS NOT NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM agent_org_run_sessions mapping + JOIN agent_org_runs mapped_run ON mapped_run.id=mapping.org_run_id + WHERE mapping.session_id=agent_sessions.session_id + AND mapping.member_id=agent_sessions.org_member_id + AND mapping.role='worker' + AND mapped_run.root_session_id=agent_sessions.parent_session_id + ) + ORDER BY created_at, session_id", + )?; + let rows = stmt + .query_map([], |row| { + Ok(RustWorkerCandidate { + session_id: row.get(0)?, + member_id: row.get(1)?, + agent_definition_id: row.get(2)?, + root_session_id: row.get(3)?, + created_at: row.get(4)?, + }) + })? + .collect::>>()?; + drop(stmt); + + let has_turn_intents = table_exists(conn, "session_turn_intents")? + && column_exists(conn, "session_turn_intents", "org_run_id")?; + let mut grouped: HashMap<(String, String), Vec> = HashMap::new(); + + for candidate in rows { + let turn_run_ids = if has_turn_intents { + let mut intent_stmt = conn.prepare( + "SELECT DISTINCT org_run_id + FROM session_turn_intents + WHERE session_id=?1 + AND org_run_id IS NOT NULL + AND status NOT IN ('stale', 'coalesced', 'rejected') + ORDER BY org_run_id + LIMIT ?2", + )?; + let run_ids = intent_stmt + .query_map( + params![&candidate.session_id, (MAX_IDS_PER_ANOMALY + 1) as i64], + |row| row.get::<_, String>(0), + )? + .collect::>>()?; + run_ids + } else { + Vec::new() + }; + + let run_id = match turn_run_ids.as_slice() { + [run_id] => { + let run_root: Option = conn + .query_row( + "SELECT root_session_id FROM agent_org_runs WHERE id=?1", + [run_id], + |row| row.get(0), + ) + .optional()? + .flatten(); + if run_root.as_deref() != Some(candidate.root_session_id.as_str()) { + report.ambiguous_worker_sessions += 1; + report.record_anomaly(format!( + "turn intent run/root mismatch session={} member={} run={} parent_root={}", + candidate.session_id, + candidate.member_id, + run_id, + candidate.root_session_id + )); + continue; + } + run_id.clone() + } + [] => { + let mut run_stmt = conn.prepare( + "SELECT id FROM agent_org_runs + WHERE root_session_id=?1 + ORDER BY created_at, id + LIMIT 2", + )?; + let run_ids = run_stmt + .query_map([&candidate.root_session_id], |row| row.get::<_, String>(0))? + .collect::>>()?; + if run_ids.len() != 1 { + report.ambiguous_worker_sessions += 1; + report.record_anomaly(format!( + "worker has no exact intent and root has {} runs session={} member={} root={}", + run_ids.len(), + candidate.session_id, + candidate.member_id, + candidate.root_session_id + )); + continue; + } + run_ids[0].clone() + } + _ => { + report.ambiguous_worker_sessions += 1; + let truncated = turn_run_ids.len() > MAX_IDS_PER_ANOMALY; + report.record_anomaly(format!( + "worker turn intents disagree session={} member={} run_ids_sample=[{}] truncated={truncated}", + candidate.session_id, + candidate.member_id, + turn_run_ids[..turn_run_ids.len().min(MAX_IDS_PER_ANOMALY)].join(",") + )); + continue; + } + }; + + match member_belongs_to_snapshot( + conn, + &run_id, + &candidate.member_id, + &candidate.agent_definition_id, + )? { + SnapshotMemberCheck::Allowed => {} + SnapshotMemberCheck::MissingMember => { + report.ambiguous_worker_sessions += 1; + report.record_anomaly(format!( + "worker member is absent from run snapshot session={} member={} run={}", + candidate.session_id, candidate.member_id, run_id + )); + continue; + } + SnapshotMemberCheck::MismatchedAgent { expected_agent_id } => { + report.ambiguous_worker_sessions += 1; + report.record_anomaly(format!( + "worker agent contradicts run snapshot session={} member={} run={} persisted_agent={} expected_agent={expected_agent_id}", + candidate.session_id, + candidate.member_id, + run_id, + candidate.agent_definition_id + )); + continue; + } + SnapshotMemberCheck::Malformed(error) => { + report.ambiguous_worker_sessions += 1; + report.record_anomaly(format!( + "run snapshot is malformed; worker left unmapped session={} member={} run={} error={error}", + candidate.session_id, candidate.member_id, run_id + )); + continue; + } + } + grouped + .entry((run_id, candidate.member_id.clone())) + .or_default() + .push(candidate); + } + + for ((run_id, member_id), candidates) in grouped { + if candidates.len() != 1 { + report.ambiguous_worker_sessions += candidates.len(); + report.record_anomaly(format!( + "multiple Rust worker sessions claim one run member run={run_id} member={member_id} total={} sessions_sample=[{}]", + candidates.len(), + candidates + .iter() + .take(MAX_IDS_PER_ANOMALY) + .map(|candidate| candidate.session_id.as_str()) + .collect::>() + .join(",") + )); + continue; + } + let candidate = &candidates[0]; + let existing: Option<(String, String)> = conn + .query_row( + "SELECT session_id, role FROM agent_org_run_sessions + WHERE org_run_id=?1 AND member_id=?2", + params![&run_id, &member_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((existing_session_id, role)) = existing { + if existing_session_id != candidate.session_id + || role != AgentOrgRunSessionRole::Worker.as_str() + { + report.ambiguous_worker_sessions += 1; + report.record_anomaly(format!( + "existing worker mapping conflicts run={run_id} member={member_id} expected_session={} existing_session={existing_session_id} role={role}", + candidate.session_id + )); + } + continue; + } + match conn.execute( + "INSERT INTO agent_org_run_sessions ( + org_run_id, member_id, session_id, role, created_at + ) VALUES (?1, ?2, ?3, 'worker', ?4)", + params![ + run_id, + member_id, + candidate.session_id, + candidate.created_at + ], + ) { + Ok(_) => report.worker_mappings_backfilled += 1, + Err(error) => { + report.ambiguous_worker_sessions += 1; + report.record_anomaly(format!( + "worker mapping constraint rejected run={} member={} session={}: {}", + run_id, member_id, candidate.session_id, error + )); + } + } + } + Ok(()) +} + +enum SnapshotMemberCheck { + Allowed, + MissingMember, + MismatchedAgent { expected_agent_id: String }, + Malformed(String), +} + +fn member_belongs_to_snapshot( + conn: &Connection, + run_id: &str, + member_id: &str, + agent_definition_id: &str, +) -> SqliteResult { + let snapshot_json: Option = conn + .query_row( + "SELECT org_snapshot_json FROM agent_org_runs WHERE id=?1", + [run_id], + |row| row.get(0), + ) + .optional()? + .flatten(); + let Some(snapshot_json) = snapshot_json else { + return Ok(SnapshotMemberCheck::Allowed); + }; + let snapshot: OrgDefinition = match serde_json::from_str(&snapshot_json) { + Ok(snapshot) => snapshot, + Err(error) => return Ok(SnapshotMemberCheck::Malformed(error.to_string())), + }; + let members = flatten_members(&snapshot.children, None); + let mut matches = members + .iter() + .filter(|member| member.member_id == member_id); + let Some(member) = matches.next() else { + return Ok(SnapshotMemberCheck::MissingMember); + }; + if matches.next().is_some() { + return Ok(SnapshotMemberCheck::Malformed(format!( + "duplicate member_id {member_id}" + ))); + } + if member.agent_id != agent_definition_id { + return Ok(SnapshotMemberCheck::MismatchedAgent { + expected_agent_id: member.agent_id.clone(), + }); + } + Ok(SnapshotMemberCheck::Allowed) +} + +fn report_unsupported_cli_sessions( + conn: &Connection, + report: &mut AgentOrgRunMigrationReport, +) -> SqliteResult<()> { + if !table_exists(conn, "code_sessions")? + || !column_exists(conn, "code_sessions", "org_member_id")? + || !column_exists(conn, "code_sessions", "parent_session_id")? + { + return Ok(()); + } + report.unsupported_cli_sessions = conn.query_row( + "SELECT COUNT(*) FROM code_sessions + WHERE org_member_id IS NOT NULL + AND ( + parent_session_id IN (SELECT root_session_id FROM agent_org_runs) + OR session_id IN (SELECT root_session_id FROM agent_org_runs) + )", + [], + |row| row.get(0), + )?; + if report.unsupported_cli_sessions == 0 { + return Ok(()); + } + let mut stmt = conn.prepare( + "SELECT session_id, org_member_id, COALESCE(parent_session_id, session_id) + FROM code_sessions + WHERE org_member_id IS NOT NULL + AND ( + parent_session_id IN (SELECT root_session_id FROM agent_org_runs) + OR session_id IN (SELECT root_session_id FROM agent_org_runs) + ) + ORDER BY session_id + LIMIT ?1", + )?; + let rows = stmt.query_map([MAX_ANOMALY_SAMPLES as i64], |row| { + Ok(format!( + "unsupported historical CLI Agent Org session={} member={} root={}", + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)? + )) + })?; + for row in rows { + report.record_anomaly(row?); + } + 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 5e2a0851a5..2896443e3d 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 @@ -5,6 +5,7 @@ mod finality; mod helpers; +mod migration; mod progress; mod store; mod worker; @@ -25,7 +26,7 @@ pub(crate) use worker::recovery_dispatch_recipient_is_available; pub use worker::{WorkerSessionInfo, WorkerSessionRuntime}; use rusqlite::{Connection, Result as SqliteResult}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::definitions::orgs::{HierarchyMode, OrgDefinition, PlanApprovalPolicy}; @@ -407,6 +408,8 @@ pub struct AgentOrgRunRecord { pub created_at: String, pub updated_at: String, pub completed_at: Option, + pub continued_from_run_id: Option, + pub originating_message_id: Option, } #[derive(Debug, Clone)] @@ -422,35 +425,117 @@ pub struct CreateAgentOrgRunParams { pub routine_fire_id: Option, } +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentOrgRunLineage { + pub continued_from_run_id: Option, + pub originating_message_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentOrgRunSessionRole { + Coordinator, + Worker, +} + +impl AgentOrgRunSessionRole { + pub fn as_str(self) -> &'static str { + match self { + Self::Coordinator => "coordinator", + Self::Worker => "worker", + } + } + + #[cfg(test)] + pub fn parse(value: &str) -> Option { + match value { + "coordinator" => Some(Self::Coordinator), + "worker" => Some(Self::Worker), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(test)] +pub(crate) struct AgentOrgRunSessionRecord { + pub org_run_id: String, + pub member_id: String, + pub session_id: String, + pub role: AgentOrgRunSessionRole, + pub created_at: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentOrgRunResolutionAccess { + ReadOnly, + Mutable, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentOrgRunResolution { + pub active_turn_run_id: Option, + pub selected_historical_run_id: Option, +} + +impl AgentOrgRunResolution { + pub(crate) fn mutable(active_turn_run_id: Option) -> Self { + Self { + active_turn_run_id, + selected_historical_run_id: None, + } + } + + #[cfg(test)] + pub(crate) fn read_only(selected_historical_run_id: Option) -> Self { + Self { + active_turn_run_id: None, + selected_historical_run_id, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentOrgRunTimelineCursor { + pub created_at: String, + pub id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentOrgRunTimelineItem { + pub id: String, + pub status: AgentOrgRunStatus, + pub summary: Option, + pub created_at: String, + pub updated_at: String, + pub completed_at: Option, + pub continued_from_run_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentOrgRunTimelinePage { + pub runs: Vec, + pub has_more: bool, + pub next_cursor: Option, +} + /// Initialize runtime Agent Org tables in `sessions.db`. pub fn init_schema(conn: &Connection) -> SqliteResult<()> { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS agent_org_runs ( - id TEXT PRIMARY KEY, - org_id TEXT NOT NULL, - coordinator_agent_id TEXT NOT NULL, - root_session_id TEXT, - org_snapshot_json TEXT, - entry_mode TEXT NOT NULL, - status TEXT NOT NULL, - work_item_id TEXT, - project_slug TEXT, - routine_fire_id TEXT, - summary TEXT, - last_error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_agent_org_runs_org_updated - ON agent_org_runs(org_id, updated_at); - CREATE INDEX IF NOT EXISTS idx_agent_org_runs_root_session - ON agent_org_runs(root_session_id); - CREATE INDEX IF NOT EXISTS idx_agent_org_runs_work_item - ON agent_org_runs(work_item_id); - CREATE INDEX IF NOT EXISTS idx_agent_org_runs_status - ON agent_org_runs(status);", - )?; + let report = migration::init_schema(conn)?; + report.log(); progress::init_schema(conn)?; Ok(()) } + +#[cfg(test)] +fn init_schema_with_report( + conn: &Connection, +) -> SqliteResult { + let report = migration::init_schema(conn)?; + progress::init_schema(conn)?; + Ok(report) +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store.rs index c50a58d3d0..b9a6eb4e40 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store.rs @@ -6,23 +6,30 @@ use crate::coordination::agent_member_interventions::AgentMemberInterventionStor use crate::coordination::agent_org_plan_approvals::AgentOrgPlanApprovalStore; use crate::coordination::agent_org_tasks::{AgentOrgTaskStore, Task, TaskStatus}; use crate::definitions::orgs::AgentOrgsStore; +use crate::session::persistence::{ + notify_session_upserted, upsert_session_with_connection, UnifiedSessionRecord, +}; use crate::session::SessionStatus; use database::db::{get_connection, with_sessions_writer}; use super::finality::load_and_assess; use super::helpers::{ - context_for_run_record, flatten_members, insert_run, load_by_id, load_by_root_session, - parent_session_id_of, row_to_run, validate_entry_mode, validate_status, + context_for_run_record, flatten_members, insert_run, load_by_id, row_to_run, + validate_entry_mode, validate_status, }; use super::progress::{ ensure_progress_in_conn, load_progress_with_conn, mark_coordinator_observed_revision_with_conn, record_completion_request_in_tx, stage_coordinator_presented_with_conn, }; use super::worker::{WorkerSessionInfo, WorkerSessionRuntime}; +#[cfg(test)] +use super::AgentOrgRunSessionRecord; use super::{ AgentOrgCompletionRequestOutcome, AgentOrgFinalityAssessment, AgentOrgRunContext, - AgentOrgRunProgress, AgentOrgRunRecord, AgentOrgRunStatus, CreateAgentOrgRunParams, - COORDINATOR_MEMBER_ID, + AgentOrgRunLineage, AgentOrgRunProgress, AgentOrgRunRecord, AgentOrgRunResolution, + AgentOrgRunResolutionAccess, AgentOrgRunSessionRole, AgentOrgRunStatus, + AgentOrgRunTimelineCursor, AgentOrgRunTimelineItem, AgentOrgRunTimelinePage, + CreateAgentOrgRunParams, COORDINATOR_MEMBER_ID, }; pub struct AgentOrgRunStore; @@ -50,15 +57,16 @@ impl AgentOrgRunStore { return Ok(Vec::new()); } let conn = get_connection().map_err(|err| err.to_string())?; - let placeholders = (1..=root_session_ids.len()) - .map(|index| format!("?{index}")) + let requested_values = (1..=root_session_ids.len()) + .map(|index| format!("(?{index})")) .collect::>() .join(", "); let sql = format!( - "SELECT id, + "WITH requested_roots(root_session_id) AS (VALUES {requested_values}) + SELECT candidate.id, org_id, coordinator_agent_id, - root_session_id, + candidate.root_session_id, org_snapshot_json, entry_mode, status, @@ -69,10 +77,18 @@ impl AgentOrgRunStore { last_error, created_at, updated_at, - completed_at - FROM agent_org_runs - WHERE root_session_id IN ({placeholders}) - ORDER BY updated_at DESC, id DESC" + completed_at, + continued_from_run_id, + originating_message_id + FROM requested_roots requested + JOIN agent_org_runs candidate ON candidate.id=( + SELECT latest.id + FROM agent_org_runs latest + WHERE latest.root_session_id=requested.root_session_id + ORDER BY latest.updated_at DESC, latest.id DESC + LIMIT 1 + ) + ORDER BY candidate.updated_at DESC, candidate.id DESC" ); let mut stmt = conn.prepare(&sql).map_err(|err| err.to_string())?; let rows = stmt @@ -86,6 +102,23 @@ impl AgentOrgRunStore { } pub fn create(params: CreateAgentOrgRunParams) -> Result { + Self::create_with_lineage(params, Default::default()) + } + + pub(crate) fn create_with_lineage( + params: CreateAgentOrgRunParams, + lineage: AgentOrgRunLineage, + ) -> Result { + let run = Self::new_run_record(params, lineage)?; + Self::persist_new_run(&run, None)?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run.id); + Ok(run) + } + + fn new_run_record( + params: CreateAgentOrgRunParams, + lineage: AgentOrgRunLineage, + ) -> Result { let entry_mode = validate_entry_mode(params.entry_mode.as_str())?; let status = validate_status(params.status.as_str())?; let org_snapshot_json = serde_json::to_string(¶ms.org_snapshot) @@ -107,21 +140,246 @@ impl AgentOrgRunStore { created_at: now.clone(), updated_at: now, completed_at: None, + continued_from_run_id: lineage.continued_from_run_id, + originating_message_id: lineage.originating_message_id, }; + Ok(run) + } + fn validate_lineage_with_connection( + conn: &Connection, + run: &AgentOrgRunRecord, + ) -> Result<(), String> { + let Some(continued_from_run_id) = run.continued_from_run_id.as_deref() else { + return Ok(()); + }; + let predecessor_root: Option> = conn + .query_row( + "SELECT root_session_id FROM agent_org_runs WHERE id=?1", + [continued_from_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| err.to_string())?; + let Some(predecessor_root) = predecessor_root else { + return Err(format!( + "continued_from_run_not_found: run {continued_from_run_id} does not exist" + )); + }; + if predecessor_root != run.root_session_id { + return Err(format!( + "continued_from_run_root_mismatch: run {continued_from_run_id} does not belong to root {:?}", + run.root_session_id + )); + } + Ok(()) + } + + fn persist_new_run( + run: &AgentOrgRunRecord, + coordinator_session: Option<&UnifiedSessionRecord>, + ) -> Result<(), String> { with_sessions_writer(|| -> Result<(), String> { let mut conn = get_connection().map_err(|err| err.to_string())?; let tx = conn .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|err| err.to_string())?; - insert_run(&tx, &run).map_err(|err| err.to_string())?; + Self::validate_lineage_with_connection(&tx, run)?; + if let Some(session) = coordinator_session { + upsert_session_with_connection(&tx, session).map_err(|err| err.to_string())?; + } + insert_run(&tx, run).map_err(|err| err.to_string())?; + if let Some(root_session_id) = run.root_session_id.as_deref() { + Self::insert_session_mapping_with_connection( + &tx, + &run.id, + COORDINATOR_MEMBER_ID, + root_session_id, + AgentOrgRunSessionRole::Coordinator, + &run.created_at, + )?; + } ensure_progress_in_conn(&tx, &run.id)?; tx.commit().map_err(|err| err.to_string()) + }) + } + + pub(crate) fn insert_session_mapping_with_connection( + conn: &Connection, + org_run_id: &str, + member_id: &str, + session_id: &str, + role: AgentOrgRunSessionRole, + created_at: &str, + ) -> Result<(), String> { + conn.execute( + "INSERT INTO agent_org_run_sessions ( + org_run_id, member_id, session_id, role, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![org_run_id, member_id, session_id, role.as_str(), created_at], + ) + .map_err(|err| { + format!( + "failed to register Agent Org {role:?} ownership run={org_run_id} member={member_id} session={session_id}: {err}" + ) })?; + Ok(()) + } + + #[cfg(test)] + pub(crate) fn mapping_for_session( + session_id: &str, + ) -> Result, String> { + let conn = get_connection().map_err(|err| err.to_string())?; + Self::mapping_for_session_with_connection(&conn, session_id) + } + + #[cfg(test)] + pub(crate) fn mapping_for_session_with_connection( + conn: &Connection, + session_id: &str, + ) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT org_run_id, member_id, session_id, role, created_at + FROM agent_org_run_sessions + WHERE session_id=?1 + ORDER BY created_at, org_run_id", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map([session_id], |row| { + let role_raw: String = row.get(3)?; + let role = AgentOrgRunSessionRole::parse(&role_raw).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Text, + format!("unknown AgentOrgRunSessionRole value: {role_raw:?}").into(), + ) + })?; + Ok(AgentOrgRunSessionRecord { + org_run_id: row.get(0)?, + member_id: row.get(1)?, + session_id: row.get(2)?, + role, + created_at: row.get(4)?, + }) + }) + .map_err(|err| err.to_string())?; + rows.collect::, _>>() + .map_err(|err| err.to_string()) + } + + pub(crate) fn member_id_for_mapped_session(session_id: &str) -> Result, String> { + let conn = get_connection().map_err(|err| err.to_string())?; + conn.query_row( + "SELECT member_id + FROM agent_org_run_sessions + WHERE session_id=?1 + LIMIT 1", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| err.to_string()) + } + + fn worker_run_id_for_session_with_connection( + conn: &Connection, + session_id: &str, + ) -> Result, String> { + conn.query_row( + "SELECT org_run_id + FROM agent_org_run_sessions + WHERE session_id=?1 AND role='worker' + LIMIT 1", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| err.to_string()) + } + + pub(crate) fn create_with_coordinator_session( + params: CreateAgentOrgRunParams, + lineage: AgentOrgRunLineage, + session: &UnifiedSessionRecord, + ) -> Result { + let run = Self::new_run_record(params, lineage)?; + if run.root_session_id.as_deref() != Some(session.session_id.as_str()) { + return Err("coordinator session must match the Agent Org root session".to_string()); + } + if session.org_member_id.as_deref() != Some(COORDINATOR_MEMBER_ID) + || session.agent_definition_id.as_deref() != Some(run.coordinator_agent_id.as_str()) + { + return Err("coordinator session identity must match the Agent Org run".to_string()); + } + Self::persist_new_run(&run, Some(session))?; + notify_session_upserted(&session.session_id); crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run.id); Ok(run) } + pub fn materialize_rust_worker_sessions( + org_run_id: &str, + sessions: &[UnifiedSessionRecord], + ) -> Result<(), String> { + if sessions.is_empty() { + return Ok(()); + } + with_sessions_writer(|| -> Result<(), String> { + let mut conn = get_connection().map_err(|err| err.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| err.to_string())?; + let expected_root: String = tx + .query_row( + "SELECT root_session_id FROM agent_org_runs WHERE id=?1", + [org_run_id], + |row| row.get::<_, Option>(0), + ) + .optional() + .map_err(|err| err.to_string())? + .flatten() + .ok_or_else(|| format!("Agent Org run {org_run_id} has no root session"))?; + let roster = Self::snapshot_member_agent_ids_with_connection(&tx, org_run_id)? + .ok_or_else(|| format!("Agent Org run {org_run_id} has no launch snapshot"))?; + for session in sessions { + let member_id = session.org_member_id.as_deref().ok_or_else(|| { + format!( + "Rust Agent Org worker {} has no member id", + session.session_id + ) + })?; + if member_id == COORDINATOR_MEMBER_ID + || session.agent_definition_id.is_none() + || session.parent_session_id.as_deref() != Some(expected_root.as_str()) + || roster.get(member_id) != session.agent_definition_id.as_ref() + { + return Err(format!( + "invalid Rust Agent Org worker run={org_run_id} member={member_id} session={}", + session.session_id + )); + } + upsert_session_with_connection(&tx, session).map_err(|err| err.to_string())?; + Self::insert_session_mapping_with_connection( + &tx, + org_run_id, + member_id, + &session.session_id, + AgentOrgRunSessionRole::Worker, + &session.created_at, + )?; + } + tx.commit().map_err(|err| err.to_string()) + })?; + for session in sessions { + notify_session_upserted(&session.session_id); + } + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(org_run_id); + Ok(()) + } + /// Pause a running run. Only transitions `running → paused`; already /// non-running runs are left unchanged and return `Ok(false)` (idempotent). pub fn mark_paused(run_id: &str) -> Result { @@ -501,23 +759,8 @@ impl AgentOrgRunStore { Ok(status) } - /// Resolve the org-run context for an arbitrary session — works for - /// both the root (coordinator) session and materialized member sessions - /// linked to the same Agent Org run. - /// - /// Strategy: try the direct `root_session_id` lookup first; if that - /// misses, walk the persisted `agent_sessions.parent_session_id` - /// chain upward (using the existing `idx_agent_sessions_parent` - /// index) and retry the lookup at each ancestor. The first ancestor - /// that anchors an `agent_org_runs` row wins. - /// - /// The persisted parent chain serves as the reverse-resolution - /// path. `root_session_id` remains the **single anchor** for an org - /// run — no per-subagent rows are added (avoids a second source of - /// truth and the corresponding unify-then-reshuffle reshape). - /// - /// Bounded to `MAX_PARENT_WALK_DEPTH` hops so a corrupt or cyclic - /// parent chain can't cause an unbounded scan during session init. + /// Resolve through turn ownership, exact mapping, root history, then the + /// bounded legacy parent walk. pub fn context_for_run( run_id: &str, org_store: &AgentOrgsStore, @@ -532,7 +775,33 @@ impl AgentOrgRunStore { session_id: &str, org_store: &AgentOrgsStore, ) -> Result, String> { - let Some(run) = Self::run_for_session_with_parent_walk(session_id)? else { + Self::context_for_session( + session_id, + org_store, + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + ) + } + + pub(crate) fn context_for_session_read_only_with_parent_walk( + session_id: &str, + org_store: &AgentOrgsStore, + ) -> Result, String> { + Self::context_for_session( + session_id, + org_store, + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::ReadOnly, + ) + } + + pub(crate) fn context_for_session( + session_id: &str, + org_store: &AgentOrgsStore, + resolution: AgentOrgRunResolution, + access: AgentOrgRunResolutionAccess, + ) -> Result, String> { + let Some(run) = Self::resolve_run_for_session(session_id, resolution, access)? else { return Ok(None); }; Ok(Some(context_for_run_record(&run, org_store)?)) @@ -541,11 +810,21 @@ impl AgentOrgRunStore { pub fn root_session_id_for_session_with_parent_walk( session_id: &str, ) -> Result, String> { - Ok(Self::run_for_session_with_parent_walk(session_id)?.and_then(|run| run.root_session_id)) + Ok(Self::resolve_run_for_session( + session_id, + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + )? + .and_then(|run| run.root_session_id)) } pub fn run_id_for_session_with_parent_walk(session_id: &str) -> Result, String> { - Ok(Self::run_for_session_with_parent_walk(session_id)?.map(|run| run.id)) + Ok(Self::resolve_run_for_session( + session_id, + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + )? + .map(|run| run.id)) } pub fn is_root_session(org_run_id: &str, session_id: &str) -> Result { @@ -562,13 +841,145 @@ impl AgentOrgRunStore { Ok(root_session_id.as_deref() == Some(session_id)) } - fn run_for_session_with_parent_walk( + pub(crate) fn resolve_run_for_session( session_id: &str, + resolution: AgentOrgRunResolution, + access: AgentOrgRunResolutionAccess, ) -> Result, String> { const MAX_PARENT_WALK_DEPTH: usize = 16; + if access == AgentOrgRunResolutionAccess::Mutable + && resolution.selected_historical_run_id.is_some() + { + return Err( + "historical Agent Org run selection is read-only and cannot authorize writes" + .to_string(), + ); + } + + let conn = get_connection().map_err(|err| err.to_string())?; + let selected_historical_run = resolution + .selected_historical_run_id + .as_deref() + .map(|selected_run_id| { + Self::load_run_with_connection(&conn, selected_run_id)? + .ok_or_else(|| format!("historical Agent Org run {selected_run_id} not found")) + }) + .transpose()?; + let mut active_run_ids = { + let mut stmt = conn + .prepare( + "SELECT DISTINCT org_run_id + FROM session_turn_intents + WHERE session_id=?1 + AND org_run_id IS NOT NULL + AND status IN ('optimistic', 'queued', 'running') + ORDER BY org_run_id + LIMIT 3", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map([session_id], |row| row.get::<_, String>(0)) + .map_err(|err| err.to_string())?; + rows.collect::, _>>() + .map_err(|err| err.to_string())? + }; + if let Some(run_id) = resolution.active_turn_run_id { + active_run_ids.push(run_id); + active_run_ids.sort(); + active_run_ids.dedup(); + } + if active_run_ids.len() > 1 { + return Err(format!( + "Agent Org turn intents disagree for session {session_id}: [{}]", + active_run_ids.join(", ") + )); + } + + let worker_run_id = Self::worker_run_id_for_session_with_connection(&conn, session_id)?; + if let (Some(active_run_id), Some(mapped_run_id)) = + (active_run_ids.first(), worker_run_id.as_ref()) + { + if active_run_id != mapped_run_id { + return Err(format!( + "Agent Org ownership conflict for session {session_id}: active turn run {active_run_id}, exact mapping run {mapped_run_id}" + )); + } + } + if let Some(active_run_id) = active_run_ids.first() { + let active_run = Self::load_run_with_connection(&conn, active_run_id)?.ok_or_else(|| { + format!( + "Agent Org active turn for session {session_id} references missing run {active_run_id}" + ) + })?; + if worker_run_id.is_none() { + let active_root = active_run.root_session_id.as_deref().ok_or_else(|| { + format!("Agent Org active turn run {active_run_id} has no root session") + })?; + let reaches_active_root: bool = conn + .query_row( + "WITH RECURSIVE ancestors(session_id, depth) AS ( + VALUES (?1, 0) + UNION ALL + SELECT parent.parent_session_id, ancestors.depth + 1 + FROM agent_sessions parent + JOIN ancestors ON parent.session_id=ancestors.session_id + WHERE parent.parent_session_id IS NOT NULL + AND ancestors.depth < ?3 + ) + SELECT EXISTS(SELECT 1 FROM ancestors WHERE session_id=?2)", + params![session_id, active_root, MAX_PARENT_WALK_DEPTH as i64], + |row| row.get(0), + ) + .map_err(|err| err.to_string())?; + if !reaches_active_root { + return Err(format!( + "Agent Org ownership conflict for session {session_id}: active turn run {active_run_id} belongs to root {active_root}" + )); + } + } + return Ok(Some(active_run)); + } + if let Some(mapped_run_id) = worker_run_id { + return Self::load_run_with_connection(&conn, &mapped_run_id).and_then(|run| { + run.ok_or_else(|| { + format!( + "Agent Org mapping for session {session_id} references missing run {mapped_run_id}" + ) + }) + .map(Some) + }); + } + if let Some(selected) = selected_historical_run { + if selected.root_session_id.as_deref() != Some(session_id) { + return Err(format!( + "historical Agent Org run {} does not belong to root {session_id}", + selected.id + )); + } + return Ok(Some(selected)); + } + + let unmapped_canonical_worker: bool = conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM agent_sessions + WHERE session_id=?1 + AND agent_definition_id IS NOT NULL + AND org_member_id IS NOT NULL + AND org_member_id<>?2 + )", + params![session_id, COORDINATOR_MEMBER_ID], + |row| row.get(0), + ) + .map_err(|err| err.to_string())?; + if access == AgentOrgRunResolutionAccess::Mutable && unmapped_canonical_worker { + return Err(format!( + "Rust Agent Org worker {session_id} has no exact Run mapping" + )); + } let mut current_id = session_id.to_string(); - let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + let mut visited = HashSet::new(); for hop in 0..=MAX_PARENT_WALK_DEPTH { if !visited.insert(current_id.clone()) { tracing::warn!( @@ -578,8 +989,53 @@ impl AgentOrgRunStore { ); return Ok(None); } - if let Some(run) = load_by_root_session(¤t_id).map_err(|err| err.to_string())? { - return Ok(Some(run)); + if current_id != session_id { + if let Some(mapped_run_id) = + Self::worker_run_id_for_session_with_connection(&conn, ¤t_id)? + { + return Self::load_run_with_connection(&conn, &mapped_run_id); + } + } + let root_runs = Self::load_runs_for_root_with_connection(&conn, ¤t_id)?; + if !root_runs.is_empty() { + // A legacy descendant is safe to infer only when its root has + // exactly one Run; a unique live Run does not remove ambiguity. + if hop > 0 { + if root_runs.len() == 1 { + return Ok(root_runs.into_iter().next()); + } + return Err(format!( + "unmapped legacy Agent Org session {session_id} is ambiguous across at least 2 runs for root {current_id}" + )); + } + let live_runs = root_runs + .iter() + .filter(|run| { + matches!( + run.status, + AgentOrgRunStatus::Running | AgentOrgRunStatus::Paused + ) + }) + .collect::>(); + if live_runs.len() == 1 { + return Ok(Some(live_runs[0].clone())); + } + if live_runs.len() > 1 { + return Err(format!( + "duplicate live Agent Org runs for root {current_id}: [{}]", + live_runs + .iter() + .map(|run| run.id.as_str()) + .collect::>() + .join(", ") + )); + } + if root_runs.len() == 1 { + return Ok(root_runs.into_iter().next()); + } + return Err(format!( + "Agent Org run is ambiguous for root {current_id}; select one historical run explicitly for read-only access" + )); } if hop == MAX_PARENT_WALK_DEPTH { tracing::warn!( @@ -590,7 +1046,16 @@ impl AgentOrgRunStore { ); return Ok(None); } - match parent_session_id_of(¤t_id).map_err(|err| err.to_string())? { + let parent = conn + .query_row( + "SELECT parent_session_id FROM agent_sessions WHERE session_id=?1", + [¤t_id], + |row| row.get::<_, Option>(0), + ) + .optional() + .map_err(|err| err.to_string())? + .flatten(); + match parent { Some(parent) => current_id = parent, None => return Ok(None), } @@ -598,6 +1063,71 @@ impl AgentOrgRunStore { Ok(None) } + fn load_run_with_connection( + conn: &Connection, + run_id: &str, + ) -> Result, String> { + conn.query_row( + "SELECT 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, completed_at, + continued_from_run_id, originating_message_id + FROM agent_org_runs WHERE id=?1 LIMIT 1", + [run_id], + row_to_run, + ) + .optional() + .map_err(|err| err.to_string()) + } + + fn load_runs_for_root_with_connection( + conn: &Connection, + root_session_id: &str, + ) -> Result, String> { + let select = "SELECT 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, completed_at, + continued_from_run_id, originating_message_id + FROM agent_org_runs"; + let mut live_stmt = conn + .prepare(&format!( + "{select} + WHERE root_session_id=?1 + AND status IN ('starting', 'running', 'paused') + LIMIT 2" + )) + .map_err(|err| err.to_string())?; + let mut runs = live_stmt + .query_map([root_session_id], row_to_run) + .map_err(|err| err.to_string())? + .collect::, _>>() + .map_err(|err| err.to_string())?; + let mut stmt = conn + .prepare(&format!( + "{select} + WHERE root_session_id=?1 + ORDER BY created_at DESC, id DESC + LIMIT 2" + )) + .map_err(|err| err.to_string())?; + let history = stmt + .query_map([root_session_id], row_to_run) + .map_err(|err| err.to_string())? + .collect::, _>>() + .map_err(|err| err.to_string())?; + for run in history { + if runs.len() == 2 { + break; + } + if !runs.iter().any(|existing| existing.id == run.id) { + runs.push(run); + } + } + Ok(runs) + } + /// List every persisted run that has anchored a coordinator session, /// across all orgs, ordered by `updated_at DESC`. Used by the Inbox /// page to render its flat list of chats — each row is one run. @@ -624,7 +1154,9 @@ impl AgentOrgRunStore { last_error, created_at, updated_at, - completed_at + completed_at, + continued_from_run_id, + originating_message_id FROM agent_org_runs WHERE root_session_id IS NOT NULL ORDER BY updated_at DESC @@ -641,6 +1173,98 @@ impl AgentOrgRunStore { Ok(out) } + pub fn list_timeline( + root_session_id: &str, + cursor: Option, + limit: Option, + ) -> Result { + let page_size = limit.unwrap_or(50).clamp(1, 100); + let fetch_limit = i64::try_from(page_size + 1).unwrap_or(101); + let conn = get_connection().map_err(|err| err.to_string())?; + let mut runs = match cursor { + Some(cursor) => { + let mut stmt = conn + .prepare( + "SELECT id, status, summary, created_at, updated_at, completed_at, + continued_from_run_id + FROM agent_org_runs + WHERE root_session_id=?1 + AND (created_at, id) < (?2, ?3) + ORDER BY created_at DESC, id DESC + LIMIT ?4", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map( + params![root_session_id, cursor.created_at, cursor.id, fetch_limit], + Self::timeline_item_from_row, + ) + .map_err(|err| err.to_string())?; + rows.collect::, _>>() + .map_err(|err| err.to_string())? + } + None => { + let mut stmt = conn + .prepare( + "SELECT id, status, summary, created_at, updated_at, completed_at, + continued_from_run_id + FROM agent_org_runs + WHERE root_session_id=?1 + ORDER BY created_at DESC, id DESC + LIMIT ?2", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map( + params![root_session_id, fetch_limit], + Self::timeline_item_from_row, + ) + .map_err(|err| err.to_string())?; + rows.collect::, _>>() + .map_err(|err| err.to_string())? + } + }; + let has_more = runs.len() > page_size; + if has_more { + runs.truncate(page_size); + } + let next_cursor = has_more && !runs.is_empty(); + let next_cursor = next_cursor.then(|| { + let last = runs.last().expect("non-empty timeline page"); + AgentOrgRunTimelineCursor { + created_at: last.created_at.clone(), + id: last.id.clone(), + } + }); + Ok(AgentOrgRunTimelinePage { + runs, + has_more, + next_cursor, + }) + } + + fn timeline_item_from_row( + row: &rusqlite::Row<'_>, + ) -> rusqlite::Result { + let status_raw: String = row.get(1)?; + let status = AgentOrgRunStatus::parse(&status_raw).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + format!("unknown AgentOrgRunStatus value: {status_raw:?}").into(), + ) + })?; + Ok(AgentOrgRunTimelineItem { + id: row.get(0)?, + status, + summary: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + completed_at: row.get(5)?, + continued_from_run_id: row.get(6)?, + }) + } + /// List runs currently in `running` status, newest-updated first. /// SQL-side status filter avoids loading terminal runs. Callers that must /// inspect every running run (the watchdog) pass `usize::MAX`, which is @@ -663,7 +1287,9 @@ impl AgentOrgRunStore { last_error, created_at, updated_at, - completed_at + completed_at, + continued_from_run_id, + originating_message_id FROM agent_org_runs WHERE root_session_id IS NOT NULL AND status = ?1 @@ -779,6 +1405,7 @@ impl AgentOrgRunStore { format!("failed to delete agent_inbox_materializations rows for {run_id}: {err}") })?; for table in [ + "agent_org_run_sessions", "agent_org_plan_approvals", "agent_org_recovery_attempts", "agent_org_task_events", @@ -926,16 +1553,16 @@ impl AgentOrgRunStore { .collect()) } - /// Canonical member ids captured in the immutable launch snapshot. + /// Canonical members captured in the immutable launch snapshot. /// /// Recovery must not consult the user's current Agent Org definition: a /// team can be edited while an older run is still alive. `None` is kept /// for historical rows that predate launch snapshots; callers may still /// classify a materialized session, but must not invent roster membership. - pub(crate) fn snapshot_member_ids_with_connection( + fn snapshot_member_agent_ids_with_connection( conn: &Connection, org_run_id: &str, - ) -> Result>, String> { + ) -> Result>, String> { let snapshot_json: Option = conn .query_row( "SELECT org_snapshot_json FROM agent_org_runs WHERE id=?1", @@ -952,12 +1579,29 @@ impl AgentOrgRunStore { serde_json::from_str(&snapshot_json).map_err(|err| { format!("failed to parse Agent Org launch snapshot for run {org_run_id}: {err}") })?; - Ok(Some( - flatten_members(&snapshot.children, None) - .into_iter() - .map(|member| member.member_id) - .collect(), - )) + let mut members = HashMap::new(); + for member in flatten_members(&snapshot.children, None) { + if members + .insert(member.member_id.clone(), member.agent_id) + .is_some() + { + return Err(format!( + "Agent Org run {org_run_id} snapshot has duplicate member id {}", + member.member_id + )); + } + } + Ok(Some(members)) + } + + pub(crate) fn snapshot_member_ids_with_connection( + conn: &Connection, + org_run_id: &str, + ) -> Result>, String> { + Ok( + Self::snapshot_member_agent_ids_with_connection(conn, org_run_id)? + .map(|members| members.into_keys().collect()), + ) } pub fn list_descendant_worker_sessions( @@ -971,18 +1615,6 @@ impl AgentOrgRunStore { conn: &Connection, org_run_id: &str, ) -> Result, String> { - let root_session_id: Option = conn - .query_row( - "SELECT root_session_id FROM agent_org_runs WHERE id = ?1", - params![org_run_id], - |row| row.get::<_, Option>(0), - ) - .optional() - .map_err(|err| err.to_string())? - .flatten(); - let Some(root) = root_session_id else { - return Ok(Vec::new()); - }; let interventions = AgentMemberInterventionStore::list_active_with_connection(conn, org_run_id)? .into_iter() @@ -991,154 +1623,161 @@ impl AgentOrgRunStore { let mut stmt = conn .prepare( - "WITH RECURSIVE descendants(session_id) AS ( - SELECT session_id - FROM agent_sessions child - WHERE child.parent_session_id = ?1 - AND NOT EXISTS ( - SELECT 1 FROM agent_org_runs nested - WHERE nested.id <> ?2 - AND nested.root_session_id = child.session_id - ) - UNION - SELECT s.session_id - FROM agent_sessions s - JOIN descendants d ON s.parent_session_id = d.session_id - WHERE NOT EXISTS ( - SELECT 1 FROM agent_org_runs nested - WHERE nested.id <> ?2 - AND nested.root_session_id = s.session_id - ) - ), ranked AS ( - SELECT s.agent_definition_id, - s.org_member_id, - s.session_id, - s.status, - s.updated_at, - ROW_NUMBER() OVER ( - PARTITION BY CASE - WHEN s.org_member_id IS NOT NULL - THEN 'member:' || s.org_member_id - ELSE 'session:' || s.session_id - END - ORDER BY s.updated_at DESC, s.session_id DESC - ) AS rank - FROM agent_sessions s - JOIN descendants d USING (session_id) - WHERE s.agent_definition_id IS NOT NULL - ) - SELECT agent_definition_id, org_member_id, session_id, status, updated_at - FROM ranked - WHERE rank = 1 - ORDER BY updated_at DESC, session_id DESC", + "SELECT s.agent_definition_id, + mapping.member_id, + s.session_id, + s.parent_session_id, + s.status, + s.updated_at + FROM agent_org_run_sessions mapping + JOIN agent_sessions s ON s.session_id=mapping.session_id + WHERE mapping.org_run_id=?1 + AND mapping.role='worker' + ORDER BY s.updated_at DESC, s.session_id DESC", ) .map_err(|err| err.to_string())?; let rows = stmt - .query_map(params![root.clone(), org_run_id], |row| { - let status_raw: String = row.get(3)?; + .query_map([org_run_id], |row| { + let status_raw: String = row.get(4)?; let status = crate::core::session::SessionStatus::parse(&status_raw).ok_or_else(|| { rusqlite::Error::FromSqlConversionFailure( - 3, + 4, rusqlite::types::Type::Text, format!("unknown SessionStatus value: {status_raw:?}").into(), ) })?; - let agent_definition_id: String = row.get(0)?; - let org_member_id: Option = row.get(1)?; - let intervention = org_member_id - .as_deref() - .and_then(|member_id| interventions.get(member_id).cloned()); + let member_id: String = row.get(1)?; + let intervention = interventions.get(&member_id).cloned(); Ok(WorkerSessionRuntime { intervention, - agent_definition_id: Some(agent_definition_id), + agent_definition_id: row.get(0)?, cli_agent_type: None, - member_id: org_member_id, + member_id: Some(member_id), session_id: row.get(2)?, - parent_session_id: Some(root.clone()), + parent_session_id: row.get(3)?, status, - updated_at: row.get(4)?, + updated_at: row.get(5)?, }) }) .map_err(|err| err.to_string())?; + let mut out = rows + .collect::, _>>() + .map_err(|err| err.to_string())?; + drop(stmt); - let mut out = Vec::new(); - for row in rows { - out.push(row.map_err(|err| err.to_string())?); - } - - let mut cli_stmt = conn + let root_session_id: Option = conn + .query_row( + "SELECT root_session_id FROM agent_org_runs WHERE id=?1", + [org_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| err.to_string())? + .flatten(); + let Some(root_session_id) = root_session_id else { + return Ok(out); + }; + let mut legacy_stmt = conn .prepare( - "SELECT cli_agent_type, org_member_id, session_id, status, updated_at - FROM code_sessions - WHERE parent_session_id = ?1 - AND org_member_id IS NOT NULL - AND cli_agent_type IS NOT NULL - ORDER BY updated_at DESC, session_id DESC", + "SELECT s.agent_definition_id, s.org_member_id, s.session_id, + s.parent_session_id, s.status, s.updated_at + FROM agent_sessions s + WHERE s.parent_session_id=?1 + AND s.agent_definition_id IS NOT NULL + AND s.org_member_id IS NOT NULL + AND s.org_member_id<>'coordinator' + AND NOT EXISTS ( + SELECT 1 FROM agent_org_run_sessions ownership + WHERE ownership.session_id=s.session_id + ) + ORDER BY s.updated_at DESC, s.session_id DESC", ) .map_err(|err| err.to_string())?; - let cli_rows = cli_stmt - .query_map(params![root.clone()], |row| { - let status_raw: String = row.get(3)?; - let status = - crate::core::session::SessionStatus::parse(&status_raw).ok_or_else(|| { - rusqlite::Error::FromSqlConversionFailure( - 3, - rusqlite::types::Type::Text, - format!("unknown CLI SessionStatus value: {status_raw:?}").into(), - ) - })?; - let cli_agent_type: String = row.get(0)?; - let org_member_id: Option = row.get(1)?; - let intervention = org_member_id - .as_deref() - .and_then(|member_id| interventions.get(member_id).cloned()); - Ok(WorkerSessionRuntime { - intervention, - agent_definition_id: None, - cli_agent_type: Some(cli_agent_type), - member_id: org_member_id, - session_id: row.get(2)?, - parent_session_id: Some(root.clone()), - status, - updated_at: row.get(4)?, - }) + let legacy_rows = legacy_stmt + .query_map([&root_session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) }) + .map_err(|err| err.to_string())? + .collect::, _>>() .map_err(|err| err.to_string())?; - for row in cli_rows { - out.push(row.map_err(|err| err.to_string())?); + if legacy_rows.is_empty() { + return Ok(out); + } + let mut seen_members = out + .iter() + .filter_map(|session| session.member_id.clone()) + .collect::>(); + let legacy_rows = legacy_rows + .into_iter() + .filter(|(_, member_id, _, _, _, _)| !seen_members.contains(member_id)) + .collect::>(); + if legacy_rows.is_empty() { + return Ok(out); + } + let root_run_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM ( + SELECT 1 FROM agent_org_runs WHERE root_session_id=?1 LIMIT 2 + )", + [&root_session_id], + |row| row.get(0), + ) + .map_err(|err| err.to_string())?; + if root_run_count != 1 { + return Err(format!( + "unmapped Rust Agent Org workers under root {root_session_id} are ambiguous across {root_run_count} runs" + )); + } + let roster = Self::snapshot_member_agent_ids_with_connection(conn, org_run_id)?; + for ( + agent_definition_id, + member_id, + session_id, + parent_session_id, + status_raw, + updated_at, + ) in legacy_rows + { + if roster + .as_ref() + .is_some_and(|roster| roster.get(&member_id) != Some(&agent_definition_id)) + || !seen_members.insert(member_id.clone()) + { + return Err(format!( + "ambiguous unmapped Rust Agent Org worker run={org_run_id} member={member_id} session={session_id}" + )); + } + let status = SessionStatus::parse(&status_raw).ok_or_else(|| { + format!( + "unknown SessionStatus value for legacy worker {session_id}: {status_raw:?}" + ) + })?; + out.push(WorkerSessionRuntime { + intervention: interventions.get(&member_id).cloned(), + agent_definition_id: Some(agent_definition_id), + cli_agent_type: None, + member_id: Some(member_id), + session_id, + parent_session_id, + status, + updated_at, + }); } - out.sort_by(|left, right| { right .updated_at .cmp(&left.updated_at) - // Historical databases can contain both a Rust and a CLI - // session for one member at the same timestamp. Rust is the - // only supported Agent Org transport, so it wins an exact tie. - .then_with(|| { - left.cli_agent_type - .is_some() - .cmp(&right.cli_agent_type.is_some()) - }) .then_with(|| right.session_id.cmp(&left.session_id)) }); - - // Rust and CLI sessions live in different tables, so neither table's - // window function can suppress an older duplicate from the other - // transport. Apply the canonical-member rule once more after the - // combined freshness sort. Historical rows without a member id are - // distinct sessions; do not guess that they belong to one member. - let mut seen_canonical_workers = HashSet::new(); - out.retain(|session| { - let key = session - .member_id - .as_ref() - .map(|member_id| format!("member:{member_id}")) - .unwrap_or_else(|| format!("session:{}", session.session_id)); - seen_canonical_workers.insert(key) - }); Ok(out) } } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs index def031c102..d106c9637c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs @@ -5,7 +5,7 @@ use crate::core::session::SessionStatus; use crate::definitions::orgs::{ AgentOrgsStore, HierarchyMode, OrgDefinition, OrgMember, PlanApprovalPolicy, }; -use rusqlite::params; +use rusqlite::{params, OptionalExtension}; #[test] fn enum_values_round_trip() { @@ -97,6 +97,615 @@ fn create_run_for_root(org: &OrgDefinition, root_session_id: &str) -> AgentOrgRu .expect("create run") } +fn run_params(org: &OrgDefinition, root_session_id: &str) -> CreateAgentOrgRunParams { + CreateAgentOrgRunParams { + org_id: org.id.clone(), + coordinator_agent_id: org.agent_id.clone(), + root_session_id: Some(root_session_id.to_string()), + org_snapshot: org.clone(), + entry_mode: AgentOrgRunEntryMode::StandaloneSession, + status: AgentOrgRunStatus::Running, + work_item_id: None, + project_slug: None, + routine_fire_id: None, + } +} + +fn set_run_status(run_id: &str, status: &str) { + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute( + "UPDATE agent_org_runs SET status=?2, updated_at=?3 WHERE id=?1", + params![run_id, status, chrono::Utc::now().to_rfc3339()], + ) + .expect("set test run status"); +} + +#[test] +fn run_constraints_allow_history_but_reject_duplicate_live_and_origin_message() { + let _sandbox = test_helpers::test_env::sandbox(); + let org = sample_org(); + let root = "root-run-constraints"; + ensure_runtime_schemas(); + + let first = AgentOrgRunStore::create_with_lineage( + run_params(&org, root), + AgentOrgRunLineage { + continued_from_run_id: None, + originating_message_id: Some("message-one".to_string()), + }, + ) + .expect("create first run"); + assert!(AgentOrgRunStore::create(run_params(&org, root)).is_err()); + + let conn = database::db::get_connection().expect("test sqlite connection"); + let starting_result = conn.execute( + "INSERT INTO agent_org_runs ( + id, org_id, coordinator_agent_id, root_session_id, + entry_mode, status, created_at, updated_at + ) VALUES ('starting-duplicate', ?1, ?2, ?3, 'standalone_session', + 'starting', ?4, ?4)", + params![ + &org.id, + &org.agent_id, + root, + chrono::Utc::now().to_rfc3339() + ], + ); + assert!(starting_result.is_err(), "starting must count as live"); + + set_run_status(&first.id, "completed"); + let second = AgentOrgRunStore::create_with_lineage( + run_params(&org, root), + AgentOrgRunLineage { + continued_from_run_id: Some(first.id.clone()), + originating_message_id: Some("message-two".to_string()), + }, + ) + .expect("create sequential second run"); + assert_eq!( + second.continued_from_run_id.as_deref(), + Some(first.id.as_str()) + ); + + set_run_status(&second.id, "completed"); + let duplicate_origin = AgentOrgRunStore::create_with_lineage( + run_params(&org, root), + AgentOrgRunLineage { + continued_from_run_id: Some(second.id.clone()), + originating_message_id: Some("message-one".to_string()), + }, + ); + assert!(duplicate_origin.is_err()); + + let coordinator_mappings: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_org_run_sessions + WHERE session_id=?1 AND role='coordinator'", + [root], + |row| row.get(0), + ) + .expect("count coordinator mappings"); + assert_eq!(coordinator_mappings, 2); + let sidebar_runs = + AgentOrgRunStore::list_runs_for_root_session_ids(&[root.to_string()]).unwrap(); + assert_eq!(sidebar_runs.len(), 1); + assert_eq!(sidebar_runs[0].id, second.id); + let sidebar_plan = conn + .prepare( + "EXPLAIN QUERY PLAN + WITH requested_roots(root_session_id) AS (VALUES (?1)) + SELECT candidate.id FROM requested_roots requested + JOIN agent_org_runs candidate ON candidate.id=( + SELECT latest.id FROM agent_org_runs latest + WHERE latest.root_session_id=requested.root_session_id + ORDER BY latest.updated_at DESC, latest.id DESC LIMIT 1 + )", + ) + .unwrap() + .query_map([root], |row| row.get::<_, String>(3)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(sidebar_plan + .iter() + .any(|detail| detail.contains("idx_agent_org_runs_root_updated"))); + assert!(!sidebar_plan + .iter() + .any(|detail| detail.contains("SCAN candidate"))); +} + +#[test] +fn coordinator_and_rust_worker_creation_are_transactional() { + let _sandbox = test_helpers::test_env::sandbox(); + let org = sample_org(); + ensure_runtime_schemas(); + let root_session = UnifiedSessionRecord { + session_id: "root-atomic-create".to_string(), + name: "atomic coordinator".to_string(), + status: SessionStatus::Idle.as_str().to_string(), + session_type: "agent".to_string(), + agent_definition_id: Some(org.agent_id.clone()), + org_member_id: Some(COORDINATOR_MEMBER_ID.to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }; + let run = AgentOrgRunStore::create_with_coordinator_session( + run_params(&org, &root_session.session_id), + Default::default(), + &root_session, + ) + .expect("atomically create coordinator, run, and mapping"); + assert_eq!( + AgentOrgRunStore::mapping_for_session(&root_session.session_id) + .unwrap() + .len(), + 1 + ); + + let worker = UnifiedSessionRecord { + session_id: "worker-atomic-create".to_string(), + name: "atomic worker".to_string(), + status: SessionStatus::Idle.as_str().to_string(), + session_type: crate::core::session::persistence::session_type::ORG_MEMBER.to_string(), + agent_definition_id: Some("agent-w1".to_string()), + org_member_id: Some("member-w1".to_string()), + parent_session_id: Some(root_session.session_id.clone()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }; + AgentOrgRunStore::materialize_rust_worker_sessions(&run.id, std::slice::from_ref(&worker)) + .expect("atomically create Rust worker and mapping"); + upsert_session(&UnifiedSessionRecord { + org_member_id: None, + ..worker.clone() + }) + .expect("partial upsert preserves mapped member identity"); + let drifted = UnifiedSessionRecord { + org_member_id: Some("member-w2".to_string()), + ..worker.clone() + }; + assert!(upsert_session(&drifted).is_err()); + assert_eq!( + crate::session::persistence::get_session(&worker.session_id) + .unwrap() + .unwrap() + .org_member_id + .as_deref(), + Some("member-w1") + ); + assert_eq!( + AgentOrgRunStore::mapping_for_session(&worker.session_id).unwrap()[0].member_id, + "member-w1" + ); + + let duplicate = UnifiedSessionRecord { + session_id: "worker-mapping-must-rollback".to_string(), + ..worker.clone() + }; + assert!(AgentOrgRunStore::materialize_rust_worker_sessions(&run.id, &[duplicate]).is_err()); + let wrong_agent = UnifiedSessionRecord { + session_id: "worker-agent-must-match-snapshot".to_string(), + agent_definition_id: Some("wrong-agent".to_string()), + ..worker + }; + assert!(AgentOrgRunStore::materialize_rust_worker_sessions(&run.id, &[wrong_agent]).is_err()); + let conn = database::db::get_connection().expect("test sqlite connection"); + let orphan_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_sessions + WHERE session_id IN ('worker-mapping-must-rollback', + 'worker-agent-must-match-snapshot')", + [], + |row| row.get(0), + ) + .expect("count rolled back worker"); + assert_eq!(orphan_count, 0, "mapping failure must roll back Session"); + + set_run_status(&run.id, "completed"); + let coordinator_collision = AgentOrgRunStore::create_with_coordinator_session( + run_params(&org, "worker-atomic-create"), + Default::default(), + &UnifiedSessionRecord { + session_id: "worker-atomic-create".to_string(), + name: "must not become coordinator".to_string(), + status: SessionStatus::Idle.as_str().to_string(), + session_type: "agent".to_string(), + agent_definition_id: Some(org.agent_id.clone()), + org_member_id: Some(COORDINATOR_MEMBER_ID.to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }, + ); + assert!(coordinator_collision.is_err()); + let collision_runs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_org_runs + WHERE root_session_id='worker-atomic-create'", + [], + |row| row.get(0), + ) + .expect("count rolled back coordinator run"); + assert_eq!(collision_runs, 0); +} + +#[test] +fn exact_resolver_keeps_old_worker_on_old_run_and_rejects_conflicts() { + let _sandbox = test_helpers::test_env::sandbox(); + let org = sample_org(); + let store = store_with_org(org.clone()); + let root = "root-resolver-history"; + let first = create_run_for_root(&org, root); + upsert_session_row(root, None); + upsert_session_row_for_member( + "worker-run-one", + Some(root), + Some("agent-w1"), + Some("member-w1"), + "idle", + ); + set_run_status(&first.id, "completed"); + let second = AgentOrgRunStore::create_with_lineage( + run_params(&org, root), + AgentOrgRunLineage { + continued_from_run_id: Some(first.id.clone()), + originating_message_id: Some("resolver-second-message".to_string()), + }, + ) + .expect("create live second run"); + + let unmapped_legacy = UnifiedSessionRecord { + session_id: "unmapped-worker-from-run-one".to_string(), + name: "unmapped legacy worker".to_string(), + status: "idle".to_string(), + session_type: crate::core::session::persistence::session_type::ORG_MEMBER.to_string(), + agent_definition_id: Some("agent-w1".to_string()), + org_member_id: Some("member-w1".to_string()), + parent_session_id: Some(root.to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }; + upsert_session(&unmapped_legacy).expect("seed unmapped legacy worker"); + let ambiguous_legacy = AgentOrgRunStore::resolve_run_for_session( + &unmapped_legacy.session_id, + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + ) + .expect_err("unmapped worker must not follow the root's unique live run"); + assert!(ambiguous_legacy.contains("has no exact Run mapping")); + assert!(AgentOrgRunStore::list_descendant_worker_sessions(&second.id).is_err()); + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute( + "UPDATE agent_sessions SET parent_session_id='worker-run-one' + WHERE session_id='unmapped-worker-from-run-one'", + [], + ) + .expect("move anomaly below an exact-mapped worker"); + assert!(AgentOrgRunStore::resolve_run_for_session( + &unmapped_legacy.session_id, + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + ) + .is_err()); + + let old_worker = AgentOrgRunStore::resolve_run_for_session( + "worker-run-one", + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + ) + .expect("resolve exact old worker") + .expect("worker run"); + assert_eq!(old_worker.id, first.id); + + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id, turn_intent_id, org_run_id, source, status, + created_at, updated_at + ) VALUES (?1, 'conflicting-intent', ?2, 'agent_org', 'running', ?3, ?3)", + params!["worker-run-one", &second.id, &now], + ) + .expect("seed conflicting active intent"); + let conflict = AgentOrgRunStore::resolve_run_for_session( + "worker-run-one", + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + ) + .expect_err("intent and mapping disagreement must fail"); + assert!(conflict.contains("ownership conflict")); + + set_run_status(&second.id, "completed"); + conn.execute( + "UPDATE session_turn_intents SET status='completed' + WHERE turn_intent_id='conflicting-intent'", + [], + ) + .expect("close conflicting intent"); + assert!(AgentOrgRunStore::context_for_session_with_parent_walk(root, &store).is_err()); + let historical = AgentOrgRunStore::context_for_session( + root, + &store, + AgentOrgRunResolution::read_only(Some(first.id.clone())), + AgentOrgRunResolutionAccess::ReadOnly, + ) + .expect("explicit historical read") + .expect("historical context"); + assert_eq!(historical.run_id, first.id); + assert!(AgentOrgRunStore::context_for_session( + root, + &store, + AgentOrgRunResolution::read_only(Some(first.id)), + AgentOrgRunResolutionAccess::Mutable, + ) + .is_err()); + + let foreign = create_run_for_root(&org, "foreign-root-resolver"); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id, turn_intent_id, org_run_id, source, status, + created_at, updated_at + ) VALUES (?1, 'cross-root-intent', ?2, 'agent_org', 'running', ?3, ?3)", + params![root, &foreign.id, chrono::Utc::now().to_rfc3339()], + ) + .expect("seed cross-root active intent"); + let cross_root = AgentOrgRunStore::resolve_run_for_session( + root, + AgentOrgRunResolution::default(), + AgentOrgRunResolutionAccess::Mutable, + ) + .expect_err("active turn must not cross Agent Org roots"); + assert!(cross_root.contains("ownership conflict")); +} + +#[test] +fn timeline_uses_stable_bounded_keyset_pagination() { + let _sandbox = test_helpers::test_env::sandbox(); + let org = sample_org(); + let root = "root-timeline-keyset"; + ensure_runtime_schemas(); + let mut ids = Vec::new(); + let mut predecessor = None; + for index in 0..3 { + let run = AgentOrgRunStore::create_with_lineage( + run_params(&org, root), + AgentOrgRunLineage { + continued_from_run_id: predecessor.clone(), + originating_message_id: Some(format!("timeline-message-{index}")), + }, + ) + .expect("create timeline run"); + set_run_status(&run.id, "completed"); + predecessor = Some(run.id.clone()); + ids.push(run.id); + } + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute( + "UPDATE agent_org_runs SET created_at='2026-01-01T00:00:00Z' + WHERE root_session_id=?1", + [root], + ) + .expect("force timestamp ties"); + + let first_page = AgentOrgRunStore::list_timeline(root, None, Some(2)).unwrap(); + assert_eq!(first_page.runs.len(), 2); + assert!(first_page.has_more); + let second_page = + AgentOrgRunStore::list_timeline(root, first_page.next_cursor.clone(), Some(2)).unwrap(); + assert_eq!(second_page.runs.len(), 1); + assert!(!second_page.has_more); + let paged_ids = first_page + .runs + .iter() + .chain(second_page.runs.iter()) + .map(|run| run.id.clone()) + .collect::>(); + assert_eq!(paged_ids.len(), 3); + assert_eq!(paged_ids, ids.into_iter().collect()); + + let other_root_run = create_run_for_root(&org, "root-timeline-other"); + for index in 3..105 { + let run = AgentOrgRunStore::create_with_lineage( + run_params(&org, root), + AgentOrgRunLineage { + continued_from_run_id: predecessor.clone(), + originating_message_id: Some(format!("timeline-message-{index}")), + }, + ) + .expect("create bounded timeline history"); + set_run_status(&run.id, "completed"); + predecessor = Some(run.id); + } + + let default_page = AgentOrgRunStore::list_timeline(root, None, None).unwrap(); + assert_eq!(default_page.runs.len(), 50); + assert!(default_page.has_more); + assert!(default_page + .runs + .iter() + .all(|run| run.id != other_root_run.id)); + let capped_page = AgentOrgRunStore::list_timeline(root, None, Some(500)).unwrap(); + assert_eq!(capped_page.runs.len(), 100); + assert!(capped_page.has_more); + + let mut plan_stmt = conn + .prepare( + "EXPLAIN QUERY PLAN + SELECT id FROM agent_org_runs + WHERE root_session_id=?1 + AND (created_at, id) < (?2, ?3) + ORDER BY created_at DESC, id DESC LIMIT 50", + ) + .unwrap(); + let query_plan = plan_stmt + .query_map( + params![root, "2026-12-31T23:59:59Z", "timeline-cursor"], + |row| row.get::<_, String>(3), + ) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!( + query_plan.iter().any(|detail| { + detail.contains("idx_agent_org_runs_root_timeline") + && detail.contains("(created_at,id)<") + }), + "cursor query must use a created_at range seek: {query_plan:?}" + ); +} + +#[test] +fn migration_backfills_only_unambiguous_rust_and_reports_legacy_cli_boundedly() { + let _sandbox = test_helpers::test_env::sandbox(); + let org = sample_org(); + let root = "root-migration-backfill"; + let run = create_run_for_root(&org, root); + let no_snapshot_root = "root-migration-no-snapshot"; + let no_snapshot_run = create_run_for_root(&org, no_snapshot_root); + let malformed_root = "root-migration-malformed-snapshot"; + let malformed_run = create_run_for_root(&org, malformed_root); + let contradictory_root = "root-migration-contradictory-snapshot"; + let _contradictory_run = create_run_for_root(&org, contradictory_root); + upsert_session_row(root, None); + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute( + "UPDATE agent_org_runs SET org_snapshot_json=NULL WHERE id=?1", + [&no_snapshot_run.id], + ) + .unwrap(); + conn.execute( + "UPDATE agent_org_runs SET org_snapshot_json='{broken' WHERE id=?1", + [&malformed_run.id], + ) + .unwrap(); + conn.execute("DELETE FROM agent_org_run_sessions", []) + .expect("clear mappings for migration fixture"); + for (session_id, parent) in [ + ("ambiguous-worker-a", root), + ("ambiguous-worker-b", root), + ("no-snapshot-worker", no_snapshot_root), + ("malformed-snapshot-worker", malformed_root), + ("contradictory-snapshot-worker", contradictory_root), + ] { + let record = UnifiedSessionRecord { + session_id: session_id.to_string(), + name: session_id.to_string(), + status: "idle".to_string(), + session_type: crate::core::session::persistence::session_type::ORG_MEMBER.to_string(), + agent_definition_id: Some( + if session_id == "contradictory-snapshot-worker" { + "agent-other" + } else { + "agent-w1" + } + .to_string(), + ), + org_member_id: Some("member-w1".to_string()), + parent_session_id: Some(parent.to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }; + upsert_session(&record).expect("seed ambiguous legacy Rust worker"); + } + conn.execute( + "INSERT INTO code_sessions ( + session_id, cli_agent_type, status, parent_session_id, + org_member_id, updated_at + ) VALUES ('legacy-cli-anomaly', 'claude_code', 'idle', ?1, + 'member-w1', ?2), + (?1, 'claude_code', 'idle', NULL, 'coordinator', ?2)", + params![root, chrono::Utc::now().to_rfc3339()], + ) + .expect("seed unsupported legacy CLI anomaly"); + + let report = init_schema_with_report(&conn).expect("run idempotent ownership migration"); + assert_eq!(report.coordinator_mappings_backfilled, 4); + assert_eq!(report.worker_mappings_backfilled, 1); + assert_eq!(report.ambiguous_worker_sessions, 4); + assert_eq!(report.unsupported_cli_sessions, 2); + assert!(report.anomaly_samples.len() <= 20); + let worker_mappings: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_org_run_sessions WHERE role='worker'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(worker_mappings, 1); + assert_eq!( + AgentOrgRunStore::mapping_for_session("no-snapshot-worker") + .unwrap() + .len(), + 1 + ); + assert!( + AgentOrgRunStore::mapping_for_session("malformed-snapshot-worker") + .unwrap() + .is_empty() + ); + assert!( + AgentOrgRunStore::mapping_for_session("contradictory-snapshot-worker") + .unwrap() + .is_empty() + ); + assert!(AgentOrgRunStore::mapping_for_session("legacy-cli-anomaly") + .unwrap() + .is_empty()); + assert!(load_by_id(&run.id).unwrap().is_some()); + + let repeated = init_schema_with_report(&conn).expect("repeat migration safely"); + assert_eq!(repeated.coordinator_mappings_backfilled, 0); + assert_eq!(repeated.worker_mappings_backfilled, 0); +} + +#[test] +fn migration_rejects_duplicate_live_roots_without_partial_mapping_writes() { + let _sandbox = test_helpers::test_env::sandbox(); + let org = sample_org(); + let root = "root-duplicate-live-migration"; + let first = create_run_for_root(&org, root); + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute("DELETE FROM agent_org_run_sessions", []) + .expect("clear mappings"); + conn.execute("DROP INDEX idx_agent_org_runs_one_live_per_root", []) + .expect("simulate pre-constraint database"); + conn.execute( + "INSERT INTO agent_org_runs ( + id, org_id, coordinator_agent_id, root_session_id, + entry_mode, status, created_at, updated_at + ) VALUES ('duplicate-live-run', ?1, ?2, ?3, + 'standalone_session', 'paused', ?4, ?4)", + params![ + &org.id, + &org.agent_id, + root, + chrono::Utc::now().to_rfc3339() + ], + ) + .expect("seed duplicate live legacy row"); + + let error = init_schema_with_report(&conn).expect_err("duplicate live roots must abort"); + assert!(error.to_string().contains("duplicate live Agent Org runs")); + let mapping_count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_org_run_sessions", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(mapping_count, 0); + let run_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_org_runs WHERE root_session_id=?1", + [root], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(run_count, 2); + assert!(load_by_id(&first.id).unwrap().is_some()); +} + #[test] fn delete_by_id_cascades_all_run_owned_state_and_plan_artifact() { let sandbox = test_helpers::test_env::sandbox(); @@ -430,7 +1039,26 @@ fn upsert_session_row_for_member( updated_at: chrono::Utc::now().to_rfc3339(), ..Default::default() }; - upsert_session(&record).expect("upsert session row"); + if agent_definition_id.is_some() && org_member_id.is_some() { + let parent = parent_session_id.expect("Rust Agent Org worker has a root parent"); + let conn = database::db::get_connection().expect("test sqlite connection"); + let run_id: Option = conn + .query_row( + "SELECT id FROM agent_org_runs WHERE root_session_id=?1", + [parent], + |row| row.get(0), + ) + .optional() + .expect("query owning fixture run"); + if let Some(run_id) = run_id { + AgentOrgRunStore::materialize_rust_worker_sessions(&run_id, &[record]) + .expect("transactionally materialize Rust worker fixture"); + } else { + upsert_session(&record).expect("upsert unmapped legacy session row"); + } + } else { + upsert_session(&record).expect("upsert session row"); + } } fn stamp_coordinator_terminal_turn(session_id: &str) { @@ -560,23 +1188,36 @@ fn context_for_session_preserves_org_hierarchy_mode() { } #[test] -fn context_for_session_with_parent_walk_one_hop_subagent() { +fn context_for_session_read_only_allows_unambiguous_legacy_worker() { let _sandbox = test_helpers::test_env::sandbox(); let org = sample_org(); let store = store_with_org(org.clone()); let _run = create_run_for_root(&org, "root-session-2"); upsert_session_row("root-session-2", None); upsert_session_row("worker-session-2", Some("root-session-2")); + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute( + "UPDATE agent_sessions SET agent_definition_id='agent-w1', org_member_id='member-w1' + WHERE session_id='worker-session-2'", + [], + ) + .expect("classify legacy Rust worker without mapping"); - let ctx = AgentOrgRunStore::context_for_session_with_parent_walk("worker-session-2", &store) - .expect("walk ok") - .expect("context resolved via parent walk"); + let ctx = AgentOrgRunStore::context_for_session_read_only_with_parent_walk( + "worker-session-2", + &store, + ) + .expect("walk ok") + .expect("context resolved via parent walk"); assert_eq!(ctx.run_id, _run.id); assert_eq!(ctx.coordinator_agent_id, "agent-coord"); + assert!( + AgentOrgRunStore::context_for_session_with_parent_walk("worker-session-2", &store).is_err() + ); } #[test] -fn context_for_session_with_parent_walk_cli_member_session() { +fn context_for_session_does_not_activate_legacy_cli_member_session() { let _sandbox = test_helpers::test_env::sandbox(); let org = sample_org(); let store = store_with_org(org.clone()); @@ -592,10 +1233,8 @@ fn context_for_session_with_parent_walk_cli_member_session() { let ctx = AgentOrgRunStore::context_for_session_with_parent_walk("cli-worker-session-walk", &store) - .expect("walk ok") - .expect("context resolved via CLI parent walk"); - assert_eq!(ctx.run_id, _run.id); - assert_eq!(ctx.coordinator_agent_id, "agent-coord"); + .expect("resolver remains diagnostic-safe"); + assert!(ctx.is_none()); } #[test] @@ -685,7 +1324,7 @@ fn find_worker_session_by_member_id_returns_descendant_with_matching_member_id() } #[test] -fn find_worker_session_by_member_id_returns_cli_member_session() { +fn find_worker_session_by_member_id_ignores_legacy_cli_member_session() { let _sandbox = test_helpers::test_env::sandbox(); let org = sample_org(); let _store = store_with_org(org.clone()); @@ -707,20 +1346,15 @@ fn find_worker_session_by_member_id_returns_cli_member_session() { let sessions = AgentOrgRunStore::list_worker_sessions_by_member_ids(&run.id, &["member-w1".to_string()]) .expect("query ok"); - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].session_id, "cli-worker-active"); - assert_eq!(sessions[0].agent_definition_id, None); - assert_eq!(sessions[0].cli_agent_type.as_deref(), Some("claude_code")); + assert!(sessions.is_empty()); - let info = AgentOrgRunStore::find_worker_session_by_member_id(&run.id, "member-w1") - .expect("query ok") - .expect("CLI worker found"); - assert_eq!(info.session_id, "cli-worker-active"); - assert_eq!(info.status, crate::core::session::SessionStatus::Running); + let info = + AgentOrgRunStore::find_worker_session_by_member_id(&run.id, "member-w1").expect("query ok"); + assert!(info.is_none()); } #[test] -fn find_worker_session_by_member_id_picks_most_recent_when_multi_instance() { +fn find_worker_session_by_member_id_uses_exact_mapping_not_fresher_unmapped_row() { let _sandbox = test_helpers::test_env::sandbox(); let org = sample_org(); let _store = store_with_org(org.clone()); @@ -734,25 +1368,23 @@ fn find_worker_session_by_member_id_picks_most_recent_when_multi_instance() { "completed", ); std::thread::sleep(std::time::Duration::from_millis(2)); - upsert_session_row_for_member( - "coord-w-new", - Some("coord-root-rotation"), - Some("agent-w1"), - Some("member-w1"), - "completed", - ); - upsert_session_row_for_member( - "coord-shared-other-member", - Some("coord-root-rotation"), - Some("agent-w1"), - Some("member-other"), - "completed", - ); - + let unmapped = UnifiedSessionRecord { + session_id: "coord-w-new".to_string(), + name: "unmapped duplicate".to_string(), + status: "completed".to_string(), + session_type: crate::core::session::persistence::session_type::ORG_MEMBER.to_string(), + parent_session_id: Some("coord-root-rotation".to_string()), + agent_definition_id: Some("agent-w1".to_string()), + org_member_id: Some("member-w1".to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }; + upsert_session(&unmapped).expect("seed unmapped legacy duplicate"); let info = AgentOrgRunStore::find_worker_session_by_member_id(&run.id, "member-w1") .expect("query ok") .expect("worker found"); - assert_eq!(info.session_id, "coord-w-new"); + assert_eq!(info.session_id, "coord-w-old"); } #[test] diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs index 4a777bbf83..a9d20e13ed 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/tests.rs @@ -24,6 +24,8 @@ fn fake_run(id: &str) -> AgentOrgRunRecord { created_at: now.clone(), updated_at: now, completed_at: None, + continued_from_run_id: None, + originating_message_id: None, } } diff --git a/src-tauri/crates/agent-core/src/core/definitions/commands.rs b/src-tauri/crates/agent-core/src/core/definitions/commands.rs index ec4b458dc8..21f208057f 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/commands.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/commands.rs @@ -153,6 +153,26 @@ pub async fn agent_org_run_list(limit: Option) -> Result, + limit: Option, +) -> Result { + if root_session_id.trim().is_empty() { + return Err("rootSessionId is required".to_string()); + } + tokio::task::spawn_blocking(move || { + crate::core::coordination::agent_org_runs::AgentOrgRunStore::list_timeline( + &root_session_id, + cursor, + limit, + ) + }) + .await + .map_err(|err| format!("Agent Org run timeline worker failed: {err}"))? +} // ───────────────────────────────────────────────────────────────────────────── // RPC contract §13 — Typed patch RPCs. // diff --git a/src-tauri/crates/agent-core/src/core/definitions/orgs.rs b/src-tauri/crates/agent-core/src/core/definitions/orgs.rs index aa63d1149c..5b78a7a36a 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/orgs.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/orgs.rs @@ -791,6 +791,7 @@ mod tests { #[test] fn validation_rejects_cli_member_with_member_id_and_transport() { let _sandbox = test_helpers::test_env::sandbox(); + let store = AgentOrgsStore::new(); let mut org = custom_org(); org.children.push(OrgMember { id: "cli-worker".to_string(), @@ -801,9 +802,33 @@ mod tests { children: Vec::new(), }); - let error = AgentOrgsStore::validate_agent_references(&org) + let error = store + .insert(org) .expect_err("CLI Agent Org members must be rejected at save time"); assert!(error.contains("member_id=cli-worker"), "{error}"); assert!(error.contains("cli:claude_code"), "{error}"); + assert!(error.contains("inbox"), "{error}"); + assert!(error.contains("task tools"), "{error}"); + assert!(store.get("custom-org").is_err()); + } + + #[test] + fn validation_rejects_cli_coordinator_when_saving_org() { + let _sandbox = test_helpers::test_env::sandbox(); + let store = AgentOrgsStore::new(); + let mut org = custom_org(); + org.id = "cli-coordinator-org".to_string(); + org.name = "CLI Coordinator Org".to_string(); + org.agent_id = "cli:claude_code".to_string(); + + let error = store + .insert(org) + .expect_err("CLI Coordinator must be rejected at save time"); + + assert!(error.contains("coordinator"), "{error}"); + assert!(error.contains("cli:claude_code"), "{error}"); + assert!(error.contains("inbox"), "{error}"); + assert!(error.contains("task tools"), "{error}"); + assert!(store.get("cli-coordinator-org").is_err()); } } diff --git a/src-tauri/crates/agent-core/src/core/session/launch/launch_helpers.rs b/src-tauri/crates/agent-core/src/core/session/launch/launch_helpers.rs index 54e3fd4730..78eccc07d9 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/launch_helpers.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/launch_helpers.rs @@ -72,15 +72,6 @@ pub(super) fn validate_launch_agent_definitions( let store = crate::definitions::definitions_store(); - if let Some(definition_id) = agent_definition_id.filter(|id| !id.trim().is_empty()) { - if store.get(definition_id).is_none() { - return Err(format!( - "Agent definition '{}' does not exist; remove the stale session or choose an existing Agent definition before launching", - definition_id - )); - } - } - if let Some(org) = org_definition { let mut missing: Vec = Vec::new(); let mut unsupported_cli: Vec = Vec::new(); @@ -145,6 +136,15 @@ pub(super) fn validate_launch_agent_definitions( } } + if let Some(definition_id) = agent_definition_id.filter(|id| !id.trim().is_empty()) { + if store.get(definition_id).is_none() { + return Err(format!( + "Agent definition '{}' does not exist; remove the stale session or choose an existing Agent definition before launching", + definition_id + )); + } + } + Ok(()) } diff --git a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs index 719c27a020..9a22e8c179 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs @@ -134,78 +134,86 @@ pub(super) async fn materialize_org_member_sessions( let rust_work_item_id = work_item_id.clone(); let rust_project_slug = project_slug.clone(); let rust_org_run_id = org_run_id.clone(); - created_rust_session_ids = tokio::task::spawn_blocking(move || { - let now = chrono::Utc::now().to_rfc3339(); - let mut created_session_ids: Vec = Vec::with_capacity(rust_members.len()); - let has_workspace_path = !rust_workspace_path.is_empty(); + created_rust_session_ids = + tokio::task::spawn_blocking(move || -> Result, String> { + let now = chrono::Utc::now().to_rfc3339(); + let mut sessions = Vec::with_capacity(rust_members.len()); + let has_workspace_path = !rust_workspace_path.is_empty(); - for member in rust_members { - let prefix = crate::definitions::prefix_lookup::session_prefix_for_launch( - Some(&member.agent_id), - has_workspace_path, - ); - let session_id = format!("{}{}", prefix, uuid::Uuid::new_v4()); - let member_config = member.runtime_config.as_ref(); - let member_model = member_runtime_model(member_config, &rust_model); - let member_account_id = member_runtime_account_id(member_config, &rust_account_id); - let member_key_source = member_runtime_key_source(member_config, &rust_key_source) - .map_err(|err| format!("invalid runtime config for member '{}': {}", member.name, err))?; - let member_native_harness_type = - member_runtime_native_harness_type(member_config, &rust_native_harness_type) - .map_err(|err| format!("invalid runtime config for member '{}': {}", member.name, err))?; - let session = UnifiedSessionRecord { - session_id: session_id.clone(), - name: format!("{} · {}", member.name, member.role), - status: crate::session::SessionStatus::Idle.as_str().to_string(), - model: member_model, - account_id: member_account_id, - workspace_path: Some(rust_workspace_path.clone()), - org_id: Some(project_management::projects::types::PERSONAL_ORG_ID.to_string()), - user_input: None, - total_tokens: 0, - created_at: now.clone(), - updated_at: now.clone(), - session_type: session_type::ORG_MEMBER.to_string(), - work_item_id: rust_work_item_id.clone(), - agent_role: Some(member.role.clone()), - project_slug: rust_project_slug.clone(), - agent_definition_id: Some(member.agent_id.clone()), - org_member_id: Some(member.id.clone()), - parent_session_id: Some(rust_root_session_id.clone()), - key_source: member_key_source, - agent_exec_mode: rust_agent_exec_mode.clone(), - native_harness_type: member_native_harness_type, - ..Default::default() - }; - if let Err(err) = session_persistence::upsert_session(&session) { - for created_session_id in &created_session_ids { - if let Err(cleanup_err) = session_persistence::delete_session(created_session_id) - { - tracing::warn!( - session_id = %created_session_id, - error = %cleanup_err, - "[session_launch] failed to clean up materialized Agent Org member session" - ); - } - } - return Err(format!( - "failed to materialize Agent Org member '{}' for run '{}': {}", - member.name, rust_org_run_id, err - )); + for member in rust_members { + let prefix = crate::definitions::prefix_lookup::session_prefix_for_launch( + Some(&member.agent_id), + has_workspace_path, + ); + let session_id = format!("{}{}", prefix, uuid::Uuid::new_v4()); + let member_config = member.runtime_config.as_ref(); + let member_model = member_runtime_model(member_config, &rust_model); + let member_account_id = + member_runtime_account_id(member_config, &rust_account_id); + let member_key_source = + member_runtime_key_source(member_config, &rust_key_source).map_err( + |err| { + format!( + "invalid runtime config for member '{}': {}", + member.name, err + ) + }, + )?; + let member_native_harness_type = member_runtime_native_harness_type( + member_config, + &rust_native_harness_type, + ) + .map_err(|err| { + format!( + "invalid runtime config for member '{}': {}", + member.name, err + ) + })?; + let session = UnifiedSessionRecord { + session_id: session_id.clone(), + name: format!("{} · {}", member.name, member.role), + status: crate::session::SessionStatus::Idle.as_str().to_string(), + model: member_model, + account_id: member_account_id, + workspace_path: Some(rust_workspace_path.clone()), + org_id: Some( + project_management::projects::types::PERSONAL_ORG_ID.to_string(), + ), + user_input: None, + total_tokens: 0, + created_at: now.clone(), + updated_at: now.clone(), + session_type: session_type::ORG_MEMBER.to_string(), + work_item_id: rust_work_item_id.clone(), + agent_role: Some(member.role.clone()), + project_slug: rust_project_slug.clone(), + agent_definition_id: Some(member.agent_id.clone()), + org_member_id: Some(member.id.clone()), + parent_session_id: Some(rust_root_session_id.clone()), + key_source: member_key_source, + agent_exec_mode: rust_agent_exec_mode.clone(), + native_harness_type: member_native_harness_type, + ..Default::default() + }; + sessions.push(session); } - created_session_ids.push(session_id); - } - tracing::info!( - run_id = %rust_org_run_id, - org_name = %rust_org_name, - member_sessions = created_session_ids.len(), - "[session_launch] materialized Rust Agent Org member sessions" - ); - Ok(created_session_ids) - }) - .await - .map_err(|err| err.to_string())??; + AgentOrgRunStore::materialize_rust_worker_sessions(&rust_org_run_id, &sessions)?; + let created_session_ids = sessions + .into_iter() + .map(|session| session.session_id) + .collect::>(); + + tracing::info!( + run_id = %rust_org_run_id, + org_name = %rust_org_name, + member_sessions = created_session_ids.len(), + "[session_launch] materialized Rust Agent Org member sessions" + ); + Ok(created_session_ids) + }) + .await + .map_err(|err| err.to_string())??; created_session_ids.extend(created_rust_session_ids.iter().cloned()); } @@ -344,7 +352,8 @@ pub(super) async fn send_initial_turn( agent_definition_id.as_deref(), &sub_agent_ids, ) - .await?; + .await? + .with_agent_org_run_hint(intent_org_run_id.clone()); crate::init::init_session(state, launch_spec).await?; crate::state::commands::session::message::send_message_impl( diff --git a/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs b/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs index 7673db5271..ac17c882da 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs @@ -3,12 +3,17 @@ use super::launch_helpers::{ member_runtime_key_source, member_runtime_model, member_runtime_native_harness_type, member_runtime_tier, validate_launch_agent_definitions, }; +use super::{ + launch_rust_agent_run, AgentRunLaunchRequest, AgentRunTarget, LaunchOrgContext, + LaunchProvenance, LaunchResourceSelection, WorkspaceLaunchTarget, +}; use crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID; use crate::definitions::builtin::SDE_AGENT_ID; use crate::definitions::orgs::{ - HierarchyMode, OrgDefinition, OrgMember, OrgMemberLaunchOverride, OrgMemberRuntimeConfig, - PlanApprovalPolicy, + AgentOrgsStore, HierarchyMode, OrgDefinition, OrgMember, OrgMemberLaunchOverride, + OrgMemberRuntimeConfig, PlanApprovalPolicy, }; +use crate::state::AgentAppState; use core_types::key_source::KeySource; use std::collections::HashMap; @@ -169,23 +174,99 @@ fn launch_validation_rejects_agent_org_with_missing_member_definition() { assert!(error.contains("custom:deleted-worker"), "{error}"); } -#[test] -fn launch_validation_rejects_cli_member_before_run_materialization() { - let _sandbox = test_helpers::test_env::sandbox(); - let org = valid_org_with_children(vec![OrgMember { - id: "cli-worker".to_string(), - name: "CLI Worker".to_string(), - role: "Builder".to_string(), - agent_id: "cli:claude_code".to_string(), - runtime_config: None, - children: Vec::new(), - }]); +#[tokio::test] +async fn launch_validation_rejects_cli_participants_before_session_or_run_creation() { + let sandbox = test_helpers::test_env::sandbox(); + let conn = database::db::get_connection().expect("test sqlite connection"); + crate::foundation::persistence::test_schema::ensure_agent_sessions_schema(&conn); + crate::foundation::persistence::session_snapshots::ensure_tables_with(&conn) + .expect("session snapshot schema"); + crate::session::persistence::init(&conn).expect("unified session schema"); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org runtime schemas"); - let error = validate_launch_agent_definitions(Some(SDE_AGENT_ID), Some(&org)) - .expect_err("CLI Agent Org members are not production-capable yet"); - assert!(error.contains("cli-worker"), "{error}"); - assert!(error.contains("cli:claude_code"), "{error}"); - assert!(error.contains("inbox"), "{error}"); + let store = AgentOrgsStore::new(); + let state = AgentAppState::new(); + for (label, cli_coordinator, expected_participant) in [ + ("coordinator", true, "coordinator"), + ("worker", false, "cli-worker"), + ] { + let children = if cli_coordinator { + Vec::new() + } else { + vec![OrgMember { + id: "cli-worker".to_string(), + name: "CLI Worker".to_string(), + role: "Builder".to_string(), + agent_id: "cli:claude_code".to_string(), + runtime_config: None, + children: Vec::new(), + }] + }; + let mut org = valid_org_with_children(children); + org.id = format!("test:cli-{label}-launch"); + org.name = format!("CLI {label} Launch"); + if cli_coordinator { + org.agent_id = "cli:claude_code".to_string(); + } + store + .seed_for_test(org.clone()) + .expect("seed unsupported CLI participant fixture"); + + let count_rows = || -> (i64, i64, i64) { + conn.query_row( + "SELECT (SELECT COUNT(*) FROM agent_sessions), + (SELECT COUNT(*) FROM agent_org_runs), + (SELECT COUNT(*) FROM agent_org_run_sessions)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("count Agent Org persistence rows") + }; + let before = count_rows(); + let error = launch_rust_agent_run( + &state, + Some(&store), + AgentRunLaunchRequest { + content: "must reject before materialization".to_string(), + target: AgentRunTarget::AgentOrg { + agent_org_id: org.id, + agent_definition_id: None, + member_overrides: HashMap::new(), + apply_member_overrides_for_future: false, + }, + resources: LaunchResourceSelection { + key_source: None, + account_id: None, + model: None, + native_harness_type: None, + }, + workspace: WorkspaceLaunchTarget::LocalWorkspace { + workspace_path: sandbox.path().to_string_lossy().into_owned(), + additional_directories: Vec::new(), + }, + org_context: LaunchOrgContext { + org_id: project_management::projects::types::PERSONAL_ORG_ID.to_string(), + project_id: None, + project_name: None, + }, + provenance: LaunchProvenance::UserSession, + mode: None, + name: None, + images: None, + ide_context: None, + parent_session_id: None, + sub_agent_ids: Vec::new(), + }, + ) + .await + .expect_err("CLI participant must fail before Root Session and Run creation"); + + assert!(error.contains(expected_participant), "{label}: {error}"); + assert!(error.contains("cli:claude_code"), "{label}: {error}"); + assert!(error.contains("inbox"), "{label}: {error}"); + assert!(error.contains("task tools"), "{label}: {error}"); + assert_eq!(count_rows(), before, "{label}: rejected launch mutated DB"); + } } #[test] diff --git a/src-tauri/crates/agent-core/src/core/session/launch/mod.rs b/src-tauri/crates/agent-core/src/core/session/launch/mod.rs index b8933b0896..0be21edaa9 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/mod.rs @@ -18,11 +18,9 @@ use tauri::Manager; use crate::coordination::agent_org_runs::{ AgentOrgRunEntryMode, AgentOrgRunStatus, AgentOrgRunStore, CreateAgentOrgRunParams, - COORDINATOR_MEMBER_ID, }; use crate::definitions::orgs::{AgentOrgsStore, OrgMemberLaunchOverride}; use crate::init::launch_spec::AgentLaunchSpec; -use crate::session::persistence; use crate::session::IdeContext; use crate::state::AgentAppState; use project_management::projects::types as project_types; @@ -465,6 +463,23 @@ pub(crate) async fn launch_rust_agent_run( .as_ref() .map(|entry| entry.branch.clone()); + let agent_org_run_params = match (agent_org_id.as_ref(), coordinator_agent_id.as_ref()) { + (Some(org_id), Some(coordinator_id)) => Some(CreateAgentOrgRunParams { + org_id: org_id.clone(), + coordinator_agent_id: coordinator_id.clone(), + root_session_id: None, + org_snapshot: effective_org_definition + .as_ref() + .ok_or("Agent Org launch is missing resolved org definition")? + .clone(), + entry_mode: AgentOrgRunEntryMode::StandaloneSession, + status: AgentOrgRunStatus::Running, + work_item_id: work_item_id.clone(), + project_slug: project_slug.clone(), + routine_fire_id: routine_fire_id.clone(), + }), + _ => None, + }; let create_result = crate::state::commands::session::create::create_session_impl( None, workspace_path.clone(), @@ -483,14 +498,11 @@ pub(crate) async fn launch_rust_agent_run( request.mode.clone(), request.resources.native_harness_type.clone(), request.parent_session_id.clone(), + agent_org_run_params, ) .await?; - let session_id = create_result - .get("sessionId") - .and_then(|value| value.as_str()) - .ok_or("create_session_impl did not return sessionId")? - .to_string(); + let session_id = create_result.session_id.clone(); if let (Some(project_slug_value), Some(work_item_id_value)) = (project_slug.as_deref(), work_item_id.as_deref()) @@ -504,63 +516,51 @@ pub(crate) async fn launch_rust_agent_run( ) .await { + if let Some(run) = create_result.agent_org_run.as_ref() { + if let Err(delete_err) = AgentOrgRunStore::delete_by_id(&run.id) { + tracing::warn!( + run_id = %run.id, + error = %delete_err, + "failed to remove Agent Org run after work-item lock failure" + ); + return Err(format!( + "{err}; Agent Org cleanup also failed, so the consistent Root Session and Run were retained: {delete_err}" + )); + } + } cleanup_session_after_org_run_create_failure(session_id.clone()).await; return Err(err); } } - let agent_org_run_id = match (agent_org_id.as_ref(), coordinator_agent_id.as_ref()) { - (Some(org_id), Some(coordinator_id)) => { - let org_snapshot = effective_org_definition - .as_ref() - .ok_or("Agent Org launch is missing resolved org definition")? - .clone(); - let run = AgentOrgRunStore::create(CreateAgentOrgRunParams { - org_id: org_id.clone(), - coordinator_agent_id: coordinator_id.clone(), - root_session_id: Some(session_id.clone()), - org_snapshot, - entry_mode: AgentOrgRunEntryMode::StandaloneSession, - status: AgentOrgRunStatus::Running, - work_item_id: work_item_id.clone(), - project_slug: project_slug.clone(), - routine_fire_id, - }); - match run { - Ok(record) => { - persistence::update_org_member_id(&session_id, COORDINATOR_MEMBER_ID) - .map_err(|err| format!("failed to persist coordinator member_id: {err}"))?; - if let Some(org) = effective_org_definition.as_ref() { - spawn_agent_org_member_materialization( - record.id.clone(), - org.clone(), - session_id.clone(), - name.clone(), - workspace_path.clone(), - request.resources.model.clone(), - request.resources.account_id.clone(), - request.resources.key_source.clone(), - request.mode.clone(), - request.resources.native_harness_type.clone(), - work_item_id.clone(), - project_slug.clone(), - ); - } - if apply_member_overrides_for_future { - if let Some(store) = org_store { - store.apply_member_launch_overrides(org_id, &member_overrides)?; - } - } - Some(record.id) - } - Err(err) => { - cleanup_session_after_org_run_create_failure(session_id.clone()).await; - return Err(err); - } - } + let agent_org_run_id = create_result + .agent_org_run + .as_ref() + .map(|record| record.id.clone()); + if let (Some(record), Some(org)) = ( + create_result.agent_org_run.as_ref(), + effective_org_definition.as_ref(), + ) { + spawn_agent_org_member_materialization( + record.id.clone(), + org.clone(), + session_id.clone(), + name.clone(), + workspace_path.clone(), + request.resources.model.clone(), + request.resources.account_id.clone(), + request.resources.key_source.clone(), + request.mode.clone(), + request.resources.native_harness_type.clone(), + work_item_id.clone(), + project_slug.clone(), + ); + } + if apply_member_overrides_for_future { + if let (Some(store), Some(org_id)) = (org_store, agent_org_id.as_deref()) { + store.apply_member_launch_overrides(org_id, &member_overrides)?; } - _ => None, - }; + } let created_at = chrono::Utc::now().to_rfc3339(); let has_initial_content = !request.content.trim().is_empty(); diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs index 14baa920c6..7162c193dc 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs @@ -28,13 +28,14 @@ pub use ops::{ mark_stale_running_sessions_abandoned, reconcile_sessions_with_terminal_turn_markers, register_session_delete_mirror_hook, register_session_mirror_hook, update_account_id, update_agent_exec_mode, update_draft_text, update_model, update_model_and_account, update_name, - update_org_member_id, update_pinned, update_reply_target_event_id, update_status, - update_work_item_link, upsert_session, + update_pinned, update_reply_target_event_id, update_status, update_work_item_link, + upsert_session, }; -pub(super) use record::{row_to_record, UNIFIED_SESSION_SELECT}; pub(crate) use ops::{ - delete_session_with_connection, finish_session_delete, prepare_session_delete, + delete_session_with_connection, finish_session_delete, notify_session_upserted, + prepare_session_delete, upsert_session_with_connection, }; +pub(super) use record::{row_to_record, UNIFIED_SESSION_SELECT}; pub use record::{session_type, UnifiedSessionRecord}; pub use workspace::{ clear_worktree_metadata, load_workspace, save_workspace, save_worktree_metadata, 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 fce89c9b33..e08a08ae6e 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 @@ -4,7 +4,7 @@ //! mapper in [`super::record`] so the column list stays in one place. use chrono::Utc; -use rusqlite::{params, Result as SqliteResult}; +use rusqlite::{params, OptionalExtension, Result as SqliteResult}; use tracing::warn; use crate::persistence::db_helpers as shared; @@ -23,6 +23,7 @@ const SESSION_DELETE_TABLES: &[&str] = &[ "session_tool_usage", "events", "pending_plan_approvals", + "agent_org_run_sessions", "agent_sessions", ]; @@ -52,6 +53,10 @@ fn notify_session_mirror(session_id: &str) { crate::coordination::agent_org_run_events::notify_agent_org_session_changed(session_id); } +pub(crate) fn notify_session_upserted(session_id: &str) { + notify_session_mirror(session_id); +} + /// Companion delete hook: the upsert-style mirror hook cannot serve deletes /// (re-reading a deleted session would mirror a stub back), so removals get /// their own registration. @@ -157,51 +162,90 @@ ON CONFLICT(session_id) DO UPDATE SET pub fn upsert_session(record: &UnifiedSessionRecord) -> SqliteResult<()> { with_sessions_writer(|| -> SqliteResult<()> { let conn = get_connection()?; - let key_source_str = record.key_source.as_ref(); - conn.execute( - UPSERT_SESSION_SQL, - params![ - record.session_id, - record.name, - record.status, - record.model, - record.account_id, - record.user_input, - record.created_at, - record.updated_at, - record.session_type, - record.channel, - record.chat_id, - record.workspace_path, - record.org_id, - record.project_id, - record.project_name, - record.work_item_id, - record.agent_role, - record.worktree_path, - record.worktree_branch, - record.base_branch, - record.merge_status, - record.project_slug, - record.agent_definition_id, - record.org_member_id, - record.parent_session_id, - record.parent_event_id, - record.workspace_additional_json, - key_source_str, - record.agent_exec_mode, - record.native_harness_type, - record.draft_text, - record.reply_target_event_id, - record.pinned as i64, - ], - )?; - Ok(()) + upsert_session_with_connection(&conn, record) })?; notify_session_mirror(&record.session_id); Ok(()) } +pub(crate) fn upsert_session_with_connection( + conn: &rusqlite::Connection, + record: &UnifiedSessionRecord, +) -> SqliteResult<()> { + if let Some(proposed_member_id) = record.org_member_id.as_deref() { + let has_mapping_table: bool = conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM sqlite_master + WHERE type='table' AND name='agent_org_run_sessions' + )", + [], + |row| row.get(0), + )?; + let mapped_member_id = has_mapping_table + .then(|| { + conn.query_row( + "SELECT member_id FROM agent_org_run_sessions + WHERE session_id=?1 LIMIT 1", + [&record.session_id], + |row| row.get::<_, String>(0), + ) + .optional() + }) + .transpose()? + .flatten(); + if mapped_member_id + .as_deref() + .is_some_and(|id| id != proposed_member_id) + { + return Err(rusqlite::Error::InvalidParameterName(format!( + "Agent Org session {} is mapped as member {} and cannot be saved as {proposed_member_id}", + record.session_id, + mapped_member_id.as_deref().unwrap_or_default() + ))); + } + } + let key_source_str = record.key_source.as_ref(); + conn.execute( + UPSERT_SESSION_SQL, + params![ + record.session_id, + record.name, + record.status, + record.model, + record.account_id, + record.user_input, + record.created_at, + record.updated_at, + record.session_type, + record.channel, + record.chat_id, + record.workspace_path, + record.org_id, + record.project_id, + record.project_name, + record.work_item_id, + record.agent_role, + record.worktree_path, + record.worktree_branch, + record.base_branch, + record.merge_status, + record.project_slug, + record.agent_definition_id, + record.org_member_id, + record.parent_session_id, + record.parent_event_id, + record.workspace_additional_json, + key_source_str, + record.agent_exec_mode, + record.native_harness_type, + record.draft_text, + record.reply_target_event_id, + record.pinned as i64, + ], + )?; + Ok(()) +} + /// Get a session by ID. pub fn get_session(session_id: &str) -> SqliteResult> { let conn = get_connection()?; @@ -453,22 +497,6 @@ pub fn update_work_item_link( }) } -/// Set the canonical Agent Org roster member id for a session. -pub fn update_org_member_id(session_id: &str, org_member_id: &str) -> SqliteResult { - let changed = with_sessions_writer(|| -> SqliteResult { - let conn = get_connection()?; - let updated = conn.execute( - "UPDATE agent_sessions SET org_member_id = ?2 WHERE session_id = ?1", - params![session_id, org_member_id], - )?; - Ok(updated > 0) - })?; - if changed { - notify_session_mirror(session_id); - } - Ok(changed) -} - // `updated_at` invariant // ---------------------- // `agent_sessions.updated_at` reflects **real conversation activity** — 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 f8ea1d6dfc..53f60e965f 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 @@ -29,17 +29,18 @@ pub use crud::{ reconcile_sessions_with_terminal_turn_markers, register_session_delete_mirror_hook, register_session_mirror_hook, save_workspace, save_worktree_metadata, session_type, update_account_id, update_agent_exec_mode, update_draft_text, update_model, - update_model_and_account, update_name, update_org_member_id, update_pinned, - update_reply_target_event_id, update_status, update_work_item_link, - update_worktree_merge_status, upsert_session, UnifiedSessionRecord, + update_model_and_account, update_name, update_pinned, update_reply_target_event_id, + update_status, update_work_item_link, update_worktree_merge_status, upsert_session, + UnifiedSessionRecord, +}; +pub(crate) use crud::{ + delete_session_with_connection, finish_session_delete, notify_session_upserted, + prepare_session_delete, upsert_session_with_connection, }; pub use sidebar::{ list_agent_org_root_sessions_page, list_standalone_coding_sessions_page, list_unpinned_sessions_by_type_page, }; -pub(crate) use crud::{ - delete_session_with_connection, finish_session_delete, prepare_session_delete, -}; pub use messages::{ anchor_at_or_after_created_at, append_compact_boundary, clear_messages, diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/sidebar.rs b/src-tauri/crates/agent-core/src/core/session/persistence/sidebar.rs index 20c5d3422c..11792e92b9 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/sidebar.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/sidebar.rs @@ -179,7 +179,7 @@ mod tests { .expect("upsert sidebar session"); } - fn insert_agent_org_run(run_id: &str, root_session_id: &str, updated_at: &str) { + fn insert_agent_org_run(run_id: &str, root_session_id: &str, updated_at: &str, status: &str) { ensure_runtime_schemas(); let conn = database::db::get_connection().expect("test sqlite connection"); conn.execute( @@ -199,7 +199,7 @@ mod tests { "builtin:sde", root_session_id, "standalone_session", - "running", + status, updated_at, ], ) @@ -245,9 +245,24 @@ mod tests { session_type::CODING, Some("org-root-b"), ); - insert_agent_org_run("run-root-a-old", "org-root-a", "2026-07-29T09:00:00Z"); - insert_agent_org_run("run-root-b", "org-root-b", "2026-07-29T14:00:00Z"); - insert_agent_org_run("run-root-a-new", "org-root-a", "2026-07-29T10:00:00Z"); + insert_agent_org_run( + "run-root-a-old", + "org-root-a", + "2026-07-29T09:00:00Z", + "completed", + ); + insert_agent_org_run( + "run-root-b", + "org-root-b", + "2026-07-29T14:00:00Z", + "running", + ); + insert_agent_org_run( + "run-root-a-new", + "org-root-a", + "2026-07-29T10:00:00Z", + "running", + ); upsert_sidebar_session( "newer-worker", "2026-07-29T15:00:00Z", diff --git a/src-tauri/crates/agent-core/src/init/fast_path.rs b/src-tauri/crates/agent-core/src/init/fast_path.rs index 6080fa7731..9b49e6e946 100644 --- a/src-tauri/crates/agent-core/src/init/fast_path.rs +++ b/src-tauri/crates/agent-core/src/init/fast_path.rs @@ -33,6 +33,7 @@ pub(super) async fn try_reuse_existing( account_id: Option<&str>, requested_model: Option<&str>, workspace_root: &Path, + durable_agent_org_run_id: Option<&str>, ) -> Option> { let session = state.get_session(session_id).await?; let existing = session.get_runtime().await?; @@ -47,8 +48,13 @@ pub(super) async fn try_reuse_existing( None => true, }; let project_matches = existing.workspace_state.read().working_dir() == workspace_root; + let ownership_matches = existing + .agent_org_context + .as_ref() + .map(|context| context.run_id.as_str()) + == durable_agent_org_run_id; - if account_matches && model_matches && project_matches { + if account_matches && model_matches && project_matches && ownership_matches { Some(existing) } else { tracing::info!("[init] Session {} needs reinitialization", session_id); diff --git a/src-tauri/crates/agent-core/src/init/launch_spec.rs b/src-tauri/crates/agent-core/src/init/launch_spec.rs index 0c7cb0f85b..c9bda4db9c 100644 --- a/src-tauri/crates/agent-core/src/init/launch_spec.rs +++ b/src-tauri/crates/agent-core/src/init/launch_spec.rs @@ -13,6 +13,7 @@ pub struct AgentLaunchSpec { pub account_id: Option, pub model_override: Option, pub native_harness_type: Option, + pub agent_org_run_hint: Option, } impl AgentLaunchSpec { @@ -31,9 +32,15 @@ impl AgentLaunchSpec { account_id, model_override: model_override.filter(|model| !model.is_empty()), native_harness_type, + agent_org_run_hint: None, } } + pub fn with_agent_org_run_hint(mut self, run_id: Option) -> Self { + self.agent_org_run_hint = run_id.filter(|value| !value.is_empty()); + self + } + pub async fn from_session_sources( state: &AgentAppState, session_id: &str, diff --git a/src-tauri/crates/agent-core/src/init/mod.rs b/src-tauri/crates/agent-core/src/init/mod.rs index 8588e3bcdd..6dbd53d093 100644 --- a/src-tauri/crates/agent-core/src/init/mod.rs +++ b/src-tauri/crates/agent-core/src/init/mod.rs @@ -111,6 +111,7 @@ struct UnifiedInitRequest<'a> { model_override: Option<&'a str>, /// Provider override for subscription-bound native harness sessions. native_harness_type: Option, + agent_org_run_hint: Option<&'a str>, } /// Build the `(ResolvedAgent, IntegrationsConfig, SessionOverrides)` triad @@ -206,6 +207,7 @@ pub async fn init_session( account_id, model_override, native_harness_type, + agent_org_run_hint, } = spec; let (resolved, integrations, overrides) = resolve_for_session(state, &definition, workspace, model_override.as_deref())?; @@ -219,6 +221,7 @@ pub async fn init_session( overrides, model_override: model_override.as_deref(), native_harness_type, + agent_org_run_hint: agent_org_run_hint.as_deref(), }) .await } @@ -235,19 +238,24 @@ pub async fn init_session( fn load_agent_org_context( state: &AgentAppState, session_id: &str, -) -> Option { + run_hint: Option<&str>, +) -> 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); }; use tauri::Manager; let org_store = handle.state::>(); - match crate::coordination::agent_org_runs::AgentOrgRunStore::context_for_session_with_parent_walk( + match crate::coordination::agent_org_runs::AgentOrgRunStore::context_for_session( session_id, org_store.inner(), + crate::coordination::agent_org_runs::AgentOrgRunResolution::mutable( + run_hint.map(str::to_string), + ), + crate::coordination::agent_org_runs::AgentOrgRunResolutionAccess::Mutable, ) { Ok(Some(ctx)) => { // Surfacing this at info is intentional: the runtime visibility @@ -263,14 +271,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!( @@ -278,7 +286,7 @@ fn load_agent_org_context( error = %err, "[init] failed to load Agent Org context" ); - None + Err(err) } } } @@ -303,6 +311,7 @@ async fn ensure_session_initialized( overrides, model_override, native_harness_type, + agent_org_run_hint, } = request; let workspace_root = resolved.workspace().to_path_buf(); @@ -319,6 +328,7 @@ async fn ensure_session_initialized( _ if !resolved.selected_model_id.is_empty() => Some(resolved.selected_model_id.clone()), _ => None, }; + let agent_org_context = load_agent_org_context(state, session_id, agent_org_run_hint)?; // Fast path: re-entrant init for an already-running session. if let Some(existing) = fast_path::try_reuse_existing( @@ -327,6 +337,9 @@ async fn ensure_session_initialized( account_id, requested_model.as_deref(), &workspace_root, + agent_org_context + .as_ref() + .map(|context| context.run_id.as_str()), ) .await { @@ -451,8 +464,6 @@ 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_browser_config = { let controller = state.agent_browser.lock().await; controller.config() @@ -461,9 +472,19 @@ async fn ensure_session_initialized( let session_record = crate::session::persistence::get_session(session_id) .ok() .flatten(); - let agent_org_current_member_id = session_record - .as_ref() - .and_then(|record| record.org_member_id.clone()); + let agent_org_current_member_id = match agent_org_context.as_ref() { + Some(context) if context.root_session_id.as_deref() == Some(session_id) => { + Some(crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID.to_string()) + } + Some(_) => { + crate::coordination::agent_org_runs::AgentOrgRunStore::member_id_for_mapped_session( + session_id, + )? + } + None => session_record + .as_ref() + .and_then(|record| record.org_member_id.clone()), + }; let session_org_id = session_record.and_then(|record| record.org_id); let mut readonly_extra_dirs = vec![crate::skills::loader::global_skills_dir()]; diff --git a/src-tauri/crates/agent-core/src/state/commands/session/create.rs b/src-tauri/crates/agent-core/src/state/commands/session/create.rs index 9552b44902..d5e5ef518a 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/create.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/create.rs @@ -6,6 +6,11 @@ use core_types::key_source::KeySource; use core_types::providers::NativeHarnessType; use project_management::projects::types::PERSONAL_ORG_ID; +pub(crate) struct CreatedSession { + pub(crate) session_id: String, + pub(crate) agent_org_run: Option, +} + /// Default agent type when none is provided by the caller. const DEFAULT_AGENT_TYPE: &str = "sde"; @@ -48,7 +53,8 @@ pub(crate) async fn create_session_impl( agent_exec_mode: Option, native_harness_type: Option, parent_session_id: Option, -) -> Result { + mut agent_org_run: Option, +) -> Result { // Trace the incoming key_source so drift between frontend and // backend posture is visible in logs. The field is now persisted // end-to-end on the rust-agent path (`agent_sessions.key_source` @@ -129,6 +135,9 @@ pub(crate) async fn create_session_impl( worktree_path, project_slug, agent_definition_id, + org_member_id: agent_org_run + .as_ref() + .map(|_| crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID.to_string()), parent_session_id, key_source: resolved_key_source, // Persist the user's launch-time mode choice (from `SessionLaunchParams.mode`) @@ -141,10 +150,24 @@ pub(crate) async fn create_session_impl( ..Default::default() }; - tokio::task::spawn_blocking(move || session_persistence::upsert_session(&session)) - .await - .map_err(|err| err.to_string())? - .map_err(|err| err.to_string())?; + if let Some(params) = agent_org_run.as_mut() { + params.root_session_id = Some(session_id.clone()); + } + let persisted_run = tokio::task::spawn_blocking(move || match agent_org_run { + Some(params) => { + crate::coordination::agent_org_runs::AgentOrgRunStore::create_with_coordinator_session( + params, + Default::default(), + &session, + ) + .map(Some) + } + None => session_persistence::upsert_session(&session) + .map(|()| None) + .map_err(|err| err.to_string()), + }) + .await + .map_err(|err| err.to_string())??; tracing::info!("[agent_session] Created session: {}", session_id); @@ -205,8 +228,8 @@ pub(crate) async fn create_session_impl( } } - Ok(serde_json::json!({ - "sessionId": session_id, - "workspacePath": workspace_path, - })) + Ok(CreatedSession { + session_id, + agent_org_run: persisted_run, + }) } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs index 093539ef57..a05fca57b0 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs @@ -14,65 +14,36 @@ pub(super) fn promote_agent_org_wake_session_to_running( run_id: &str, session_id: &str, ) -> Result { - use crate::coordination::agent_org_runs::{AgentOrgRunStatus, COORDINATOR_MEMBER_ID}; + use crate::coordination::agent_org_runs::AgentOrgRunStatus; use crate::session::SessionStatus; let wakeable = SessionStatus::AGENT_ORG_WAKEABLE; let now = chrono::Utc::now().to_rfc3339(); conn.execute( - "WITH RECURSIVE - run_anchor(root_session_id) AS ( - SELECT root_session_id - FROM agent_org_runs - WHERE id=?4 AND status=?5 AND root_session_id IS NOT NULL - ), - descendants(session_id) AS ( - SELECT root_session_id FROM run_anchor - UNION - SELECT child.session_id - FROM agent_sessions child - JOIN descendants parent ON child.parent_session_id=parent.session_id - WHERE NOT EXISTS ( - SELECT 1 FROM agent_org_runs nested - WHERE nested.id<>?4 - AND nested.root_session_id=child.session_id - ) - ), - ranked(session_id, member_rank) AS ( - SELECT session.session_id, - ROW_NUMBER() OVER ( - PARTITION BY CASE - WHEN session.session_id=anchor.root_session_id - THEN 'coordinator' - ELSE 'member:' || session.org_member_id - END - ORDER BY session.updated_at DESC, session.session_id DESC - ) - FROM agent_sessions session - JOIN descendants USING (session_id) - CROSS JOIN run_anchor anchor - WHERE session.session_id=anchor.root_session_id - OR (session.agent_definition_id IS NOT NULL - AND session.org_member_id IS NOT NULL) - ) - UPDATE agent_sessions + "UPDATE agent_sessions SET status=?1, updated_at=?2 WHERE session_id=?3 AND status IN (?6, ?7, ?8, ?9, ?10, ?11) - AND session_id IN ( - SELECT session_id FROM ranked WHERE member_rank=1 + AND EXISTS ( + SELECT 1 + FROM agent_org_run_sessions ownership + JOIN agent_org_runs run ON run.id=ownership.org_run_id + WHERE ownership.org_run_id=?4 + AND ownership.session_id=agent_sessions.session_id + AND run.status=?5 ) AND NOT EXISTS ( SELECT 1 FROM agent_member_interventions intervention WHERE intervention.org_run_id=?4 - AND intervention.member_id=CASE - WHEN agent_sessions.session_id=(SELECT root_session_id FROM run_anchor) - THEN ?12 - ELSE agent_sessions.org_member_id - END + AND intervention.member_id=( + SELECT ownership.member_id + FROM agent_org_run_sessions ownership + WHERE ownership.org_run_id=?4 + AND ownership.session_id=agent_sessions.session_id + ) AND intervention.cleared_at IS NULL - AND datetime(intervention.resume_after)>datetime(?13) + AND datetime(intervention.resume_after)>datetime(?12) )", rusqlite::params![ SessionStatus::Running.as_str(), @@ -86,45 +57,37 @@ pub(super) fn promote_agent_org_wake_session_to_running( wakeable[3].as_str(), wakeable[4].as_str(), wakeable[5].as_str(), - COORDINATOR_MEMBER_ID, &now, ], ) .map_err(|error| error.to_string()) } -/// Promote a direct Rust Agent Org turn unless deletion has established the -/// run's terminal `cancelled` fence. Direct user turns intentionally retain -/// their existing behavior for completed/failed historical runs; this guard -/// only closes the race where a message was queued while hierarchy deletion -/// was stopping the run. +/// Promote a direct Rust Agent Org turn only while its exact owning run is +/// mutable. Historical run selection is read-only and cannot revive a +/// terminal run. pub(super) fn promote_agent_org_direct_session_to_running( conn: &rusqlite::Connection, run_id: &str, session_id: &str, ) -> Result { - use rusqlite::OptionalExtension; - - let run_status = conn - .query_row( - "SELECT status FROM agent_org_runs WHERE id=?1", - [run_id], - |row| row.get::<_, String>(0), - ) - .optional() - .map_err(|error| error.to_string())?; - if run_status.as_deref() == Some("cancelled") || run_status.is_none() { - return Ok(0); - } - conn.execute( "UPDATE agent_sessions SET status=?1, updated_at=?2 - WHERE session_id=?3", + WHERE session_id=?3 + AND EXISTS ( + SELECT 1 + FROM agent_org_run_sessions ownership + JOIN agent_org_runs run ON run.id=ownership.org_run_id + WHERE ownership.org_run_id=?4 + AND ownership.session_id=agent_sessions.session_id + AND run.status='running' + )", rusqlite::params![ crate::session::SessionStatus::Running.as_str(), chrono::Utc::now().to_rfc3339(), session_id, + run_id, ], ) .map_err(|error| error.to_string()) @@ -151,9 +114,9 @@ pub(super) fn resolve_agent_org_wake_mode( .map_err(|error| error.to_string())?; let member_id: String = tx .query_row( - "SELECT org_member_id FROM agent_sessions - WHERE session_id=?1 AND org_member_id IS NOT NULL", - params![session_id], + "SELECT member_id FROM agent_org_run_sessions + WHERE org_run_id=?1 AND session_id=?2", + params![run_id, session_id], |row| row.get(0), ) .optional() 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 924997f102..27a1906ffb 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 @@ -134,7 +134,8 @@ pub(crate) async fn send_message_impl( Some(effective_model.clone()), effective_native_harness_type, ) - .await?; + .await? + .with_agent_org_run_hint(intent_org_run_id.clone()); let runtime = crate::init::init_session(state, launch_spec).await?; 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 b5a4d0c019..6542d71af6 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 @@ -94,8 +94,9 @@ fn setup_wake_mode_fixture(execution_mode: &str, task_status: TaskStatus) -> Wak }, ) .expect("seed coordinator session"); - crate::session::persistence::upsert_session( - &crate::session::persistence::UnifiedSessionRecord { + AgentOrgRunStore::materialize_rust_worker_sessions( + &run.id, + &[crate::session::persistence::UnifiedSessionRecord { session_id: session_id.clone(), name: "Planner".into(), status: "idle".into(), @@ -107,9 +108,9 @@ fn setup_wake_mode_fixture(execution_mode: &str, task_status: TaskStatus) -> Wak agent_definition_id: Some("planner-agent".into()), key_source: KeySource::OwnKey, ..Default::default() - }, + }], ) - .expect("seed member session"); + .expect("transactionally materialize member session"); let task_id = "mode-task".to_string(); AgentOrgTaskStore::create(CreateTaskParams { id: task_id.clone(), diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/context.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/context.rs index c1ad72b30e..aed5eb7b2b 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/context.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/context.rs @@ -5,13 +5,10 @@ //! the command families (run view, group chat, plan approval, intervention, //! lifecycle) share one implementation. -use rusqlite::{params, OptionalExtension}; - use crate::coordination::agent_org_runs::{AgentOrgRunContext, AgentOrgRunStore}; use crate::definitions::orgs::AgentOrgsStore; use crate::session::persistence; use crate::state::AgentAppState; -use database::db::get_connection; pub(super) struct SessionOrgReadContext { pub(super) context: Option, @@ -21,6 +18,21 @@ pub(super) struct SessionOrgReadContext { pub(super) async fn session_org_read_context( state: &AgentAppState, session_id: &str, +) -> Result, String> { + session_org_read_context_inner(state, session_id, false).await +} + +pub(super) async fn session_org_read_context_for_run_view( + state: &AgentAppState, + session_id: &str, +) -> Result, String> { + session_org_read_context_inner(state, session_id, true).await +} + +async fn session_org_read_context_inner( + state: &AgentAppState, + session_id: &str, + read_only: bool, ) -> Result, String> { let runtime_context = match state.get_session(session_id).await { Some(session) => session @@ -46,37 +58,29 @@ pub(super) async fn session_org_read_context( // executor at every call site. tokio::task::spawn_blocking(move || -> Result, String> { let persisted = persistence::get_session(&session_id).map_err(|err| err.to_string())?; - let member_id = match persisted.as_ref() { - Some(record) => Some(record.org_member_id.clone()), - None => { - let conn = get_connection().map_err(|err| err.to_string())?; - conn.query_row( - "SELECT org_member_id FROM code_sessions WHERE session_id = ?1", - params![&session_id], - |row| row.get::<_, Option>(0), - ) - .optional() - .map_err(|err| err.to_string())? - } - }; - if persisted.is_none() && member_id.is_none() && runtime_context.is_none() { + let mapped_member_id = AgentOrgRunStore::member_id_for_mapped_session(&session_id)?; + let member_id = mapped_member_id.or_else(|| { + persisted + .as_ref() + .and_then(|record| record.org_member_id.clone()) + }); + if persisted.is_none() && member_id.is_none() { return Ok(None); } - let context = match runtime_context { - Some(context) => Some(context), - None => match org_store { - Some(store) => AgentOrgRunStore::context_for_session_with_parent_walk( + let context = match (org_store, read_only) { + (Some(store), true) => { + AgentOrgRunStore::context_for_session_read_only_with_parent_walk( &session_id, store.as_ref(), - )?, - None => None, - }, + )? + } + (Some(store), false) => { + AgentOrgRunStore::context_for_session_with_parent_walk(&session_id, store.as_ref())? + } + (None, _) => runtime_context, }; - Ok(Some(SessionOrgReadContext { - context, - member_id: member_id.flatten(), - })) + Ok(Some(SessionOrgReadContext { context, member_id })) }) .await .map_err(|err| format!("Agent Org session context worker failed: {err}"))? 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 01d743df6a..7bf19b50d9 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 @@ -31,7 +31,7 @@ use crate::coordination::agent_org_tasks::{ }; use crate::state::AgentAppState; -use super::context::{require_session_member_id, session_org_read_context}; +use super::context::{require_session_member_id, session_org_read_context_for_run_view}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -172,7 +172,7 @@ pub async fn agent_org_session_run_view_impl( state: &AgentAppState, session_id: &str, ) -> Result, String> { - let Some(read_context) = session_org_read_context(state, session_id).await? else { + let Some(read_context) = session_org_read_context_for_run_view(state, session_id).await? else { return Ok(None); }; let Some(context) = read_context.context.as_ref() else { 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 e7a19016ab..be391207f6 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 @@ -173,7 +173,8 @@ fn load_agent_org_session_delete_plan( "SELECT id, status FROM agent_org_runs WHERE root_session_id=?1 - ORDER BY id", + ORDER BY id + LIMIT 2", ) .map_err(|err| err.to_string())?; let rows = stmt @@ -190,8 +191,7 @@ fn load_agent_org_session_delete_plan( }; if run_rows.len() != 1 { return Err(format!( - "Refusing to delete Agent Org root {root_session_id}: {} runs claim the same root", - run_rows.len() + "Refusing to delete Agent Org root {root_session_id}: at least 2 runs claim the same root" )); } let run_status = crate::coordination::agent_org_runs::AgentOrgRunStatus::parse(run_status_raw) @@ -1275,6 +1275,13 @@ mod tests { seed_run_owned_rows("hierarchy-worker-run"); let conn = get_connection().expect("sandbox DB"); + conn.execute( + "INSERT INTO agent_org_run_sessions ( + org_run_id, member_id, session_id, role, created_at + ) VALUES ('hierarchy-worker-run', 'worker', ?1, 'worker', ?2)", + rusqlite::params![worker, "2026-07-16T00:00:00Z"], + ) + .expect("seed exact worker mapping"); assert!( load_agent_org_session_delete_plan(&conn, worker) .expect("plan worker") @@ -1285,6 +1292,7 @@ mod tests { session_persistence::delete_session(worker).expect("canonical single-session deletion"); assert!(!row_exists("agent_sessions", "session_id", worker)); + assert!(!row_exists("agent_org_run_sessions", "session_id", worker)); assert!(row_exists("agent_sessions", "session_id", root)); assert!(row_exists("agent_org_runs", "id", "hierarchy-worker-run")); assert!(row_exists( diff --git a/src-tauri/crates/e2e-test/src/agent_org.rs b/src-tauri/crates/e2e-test/src/agent_org.rs index 393ba32139..f5886d227a 100644 --- a/src-tauri/crates/e2e-test/src/agent_org.rs +++ b/src-tauri/crates/e2e-test/src/agent_org.rs @@ -12,11 +12,8 @@ //! Pairing strategy: positive AND negative pins per behavior. Every //! successful send is followed by a `list-by-run` read so a future //! refactor of either side surfaces here, not just in unit tests. -//! Production caller-path coverage lives in -//! `production_return_to_work_drains_inbox_into_member_transcript` below. It -//! launches a real materialized member and uses the debug-only deterministic -//! provider; full live-provider coordinator behavior belongs in rendered UI -//! E2E, not this deterministic runtime contract suite. +//! Production caller paths cover launch, member wake, and guarded deletion; +//! live-provider and rendered UI behavior remains in the UI E2E suite. use super::config::Config; use super::harness; @@ -31,12 +28,17 @@ const LAUNCH_COORDINATOR_PATH: &str = "/agent/test/agent-org/launch-coordinator" const SESSION_RETURN_TO_WORK_PATH: &str = "/agent/test/agent-org/session-return-to-work"; const TASK_TOOL_DIRECT_PATH: &str = "/agent/test/agent-org/task-tool-direct"; const RUN_SEED_PATH: &str = "/agent/test/agent-org/run/seed"; +const RUN_CLEANUP_PATH: &str = "/agent/test/agent-org/run/cleanup"; const E2E_RUN_FIXTURE_ORG_PREFIX: &str = "e2e-agent-org-fixture:"; const RUN_VIEW_PATH: &str = "/agent/test/agent-org/run-view"; const DURABLE_INVARIANTS_PATH: &str = "/agent/test/agent-org/durable-invariants"; const FIND_WORKER_SESSION_PATH: &str = "/agent/test/agent-org/find-worker-session"; const SEED_CLI_MEMBER_RUN_PATH: &str = "/agent/test/agent-org/stale-workers/seed-cli-member"; +const SEED_RUST_MEMBER_RUN_PATH: &str = "/agent/test/agent-org/stale-workers/seed-run"; +const SESSION_DELETE_SNAPSHOT_PATH: &str = "/agent/test/agent-org/session-delete/snapshot"; +const SESSION_DELETE_ATTEMPT_PATH: &str = "/agent/test/agent-org/session-delete/attempt"; const TASKS_SEED_PATH: &str = "/agent/test/agent-org/tasks/seed"; +const TASKS_LIST_PATH: &str = "/agent/test/agent-org/tasks/list"; const PAUSE_RUN_PATH: &str = "/agent/test/agent-org/run/pause"; const RESUME_RUN_PATH: &str = "/agent/test/agent-org/run/resume"; const SIMULATE_APP_RESTART_PATH: &str = "/agent/test/agent-org/simulate-app-restart"; @@ -464,6 +466,30 @@ async fn durable_invariants(cfg: &Config, org_run_id: &str) -> Result bool { + response + .pointer("/view/context/runId") + .and_then(serde_json::Value::as_str) + == Some(run_id) + && response + .pointer("/view/members") + .and_then(serde_json::Value::as_array) + .is_some_and(|members| { + members.iter().any(|member| { + member.get("memberId").and_then(serde_json::Value::as_str) == Some(member_id) + && member + .pointer("/sessionRuntime/sessionId") + .and_then(serde_json::Value::as_str) + == Some(session_id) + }) + }) +} + fn tmp_agent_org_workspace(label: &str) -> String { let suffix = unique_run_id(label).replace(':', "-"); let path = std::env::temp_dir().join(format!("orgii-{suffix}")); @@ -3242,14 +3268,56 @@ pub async fn run_pause_resume_toggles_status(cfg: &Config) -> bool { pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> bool { let label = "app-restart-transitions-running-runs-to-paused"; - // (1) Seed a fresh running org run. + let fixture_id = unique_run_id("multi-run-restart"); + let org_id = fixture_id.clone(); + let root_session_id = format!("root-{fixture_id}"); + let historical_worker_session_id = format!("worker-history-{fixture_id}"); + let current_worker_session_id = format!("worker-current-{fixture_id}"); + let member_id = "m-restart"; + + let historical_seed = match post_agent_org_json( + cfg, + SEED_RUST_MEMBER_RUN_PATH, + serde_json::json!({ + "org_id": org_id, + "root_session_id": root_session_id, + "run_status": "completed", + "root_status": "idle", + "workers": [{ + "member_id": member_id, + "agent_definition_id": "builtin:explore", + "session_id": historical_worker_session_id, + "status": "completed" + }] + }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let historical_run_id = match historical_seed + .get("org_run_id") + .and_then(|value| value.as_str()) + { + Some(value) if !value.is_empty() => value.to_string(), + _ => return harness::print_error(label, &historical_seed.to_string()), + }; + let seed_resp = match post_agent_org_json( cfg, - SEED_CLI_MEMBER_RUN_PATH, + SEED_RUST_MEMBER_RUN_PATH, serde_json::json!({ - "cli_agent_type": "claude_code", - "member_id": "m-restart", - "status": "idle" + "org_id": org_id, + "root_session_id": root_session_id, + "run_status": "running", + "root_status": "idle", + "workers": [{ + "member_id": member_id, + "agent_definition_id": "builtin:explore", + "session_id": current_worker_session_id, + "status": "running" + }] }), ) .await @@ -3257,15 +3325,12 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo Err(err) => return harness::print_error(label, &err), Ok(json) => json, }; - let seed_ok = seed_resp.get("ok").and_then(|v| v.as_bool()) == Some(true); + let seed_ok = historical_seed.get("ok").and_then(|v| v.as_bool()) == Some(true) + && seed_resp.get("ok").and_then(|v| v.as_bool()) == Some(true); let org_run_id = match seed_resp.get("org_run_id").and_then(|v| v.as_str()) { Some(value) if !value.is_empty() => value.to_string(), _ => return harness::print_error(label, "seed did not return org_run_id"), }; - let root_session_id = match seed_resp.get("root_session_id").and_then(|v| v.as_str()) { - Some(value) if !value.is_empty() => value.to_string(), - _ => return harness::print_error(label, "seed did not return root_session_id"), - }; if let Err(err) = seed_task( cfg, &org_run_id, @@ -3295,6 +3360,38 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo .and_then(|v| v.as_str()) .unwrap_or("unknown"); + let historical_view_before = match post_agent_org_json( + cfg, + RUN_VIEW_PATH, + serde_json::json!({ "session_id": historical_worker_session_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let current_view_before = match post_agent_org_json( + cfg, + RUN_VIEW_PATH, + serde_json::json!({ "session_id": current_worker_session_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let exact_ownership_before = run_view_has_exact_worker( + &historical_view_before, + &historical_run_id, + member_id, + &historical_worker_session_id, + ) && run_view_has_exact_worker( + ¤t_view_before, + &org_run_id, + member_id, + ¤t_worker_session_id, + ); + // (3) Simulate app restart. let restart_resp = match post_agent_org_json(cfg, SIMULATE_APP_RESTART_PATH, serde_json::json!({})).await { @@ -3328,7 +3425,7 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo let run_view_resp = match post_agent_org_json( cfg, RUN_VIEW_PATH, - serde_json::json!({ "session_id": root_session_id }), + serde_json::json!({ "session_id": current_worker_session_id }), ) .await { @@ -3340,8 +3437,145 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo .and_then(|value| value.get("runStatus")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); + let historical_view_after_restart = match post_agent_org_json( + cfg, + RUN_VIEW_PATH, + serde_json::json!({ "session_id": historical_worker_session_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let exact_ownership_after_restart = run_view_has_exact_worker( + &historical_view_after_restart, + &historical_run_id, + member_id, + &historical_worker_session_id, + ) && run_view_has_exact_worker( + &run_view_resp, + &org_run_id, + member_id, + ¤t_worker_session_id, + ); + let historical_run_stayed_completed = historical_view_after_restart + .pointer("/view/runStatus") + .and_then(serde_json::Value::as_str) + == Some("completed"); + + let snapshot_body = serde_json::json!({ + "session_ids": [ + root_session_id, + historical_worker_session_id, + current_worker_session_id + ], + "run_ids": [historical_run_id, org_run_id] + }); + let snapshot_before = + match post_agent_org_json(cfg, SESSION_DELETE_SNAPSHOT_PATH, snapshot_body.clone()).await { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let tasks_before = match post_agent_org_json( + cfg, + TASKS_LIST_PATH, + serde_json::json!({ "org_run_id": org_run_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let delete_attempt = match post_agent_org_json( + cfg, + SESSION_DELETE_ATTEMPT_PATH, + serde_json::json!({ "session_id": root_session_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let snapshot_after = + match post_agent_org_json(cfg, SESSION_DELETE_SNAPSHOT_PATH, snapshot_body).await { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let tasks_after = match post_agent_org_json( + cfg, + TASKS_LIST_PATH, + serde_json::json!({ "org_run_id": org_run_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let historical_view_after_delete = match post_agent_org_json( + cfg, + RUN_VIEW_PATH, + serde_json::json!({ "session_id": historical_worker_session_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let current_view_after_delete = match post_agent_org_json( + cfg, + RUN_VIEW_PATH, + serde_json::json!({ "session_id": current_worker_session_id }), + ) + .await + { + Err(err) => return harness::print_error(label, &err), + Ok(json) => json, + }; + let deletion_rejected = delete_attempt.get("ok").and_then(|value| value.as_bool()) + == Some(false) + && delete_attempt + .get("error") + .and_then(serde_json::Value::as_str) + .is_some_and(|error| error.contains("at least 2 runs claim the same root")); + let snapshot_has = |group: &str, id: &str| { + snapshot_before + .get(group) + .and_then(|value| value.get(id)) + .is_some_and(serde_json::Value::is_object) + }; + let snapshot_complete = [ + &root_session_id, + &historical_worker_session_id, + ¤t_worker_session_id, + ] + .iter() + .all(|id| snapshot_has("sessions", id)) + && [&historical_run_id, &org_run_id] + .iter() + .all(|id| snapshot_has("runs", id)) + && snapshot_before + .get("mappings") + .and_then(serde_json::Value::as_array) + .is_some_and(|mappings| mappings.len() == 4); + let durable_state_unchanged = snapshot_complete + && snapshot_before == snapshot_after + && tasks_before == tasks_after + && tasks_before + .get("tasks") + .and_then(serde_json::Value::as_array) + .is_some_and(|tasks| tasks.len() == 1); + let exact_ownership_after_delete = run_view_has_exact_worker( + &historical_view_after_delete, + &historical_run_id, + member_id, + &historical_worker_session_id, + ) && run_view_has_exact_worker( + ¤t_view_after_delete, + &org_run_id, + member_id, + ¤t_worker_session_id, + ); - // (6) User can resume from UI — full round trip. let resume_resp = match post_agent_org_json( cfg, RESUME_RUN_PATH, @@ -3371,7 +3605,7 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo .and_then(|v| v.as_str()) .unwrap_or("unknown"); - harness::print_result( + let passed = harness::print_result( label, &serde_json::json!({ "seed": seed_resp, @@ -3379,12 +3613,18 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo "inv_before_restart": inv_before_restart, "inv_after_restart": inv_after_restart, "run_view_after_restart": run_view_resp, + "delete_attempt": delete_attempt, + "snapshot_before": snapshot_before, "resume": resume_resp, "inv_after_resume": inv_after_resume, }) .to_string(), &[ ("seed ok", seed_ok), + ( + "exact worker ownership before restart", + exact_ownership_before, + ), ( "run status before restart is 'running'", run_status_before == "running", @@ -3399,6 +3639,26 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo "run view poll does not auto-terminate paused run", run_status_after_view_poll == "paused", ), + ( + "exact worker ownership survives recovery", + exact_ownership_after_restart, + ), + ( + "historical run stays completed", + historical_run_stayed_completed, + ), + ( + "production multi-run deletion is rejected", + deletion_rejected, + ), + ( + "delete rejection changes no durable rows", + durable_state_unchanged, + ), + ( + "exact mappings survive delete rejection", + exact_ownership_after_delete, + ), ("resume endpoint ok", resume_ok), ("resume transitioned=true", resume_transitioned), ( @@ -3406,5 +3666,38 @@ pub async fn app_restart_transitions_running_runs_to_paused(cfg: &Config) -> boo run_status_after_resume == "running", ), ], - ) + ); + + let mut cleanup_ok = true; + for run_id in [&historical_run_id, &org_run_id] { + let cleanup = post_agent_org_json( + cfg, + RUN_CLEANUP_PATH, + serde_json::json!({ "org_run_id": run_id }), + ) + .await; + if !matches!(cleanup, Ok(ref response) if response.get("ok").and_then(serde_json::Value::as_bool) == Some(true)) + { + cleanup_ok = false; + eprintln!("[{label}] run cleanup failed for {run_id}: {cleanup:?}"); + } + } + for session_id in [ + &historical_worker_session_id, + ¤t_worker_session_id, + &root_session_id, + ] { + let cleanup = post_agent_org_json( + cfg, + SESSION_DELETE_ATTEMPT_PATH, + serde_json::json!({ "session_id": session_id }), + ) + .await; + if !matches!(cleanup, Ok(ref response) if response.get("ok").and_then(serde_json::Value::as_bool) == Some(true)) + { + cleanup_ok = false; + eprintln!("[{label}] session cleanup failed for {session_id}: {cleanup:?}"); + } + } + passed && cleanup_ok } diff --git a/src-tauri/src/api/agent/mod.rs b/src-tauri/src/api/agent/mod.rs index 9c97dfd396..33c70d8391 100644 --- a/src-tauri/src/api/agent/mod.rs +++ b/src-tauri/src/api/agent/mod.rs @@ -726,6 +726,10 @@ pub fn create_routes() -> Router { "/test/agent-org/session-delete/snapshot", post(test::agent_org::test_agent_org_session_delete_snapshot), ) + .route( + "/test/agent-org/session-delete/attempt", + post(test::agent_org::test_agent_org_session_delete_attempt), + ) .route( "/test/agent-org/stale-workers/seed-cli-member", post(test::agent_org::test_agent_org_seed_cli_member_run), diff --git a/src-tauri/src/api/agent/test/agent_org.rs b/src-tauri/src/api/agent/test/agent_org.rs index 90f3a6c911..01930d08ee 100644 --- a/src-tauri/src/api/agent/test/agent_org.rs +++ b/src-tauri/src/api/agent/test/agent_org.rs @@ -5,8 +5,8 @@ //! probes (driving an `AgentInboxStore` / `AgentOrgRunContext` helper //! directly, no live session, no LLM). The caller-path exceptions are //! `launch-coordinator`, which drives the canonical `session_launch_impl`, -//! and `session-return-to-work`, which drives the production wake scheduler -//! and inbox drain on a materialized member session. Each endpoint's +//! `session-return-to-work`, which drives the production wake scheduler and +//! inbox drain, and `session-delete/attempt`, which calls production deletion. Each endpoint's //! individual doc states which kind it is. Helper-isolation probes catch //! contract drift cheaply; the deterministic fake-provider return-to-work //! scenario catches regressions where the real turn processor stops draining @@ -1783,7 +1783,8 @@ pub async fn test_agent_org_seed_stale_worker_run( })?; let mut worker_sessions = Vec::new(); - for worker in workers { + let mut worker_records = Vec::new(); + for (index, worker) in workers.into_iter().enumerate() { let Some(worker_obj) = worker.as_object() else { return Err("each worker must be an object".to_string()); }; @@ -1797,7 +1798,8 @@ pub async fn test_agent_org_seed_stale_worker_run( .get("member_id") .and_then(|value| value.as_str()) .filter(|value| !value.trim().is_empty()) - .map(str::to_string); + .map(str::to_string) + .unwrap_or_else(|| format!("worker-{index}")); let session_id = worker_obj .get("session_id") .and_then(|value| value.as_str()) @@ -1824,19 +1826,18 @@ pub async fn test_agent_org_seed_stale_worker_run( .map(str::to_string) .unwrap_or_else(|| root_session_id.clone()); - upsert_session(&UnifiedSessionRecord { + worker_records.push(UnifiedSessionRecord { session_id: session_id.clone(), name: format!("stale-worker-{agent_definition_id}"), status: status.as_str().to_string(), session_type: session_type::ORG_MEMBER.to_string(), parent_session_id: Some(parent_session_id), agent_definition_id: Some(agent_definition_id.clone()), - org_member_id: member_id, + org_member_id: Some(member_id), created_at: updated_at.clone(), updated_at: updated_at.clone(), ..Default::default() - }) - .map_err(|err| err.to_string())?; + }); worker_sessions.push(serde_json::json!({ "session_id": session_id, "agent_definition_id": agent_definition_id, @@ -1844,6 +1845,7 @@ pub async fn test_agent_org_seed_stale_worker_run( "status": status.as_str(), })); } + AgentOrgRunStore::materialize_rust_worker_sessions(&run.id, &worker_records)?; Ok::(serde_json::json!({ "ok": true, @@ -1905,35 +1907,94 @@ pub async fn test_agent_org_session_delete_snapshot( } let result = tokio::task::spawn_blocking(move || -> Result { + use rusqlite::OptionalExtension; + use std::collections::BTreeMap; + 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 { - let exists = conn + for session_id in &session_ids { + let row = conn .query_row( - "SELECT EXISTS( - SELECT 1 FROM agent_sessions WHERE session_id=?1 - )", - [&session_id], - |row| row.get::<_, bool>(0), + "SELECT status, org_member_id, parent_session_id, + agent_definition_id, updated_at + FROM agent_sessions WHERE session_id=?1", + [session_id], + |row| { + Ok(serde_json::json!({ + "status": row.get::<_, String>(0)?, + "org_member_id": row.get::<_, Option>(1)?, + "parent_session_id": row.get::<_, Option>(2)?, + "agent_definition_id": row.get::<_, Option>(3)?, + "updated_at": row.get::<_, String>(4)?, + })) + }, ) + .optional() .map_err(|err| err.to_string())?; - sessions.insert(session_id, serde_json::Value::Bool(exists)); + sessions.insert(session_id.clone(), row.unwrap_or(serde_json::Value::Null)); } let mut runs = serde_json::Map::new(); - for run_id in run_ids { - let exists = conn + for run_id in &run_ids { + let row = conn .query_row( - "SELECT EXISTS(SELECT 1 FROM agent_org_runs WHERE id=?1)", - [&run_id], - |row| row.get::<_, bool>(0), + "SELECT status, root_session_id, updated_at, completed_at, + continued_from_run_id, originating_message_id + FROM agent_org_runs WHERE id=?1", + [run_id], + |row| { + Ok(serde_json::json!({ + "status": row.get::<_, String>(0)?, + "root_session_id": row.get::<_, Option>(1)?, + "updated_at": row.get::<_, String>(2)?, + "completed_at": row.get::<_, Option>(3)?, + "continued_from_run_id": row.get::<_, Option>(4)?, + "originating_message_id": row.get::<_, Option>(5)?, + })) + }, ) + .optional() + .map_err(|err| err.to_string())?; + runs.insert(run_id.clone(), row.unwrap_or(serde_json::Value::Null)); + } + let mut mappings = BTreeMap::new(); + for session_id in &session_ids { + let mut stmt = conn + .prepare( + "SELECT org_run_id, member_id, session_id, role, created_at + FROM agent_org_run_sessions WHERE session_id=?1", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map([session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }) .map_err(|err| err.to_string())?; - runs.insert(run_id, serde_json::Value::Bool(exists)); + for row in rows { + let (run_id, member_id, mapped_session_id, role, created_at) = + row.map_err(|err| err.to_string())?; + mappings.insert( + format!("{run_id}\0{member_id}\0{mapped_session_id}"), + serde_json::json!({ + "org_run_id": run_id, + "member_id": member_id, + "session_id": mapped_session_id, + "role": role, + "created_at": created_at, + }), + ); + } } Ok(serde_json::json!({ "ok": true, "sessions": sessions, "runs": runs, + "mappings": mappings.into_values().collect::>(), })) }) .await; @@ -1948,6 +2009,41 @@ pub async fn test_agent_org_session_delete_snapshot( } } +/// Debug-only bridge to production `agent_delete_session`. +pub async fn test_agent_org_session_delete_attempt( + Json(body): Json, +) -> Json { + use tauri::Manager; + + let session_id = match body.get("session_id").and_then(|value| value.as_str()) { + Some(value) if !value.trim().is_empty() => value.to_string(), + _ => { + return Json(serde_json::json!({ + "ok": false, + "error": "session_id is required (non-empty string)" + })) + } + }; + let Some(handle) = crate::api::get_app_handle() else { + return Json(serde_json::json!({ "ok": false, "error": "AppHandle not initialized." })); + }; + let state = handle.state::(); + match agent_core::state::commands::session::agent_delete_session(state, session_id.clone()) + .await + { + Ok(receipt) => Json(serde_json::json!({ + "ok": true, + "session_id": session_id, + "receipt": receipt, + })), + Err(error) => Json(serde_json::json!({ + "ok": false, + "session_id": session_id, + "error": error, + })), + } +} + /// `POST /test/agent-org/find-worker-session` /// /// Read-only bridge around diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 9e08f71f12..17a108fa40 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -918,6 +918,7 @@ agent_core::definitions::commands::agent_orgs_add, agent_core::definitions::commands::agent_orgs_update, agent_core::definitions::commands::agent_orgs_remove, agent_core::definitions::commands::agent_org_run_list, +agent_core::definitions::commands::agent_org_run_timeline, agent_core::state::commands::session::debug::org_runtime::debug_session_org_runtime_snapshot, agent_core::state::commands::session::debug::org_runtime::debug_session_execute_tool, agent_core::state::commands::session::debug::org_runtime::debug_session_execute_org_tool,