diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs index 51477ef39..e30601cb6 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs @@ -386,6 +386,24 @@ impl AgentInboxStore { /// transaction. pub fn resolve_delivery( params: ResolveInboxDeliveryParams, + ) -> Result { + let storage = ResolveInboxDeliveryError::Storage; + with_sessions_writer( + || -> Result { + let mut conn = get_connection().map_err(|err| storage(err.to_string()))?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| storage(err.to_string()))?; + let resolution = Self::resolve_delivery_in_tx(&tx, params)?; + tx.commit().map_err(|err| storage(err.to_string()))?; + Ok(resolution) + }, + ) + } + + pub(crate) fn resolve_delivery_in_tx( + conn: &Connection, + params: ResolveInboxDeliveryParams, ) -> Result { use crate::coordination::agent_org_runs::{ AgentOrgRunStatus, AgentOrgRunStore, COORDINATOR_MEMBER_ID, @@ -446,94 +464,85 @@ impl AgentInboxStore { } } - with_sessions_writer( - || -> Result { - let mut conn = get_connection().map_err(|err| storage(err.to_string()))?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|err| storage(err.to_string()))?; - let run_status = - AgentOrgRunStore::get_run_status_with_connection(&tx, ¶ms.org_run_id) - .map_err(storage)?; - if run_status != Some(AgentOrgRunStatus::Running) { - if run_status == Some(AgentOrgRunStatus::Archived) { - return Err(constraint(format!( - "team_archived: Agent Org run {} is read-only", - params.org_run_id - ))); - } - return Err(constraint(format!( - "Agent Org run {} is not Running; Inbox delivery repair was not applied", - params.org_run_id - ))); - } + let run_status = AgentOrgRunStore::get_run_status_with_connection(conn, ¶ms.org_run_id) + .map_err(storage)?; + if run_status != Some(AgentOrgRunStatus::Running) { + if run_status == Some(AgentOrgRunStatus::Archived) { + return Err(constraint(format!( + "team_archived: Agent Org run {} is read-only", + params.org_run_id + ))); + } + return Err(constraint(format!( + "Agent Org run {} is not Running; Inbox delivery repair was not applied", + params.org_run_id + ))); + } - let source: Option<(Option, Option)> = tx - .query_row( - "SELECT recipient_member_id, read_at + let source: Option<(Option, Option)> = conn + .query_row( + "SELECT recipient_member_id, read_at FROM agent_org_runtime_inbox WHERE id=?1 AND org_run_id=?2 LIMIT 1", - params![params.inbox_id, ¶ms.org_run_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(|err| storage(err.to_string()))?; - let Some((source_recipient_member_id, read_at)) = source else { - return Err(constraint(format!( - "Inbox row {} does not belong to Agent Org run {}", - params.inbox_id, params.org_run_id - ))); - }; - if read_at.is_some() { - return Err(constraint(format!( - "Inbox row {} was already delivered and cannot be resolved as undeliverable", - params.inbox_id - ))); - } + params![params.inbox_id, ¶ms.org_run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|err| storage(err.to_string()))?; + let Some((source_recipient_member_id, read_at)) = source else { + return Err(constraint(format!( + "Inbox row {} does not belong to Agent Org run {}", + params.inbox_id, params.org_run_id + ))); + }; + if read_at.is_some() { + return Err(constraint(format!( + "Inbox row {} was already delivered and cannot be resolved as undeliverable", + params.inbox_id + ))); + } - if let Some(existing) = - load_delivery_resolution(&tx, ¶ms.org_run_id, params.inbox_id) - .map_err(storage)? - { - let is_same = existing.resolution_kind == params.resolution_kind - && existing.resolved_by_member_id == params.resolved_by_member_id - && existing.reason == params.reason - && existing.replacement_inbox_id == params.replacement_inbox_id - && existing.replacement_task_id == params.replacement_task_id; - if is_same { - tx.commit().map_err(|err| storage(err.to_string()))?; - return Ok(existing); - } - return Err(constraint(format!( - "Inbox row {} already has a different delivery resolution", - params.inbox_id - ))); - } + if let Some(existing) = + load_delivery_resolution(conn, ¶ms.org_run_id, params.inbox_id).map_err(storage)? + { + let is_same = existing.resolution_kind == params.resolution_kind + && existing.resolved_by_member_id == params.resolved_by_member_id + && existing.reason == params.reason + && existing.replacement_inbox_id == params.replacement_inbox_id + && existing.replacement_task_id == params.replacement_task_id; + if is_same { + return Ok(existing); + } + return Err(constraint(format!( + "Inbox row {} already has a different delivery resolution", + params.inbox_id + ))); + } - // A model-visible repair tool must not be able to discard healthy - // work merely because the coordinator changed its mind. Only - // identities that are provably outside a deliverable production - // path may be resolved here. Recoverable states (Idle, terminal - // retry candidates, Pending, Paused, Running/waiting) must instead - // be resumed/retried or explicitly archived by the user first. - let permanently_unavailable = inbox_recipient_is_permanently_unavailable( - &tx, - ¶ms.org_run_id, - source_recipient_member_id.as_deref(), - ) - .map_err(storage)?; - if !permanently_unavailable { - return Err(constraint(format!( + // A model-visible repair tool must not be able to discard healthy + // work merely because the coordinator changed its mind. Only + // identities that are provably outside a deliverable production + // path may be resolved here. Recoverable states (Idle, terminal + // retry candidates, Pending, Paused, Running/waiting) must instead + // be resumed/retried or explicitly archived by the user first. + let permanently_unavailable = inbox_recipient_is_permanently_unavailable( + conn, + ¶ms.org_run_id, + source_recipient_member_id.as_deref(), + ) + .map_err(storage)?; + if !permanently_unavailable { + return Err(constraint(format!( "Inbox row {} still has a recoverable canonical recipient. Resume/retry that recipient instead of discarding or superseding healthy delivery; archive it explicitly first only if the user has decided it is permanently unavailable.", params.inbox_id ))); - } + } - if let Some(replacement_inbox_id) = params.replacement_inbox_id { - let replacement: Option<(Option, Option, bool)> = tx - .query_row( - "SELECT inbox.recipient_member_id, + if let Some(replacement_inbox_id) = params.replacement_inbox_id { + let replacement: Option<(Option, Option, bool)> = conn + .query_row( + "SELECT inbox.recipient_member_id, inbox.read_at, EXISTS( SELECT 1 @@ -543,94 +552,90 @@ impl AgentInboxStore { FROM agent_org_runtime_inbox inbox WHERE inbox.id=?1 AND inbox.org_run_id=?2 LIMIT 1", - params![replacement_inbox_id, ¶ms.org_run_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional() - .map_err(|err| storage(err.to_string()))?; - let Some((Some(replacement_member_id), replacement_read_at, is_resolved)) = - replacement - else { - return Err(constraint(format!( + params![replacement_inbox_id, ¶ms.org_run_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(|err| storage(err.to_string()))?; + let Some((Some(replacement_member_id), replacement_read_at, is_resolved)) = replacement + else { + return Err(constraint(format!( "replacement Inbox row {replacement_inbox_id} must exist in the same run and name a canonical recipient_member_id" ))); - }; - if is_resolved { - return Err(constraint(format!( + }; + if is_resolved { + return Err(constraint(format!( "replacement Inbox row {replacement_inbox_id} already has a delivery resolution and cannot be used as a live replacement" ))); - } - let replacement_is_unavailable = inbox_recipient_is_permanently_unavailable( - &tx, - ¶ms.org_run_id, - Some(&replacement_member_id), - ) - .map_err(storage)?; - if replacement_read_at.is_none() && replacement_is_unavailable { - return Err(constraint(format!( + } + let replacement_is_unavailable = inbox_recipient_is_permanently_unavailable( + conn, + ¶ms.org_run_id, + Some(&replacement_member_id), + ) + .map_err(storage)?; + if replacement_read_at.is_none() && replacement_is_unavailable { + return Err(constraint(format!( "replacement Inbox row {replacement_inbox_id} has not been delivered and its recipient {replacement_member_id:?} is permanently unavailable" ))); - } - } - if let Some(replacement_task_id) = params.replacement_task_id.as_deref() { - let replacement_exists: bool = tx - .query_row( - "SELECT EXISTS( + } + } + if let Some(replacement_task_id) = params.replacement_task_id.as_deref() { + let replacement_exists: bool = conn + .query_row( + "SELECT EXISTS( SELECT 1 FROM agent_org_runtime_tasks WHERE id=?1 AND org_run_id=?2 )", - params![replacement_task_id, ¶ms.org_run_id], - |row| row.get(0), - ) - .map_err(|err| storage(err.to_string()))?; - if !replacement_exists { - return Err(constraint(format!( - "replacement task {replacement_task_id:?} does not exist in Agent Org run {}", - params.org_run_id - ))); - } - } + params![replacement_task_id, ¶ms.org_run_id], + |row| row.get(0), + ) + .map_err(|err| storage(err.to_string()))?; + if !replacement_exists { + return Err(constraint(format!( + "replacement task {replacement_task_id:?} does not exist in Agent Org run {}", + params.org_run_id + ))); + } + } - let created_at = chrono::Utc::now().to_rfc3339(); - tx.execute( - "INSERT INTO agent_org_runtime_inbox_delivery_resolutions ( + let created_at = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_org_runtime_inbox_delivery_resolutions ( inbox_id, org_run_id, resolution_kind, resolved_by_member_id, reason, replacement_inbox_id, replacement_task_id, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - params.inbox_id, - ¶ms.org_run_id, - params.resolution_kind.as_str(), - ¶ms.resolved_by_member_id, - ¶ms.reason, - params.replacement_inbox_id, - params.replacement_task_id.as_deref(), - &created_at, - ], - ) - .map_err(|err| storage(err.to_string()))?; - // A Session that materialized the old row before this repair must - // not later acknowledge it as delivered. The guarded mark-read - // path also rechecks the resolution table. - tx.execute( - "DELETE FROM agent_org_runtime_inbox_materializations WHERE inbox_id=?1", - params![params.inbox_id], - ) - .map_err(|err| storage(err.to_string()))?; - tx.commit().map_err(|err| storage(err.to_string()))?; - Ok(AgentInboxDeliveryResolution { - inbox_id: params.inbox_id, - org_run_id: params.org_run_id, - resolution_kind: params.resolution_kind, - resolved_by_member_id: params.resolved_by_member_id, - reason: params.reason, - replacement_inbox_id: params.replacement_inbox_id, - replacement_task_id: params.replacement_task_id, - created_at, - }) - }, + params![ + params.inbox_id, + ¶ms.org_run_id, + params.resolution_kind.as_str(), + ¶ms.resolved_by_member_id, + ¶ms.reason, + params.replacement_inbox_id, + params.replacement_task_id.as_deref(), + &created_at, + ], + ) + .map_err(|err| storage(err.to_string()))?; + // A Session that materialized the old row before this repair must + // not later acknowledge it as delivered. The guarded mark-read + // path also rechecks the resolution table. + conn.execute( + "DELETE FROM agent_org_runtime_inbox_materializations WHERE inbox_id=?1", + params![params.inbox_id], ) + .map_err(|err| storage(err.to_string()))?; + Ok(AgentInboxDeliveryResolution { + inbox_id: params.inbox_id, + org_run_id: params.org_run_id, + resolution_kind: params.resolution_kind, + resolved_by_member_id: params.resolved_by_member_id, + reason: params.reason, + replacement_inbox_id: params.replacement_inbox_id, + replacement_task_id: params.replacement_task_id, + created_at, + }) } /// Return a bounded tail of one run's inbox history in chronological diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs index 3a315b64b..af5b757e1 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_write.rs @@ -10,6 +10,95 @@ use super::record::row_to_record; use super::{AgentInboxRecord, AgentInboxStore, InsertInboxParams}; impl AgentInboxStore { + /// Resolve formal coordination rows that became impossible to execute + /// while their Member's exact Provider Turn was still running. + /// + /// Writer cancellation/reassignment deliberately does not stop an old + /// TaskExecution. Coordinator messages sent while that Turn is active are + /// therefore persisted but cannot be drained until the Member becomes + /// idle. If every Task owned by the Member is terminal or reassigned at + /// that boundary, a new formal wake has no Task authority to bind to. Keep + /// the original rows unread for audit, but record a lifecycle-owned + /// cancellation so they no longer cause an impossible wake loop or block + /// Team Quiescence forever. + /// + /// This is intentionally limited to the pre-UserDirectedWork formal + /// message classes. PR 9's user-directed Inbox rows have their own exact + /// source authority and must never be swept by this fallback. + pub(crate) fn resolve_obsolete_formal_rows_after_successful_member_turn( + org_run_id: &str, + member_id: &str, + ) -> Result { + const REASON: &str = "member_turn_finished_without_owned_formal_work"; + + let resolved = with_sessions_writer(|| -> Result { + 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 has_owned_open_work: bool = tx + .query_row( + "SELECT EXISTS( + SELECT 1 FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND owner=?2 + AND status IN ('pending','in_progress') + )", + params![org_run_id, member_id], + |row| row.get(0), + ) + .map_err(|err| err.to_string())?; + if has_owned_open_work { + tx.commit().map_err(|err| err.to_string())?; + return Ok(0); + } + + let now = chrono::Utc::now().to_rfc3339(); + let resolved = tx + .execute( + "INSERT OR IGNORE INTO agent_org_runtime_inbox_delivery_resolutions ( + inbox_id,org_run_id,resolution_kind,resolved_by_member_id,reason, + replacement_inbox_id,replacement_task_id,created_at + ) + SELECT inbox.id,inbox.org_run_id,'cancelled','system:lifecycle', + ?3,NULL,NULL,?4 + FROM agent_org_runtime_inbox inbox + WHERE inbox.org_run_id=?1 + AND inbox.recipient_member_id=?2 + AND inbox.read_at IS NULL + AND inbox.payload_kind IN ( + 'plain','task_assigned','plan_approval_response','shutdown_request' + ) + AND NOT EXISTS ( + SELECT 1 + FROM agent_org_runtime_inbox_delivery_resolutions resolution + WHERE resolution.inbox_id=inbox.id + )", + params![org_run_id, member_id, REASON, &now], + ) + .map_err(|err| err.to_string())?; + tx.execute( + "DELETE FROM agent_org_runtime_inbox_materializations + WHERE inbox_id IN ( + SELECT resolution.inbox_id + FROM agent_org_runtime_inbox_delivery_resolutions resolution + JOIN agent_org_runtime_inbox inbox ON inbox.id=resolution.inbox_id + WHERE resolution.org_run_id=?1 + AND resolution.reason=?3 + AND inbox.recipient_member_id=?2 + )", + params![org_run_id, member_id, REASON], + ) + .map_err(|err| err.to_string())?; + tx.commit().map_err(|err| err.to_string())?; + Ok(resolved) + })?; + + if resolved > 0 { + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(org_run_id); + } + Ok(resolved) + } + /// Persist a message and return the inserted record. The caller is /// responsible for resolving display-name / broadcast targets to one or /// more concrete `AgentId`s before calling this — the store does not diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/artifact.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/artifact.rs index 041ca5e15..aaebe045e 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/artifact.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/artifact.rs @@ -22,7 +22,7 @@ pub(super) struct OwnedPlanPath { file_name: String, } -pub(super) struct StagedPlanArtifact { +pub(crate) struct StagedPlanArtifact { owned: OwnedPlanPath, temp_path: PathBuf, target_path: PathBuf, @@ -31,7 +31,7 @@ pub(super) struct StagedPlanArtifact { /// Plan artifacts are a derived filesystem projection of SQLite state. A /// dedicated lock preserves commit/install order without holding the global /// sessions writer across rename or directory fsync. -pub(super) fn plan_artifact_install_lock() -> &'static parking_lot::Mutex<()> { +pub(crate) fn plan_artifact_install_lock() -> &'static parking_lot::Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| parking_lot::Mutex::new(())) } @@ -254,7 +254,7 @@ pub(super) fn resolve_owned_plan_target( } } -pub(super) fn stage_plan_artifact_with_connection( +pub(crate) fn stage_plan_artifact_with_connection( conn: &Connection, source_session_id: &str, plan_path: &str, @@ -355,7 +355,7 @@ fn stage_owned_plan_artifact( /// Install only the already-fsynced bytes. Callers invoke this after SQLite /// commits while holding the dedicated artifact lock so two revisions cannot /// install out of commit order and unrelated database writes are not blocked. -pub(super) fn install_staged_plan_artifact( +pub(crate) fn install_staged_plan_artifact( staged: Option<&StagedPlanArtifact>, ) -> Result<(), String> { let Some(staged) = staged else { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs index 9e4658bc3..9fb9528b7 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::coordination::agent_org_tasks::TaskMutationOutcome; use crate::definitions::orgs::PlanApprovalPolicy; -mod artifact; +pub(crate) mod artifact; mod persistence; mod store; mod transitions; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs index 55bb209c3..fa093f1fa 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs @@ -42,6 +42,57 @@ pub struct AgentOrgPlanArtifactRepairReport { } impl AgentOrgPlanApprovalStore { + pub(crate) fn submit_agent_org_plan_in_tx( + conn: &Connection, + params: CreateAgentOrgPlanApprovalParams, + delivery: Option, + ) -> Result, String> { + match params.policy { + PlanApprovalPolicy::Coordinator => { + let delivery = delivery.ok_or_else(|| { + "coordinator plan approval requires Inbox delivery".to_string() + })?; + validate_delivery(&delivery)?; + let approval = create_pending_in_tx(conn, params)?; + AgentInboxStore::insert_in_tx( + conn, + InsertInboxParams { + recipient_agent_id: delivery.recipient_agent_id, + recipient_member_id: Some(COORDINATOR_MEMBER_ID.to_string()), + sender_agent_id: delivery.sender_agent_id, + sender_member_id: delivery.sender_member_id, + org_run_id: Some(approval.org_run_id.clone()), + message: plan_approval_request_message(&approval), + }, + )?; + Ok(vec![COORDINATOR_MEMBER_ID.to_string()]) + } + PlanApprovalPolicy::User => { + if delivery.is_some() { + return Err("user plan approval does not accept Inbox delivery".to_string()); + } + create_pending_in_tx(conn, params)?; + Ok(Vec::new()) + } + PlanApprovalPolicy::Automatic => { + if delivery.is_some() { + return Err( + "automatic plan approval does not accept Inbox delivery".to_string() + ); + } + let approval = create_pending_in_tx(conn, params)?; + let plan_content = approval.plan_content.clone(); + let approved = approve_pending_in_tx( + conn, + approval, + AgentOrgPlanDecisionBy::System, + plan_content, + )?; + Ok(approved.wake_member_ids) + } + } + } + /// Resolve a filename under the exact Plan root owned by a persisted /// source session. Callers use this when they need a fresh path after a /// historical revision points outside the session's managed root. @@ -299,8 +350,16 @@ impl AgentOrgPlanApprovalStore { request_id: &str, ) -> Result, String> { let conn = get_connection().map_err(|err| err.to_string())?; + Self::get_pending_by_request_id_with_connection(&conn, run_id, request_id) + } + + pub(crate) fn get_pending_by_request_id_with_connection( + conn: &Connection, + run_id: &str, + request_id: &str, + ) -> Result, String> { query_record( - &conn, + conn, "WHERE org_run_id=?1 AND request_id=?2 AND status='pending'", params![run_id, request_id], ) @@ -420,6 +479,32 @@ impl AgentOrgPlanApprovalStore { Ok(approved) } + pub(crate) fn approve_in_tx( + conn: &Connection, + approval_id: &str, + plan_revision_id: &str, + decision_by: AgentOrgPlanDecisionBy, + edited_content: Option, + ) -> Result { + if let Some(edited_content) = edited_content.as_deref() { + validate_required_text( + "plan approval edited content", + edited_content, + PLAN_CONTENT_MAX_CHARS, + PLAN_CONTENT_MAX_BYTES, + )?; + } + let approval = query_record( + conn, + "WHERE approval_id=?1 AND plan_revision_id=?2 AND status='pending'", + params![approval_id, plan_revision_id], + )? + .ok_or_else(|| "agent_org_plan_approval_stale_revision".to_string())?; + authorize_decision(approval.policy, decision_by)?; + let plan_content = edited_content.unwrap_or_else(|| approval.plan_content.clone()); + approve_pending_in_tx(conn, approval, decision_by, plan_content) + } + pub fn request_changes( approval_id: &str, plan_revision_id: &str, @@ -427,86 +512,21 @@ impl AgentOrgPlanApprovalStore { feedback: &str, delivery: AgentOrgPlanInboxDelivery, ) -> Result<(AgentOrgPlanApproval, AgentInboxRecord), String> { - let feedback = feedback.trim(); - validate_required_text( - "plan approval feedback", - feedback, - PLAN_FEEDBACK_MAX_CHARS, - PLAN_FEEDBACK_MAX_BYTES, - )?; - validate_delivery(&delivery)?; let result = with_sessions_writer(|| { let mut conn = get_connection().map_err(|err| err.to_string())?; let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(|err| err.to_string())?; - let approval = query_record( - &tx, - "WHERE approval_id=?1 AND plan_revision_id=?2 AND status='pending'", - params![approval_id, plan_revision_id], - )? - .ok_or_else(|| "agent_org_plan_approval_stale_revision".to_string())?; - authorize_decision(approval.policy, decision_by)?; - let run_status: String = tx - .query_row( - "SELECT status FROM agent_org_runtime_runs WHERE id=?1", - params![&approval.org_run_id], - |row| row.get(0), - ) - .map_err(|err| err.to_string())?; - if run_status != "running" { - return Err(crate::coordination::agent_org_runs::mutation_blocked_error( - &approval.org_run_id, - &run_status, - )); - } - let resolved_at = chrono::Utc::now().to_rfc3339(); - let changed = tx - .execute( - "UPDATE agent_org_runtime_plan_approvals - SET status=?1, decision_by=?2, feedback=?3, resolved_at=?4 - WHERE approval_id=?5 AND plan_revision_id=?6 AND status=?7", - params![ - AgentOrgPlanApprovalStatus::ChangesRequested.as_wire(), - decision_by.as_wire(), - feedback, - &resolved_at, - approval_id, - plan_revision_id, - AgentOrgPlanApprovalStatus::Pending.as_wire(), - ], - ) - .map_err(|err| err.to_string())?; - if changed != 1 { - return Err("agent_org_plan_approval_stale_revision".to_string()); - } - let inbox_record = AgentInboxStore::insert_in_tx( + let result = Self::request_changes_in_tx( &tx, - InsertInboxParams { - recipient_agent_id: delivery.recipient_agent_id, - recipient_member_id: Some(approval.source_member_id.clone()), - sender_agent_id: delivery.sender_agent_id, - sender_member_id: delivery.sender_member_id, - org_run_id: Some(approval.org_run_id.clone()), - message: AgentMessage::PlanApprovalResponse { - request_id: RequestId(approval.request_id.clone()), - accepted: false, - feedback: Some(feedback.to_string()), - next_mode: Some(crate::session::AgentExecMode::Plan), - }, - }, + approval_id, + plan_revision_id, + decision_by, + feedback, + delivery, )?; tx.commit().map_err(|err| err.to_string())?; - Ok(( - AgentOrgPlanApproval { - status: AgentOrgPlanApprovalStatus::ChangesRequested, - decision_by: Some(decision_by.as_wire().to_string()), - feedback: Some(feedback.to_string()), - resolved_at: Some(resolved_at), - ..approval - }, - inbox_record, - )) + Ok::<_, String>(result) })?; crate::coordination::agent_org_run_events::notify_agent_org_run_changed( &result.0.org_run_id, @@ -514,6 +534,90 @@ impl AgentOrgPlanApprovalStore { Ok(result) } + pub(crate) fn request_changes_in_tx( + conn: &Connection, + approval_id: &str, + plan_revision_id: &str, + decision_by: AgentOrgPlanDecisionBy, + feedback: &str, + delivery: AgentOrgPlanInboxDelivery, + ) -> Result<(AgentOrgPlanApproval, AgentInboxRecord), String> { + let feedback = feedback.trim(); + validate_required_text( + "plan approval feedback", + feedback, + PLAN_FEEDBACK_MAX_CHARS, + PLAN_FEEDBACK_MAX_BYTES, + )?; + validate_delivery(&delivery)?; + let approval = query_record( + conn, + "WHERE approval_id=?1 AND plan_revision_id=?2 AND status='pending'", + params![approval_id, plan_revision_id], + )? + .ok_or_else(|| "agent_org_plan_approval_stale_revision".to_string())?; + authorize_decision(approval.policy, decision_by)?; + let run_status: String = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + params![&approval.org_run_id], + |row| row.get(0), + ) + .map_err(|err| err.to_string())?; + if run_status != "running" { + return Err(crate::coordination::agent_org_runs::mutation_blocked_error( + &approval.org_run_id, + &run_status, + )); + } + let resolved_at = chrono::Utc::now().to_rfc3339(); + let changed = conn + .execute( + "UPDATE agent_org_runtime_plan_approvals + SET status=?1, decision_by=?2, feedback=?3, resolved_at=?4 + WHERE approval_id=?5 AND plan_revision_id=?6 AND status=?7", + params![ + AgentOrgPlanApprovalStatus::ChangesRequested.as_wire(), + decision_by.as_wire(), + feedback, + &resolved_at, + approval_id, + plan_revision_id, + AgentOrgPlanApprovalStatus::Pending.as_wire(), + ], + ) + .map_err(|err| err.to_string())?; + if changed != 1 { + return Err("agent_org_plan_approval_stale_revision".to_string()); + } + let inbox_record = AgentInboxStore::insert_in_tx( + conn, + InsertInboxParams { + recipient_agent_id: delivery.recipient_agent_id, + recipient_member_id: Some(approval.source_member_id.clone()), + sender_agent_id: delivery.sender_agent_id, + sender_member_id: delivery.sender_member_id, + org_run_id: Some(approval.org_run_id.clone()), + message: AgentMessage::PlanApprovalResponse { + request_id: RequestId(approval.request_id.clone()), + accepted: false, + feedback: Some(feedback.to_string()), + next_mode: Some(crate::session::AgentExecMode::Plan), + }, + }, + )?; + Ok(( + AgentOrgPlanApproval { + status: AgentOrgPlanApprovalStatus::ChangesRequested, + decision_by: Some(decision_by.as_wire().to_string()), + feedback: Some(feedback.to_string()), + resolved_at: Some(resolved_at), + ..approval + }, + inbox_record, + )) + } + pub fn get(approval_id: &str) -> Result, String> { let conn = get_connection().map_err(|err| err.to_string())?; query_record(&conn, "WHERE approval_id=?1", params![approval_id]) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs index 7a3a66b5d..d075f742e 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs @@ -613,14 +613,10 @@ fn approval_rejects_atomically_when_source_task_was_cancelled() { let (_sandbox, context) = setup(PlanApprovalPolicy::Coordinator); create_plan_task(&context); let pending = create_pending_approval(&context); - let task = AgentOrgTaskStore::get(&context.run_id, "plan-task") - .unwrap() - .unwrap(); AgentOrgTaskStore::cancel_with_transactional_effects( TaskGraphWriterAdmin::new("root-plan-approval", "coordinator-turn").unwrap(), &context.run_id, "plan-task", - &task.updated_at, TaskTerminalReason { code: "scope.changed".to_string(), message: "replace the planning goal".to_string(), diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs index 32c4eddd9..b15147e56 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use rusqlite::{params, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension}; use crate::coordination::agent_inbox::{ AgentInboxStore, AgentMessage, InsertInboxParams, RequestId, SYSTEM_SENDER_ID, @@ -23,7 +23,7 @@ use super::{ }; pub(super) fn create_pending_in_tx( - tx: &rusqlite::Transaction<'_>, + tx: &Connection, params: CreateAgentOrgPlanApprovalParams, ) -> Result { validate_create_params(¶ms)?; @@ -123,7 +123,7 @@ pub(super) fn create_pending_in_tx( } pub(super) fn approve_pending_in_tx( - tx: &rusqlite::Transaction<'_>, + tx: &Connection, approval: AgentOrgPlanApproval, decision_by: AgentOrgPlanDecisionBy, plan_content: String, @@ -202,7 +202,7 @@ pub(super) fn approve_pending_in_tx( /// transaction commits. A wake is merely a best-effort doorbell; the inbox /// rows remain the source of truth across queue failure, pause, or restart. fn enqueue_post_approval_messages_in_tx( - tx: &rusqlite::Transaction<'_>, + tx: &Connection, approved: &ApprovedAgentOrgPlan, ) -> Result, String> { let tasks = AgentOrgTaskStore::list_with_connection(tx, &approved.approval.org_run_id)?; @@ -283,7 +283,7 @@ fn enqueue_post_approval_messages_in_tx( } fn participant_agent_ids_in_tx( - tx: &rusqlite::Transaction<'_>, + tx: &Connection, run_id: &str, ) -> Result<(String, HashMap), String> { let (coordinator_agent_id, snapshot_json): (String, Option) = tx 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 5bbe59603..561e47440 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 @@ -173,9 +173,9 @@ pub struct AgentOrgRunContext { pub members: Vec, /// Plan-approval policy captured in the launch snapshot. pub plan_approval_policy: PlanApprovalPolicy, - /// Compiled capability facts for future Writer/peer activation. PR2 - /// persists and freezes these facts but does not use them to authorize - /// Task mutations or member-to-member delivery yet. + /// Compiled capability facts frozen at Team launch. Writer authority is + /// resolved from this snapshot; mutable prompts, links, and definitions + /// cannot grant it to an already-running Team. #[serde(skip)] pub capability_index: AgentOrgCapabilityIndex, /// Session ID of the coordinator (root) session for this run. Used by @@ -280,8 +280,8 @@ impl AgentOrgRunContext { /// Task assignees that `caller_member_id` is authorized to manage. /// /// - coordinator: itself plus every roster member; - /// - ordinary member: itself; - /// - ordinary member: itself only until PR7 activates configured Writers. + /// - configured graph writer: itself plus every roster member; + /// - ordinary member: itself. /// /// This is the task-governance source of truth. It must not be replaced by /// `allowed_recipient_member_ids_for`: permission to talk to a peer is not @@ -291,7 +291,9 @@ impl AgentOrgRunContext { return Vec::new(); } - let mut allowed = if caller_member_id == COORDINATOR_MEMBER_ID { + let mut allowed = if caller_member_id == COORDINATOR_MEMBER_ID + || self.capability_index.is_additional_writer(caller_member_id) + { self.participants() .into_iter() .map(|participant| participant.member_id) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/progress.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/progress.rs index 9f9b68a6d..0f5301e62 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/progress.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/progress.rs @@ -5,7 +5,7 @@ //! coordinator turn was presented with (and successfully observed) the latest //! durable task mutation before announcing completion. -use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; use crate::coordination::agent_org_payload_limits::{ @@ -71,10 +71,7 @@ pub(super) fn ensure_progress_in_conn(conn: &Connection, org_run_id: &str) -> Re /// /// A new mutation invalidates an earlier explicit completion request. The /// coordinator must observe the new revision and request completion again. -pub(crate) fn bump_work_revision_in_tx( - tx: &Transaction<'_>, - org_run_id: &str, -) -> Result { +pub(crate) fn bump_work_revision_in_tx(tx: &Connection, org_run_id: &str) -> Result { ensure_progress_in_conn(tx, org_run_id)?; tx.execute( "UPDATE agent_org_runtime_run_progress @@ -193,7 +190,7 @@ pub(super) fn mark_coordinator_observed_revision_with_conn( } pub(super) fn record_completion_request_in_tx( - tx: &Transaction<'_>, + tx: &Connection, org_run_id: &str, summary: &str, ) -> Result { 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 ea4a62e0f..7ac65f9b1 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 @@ -754,48 +754,11 @@ impl AgentOrgRunStore { summary: &str, ) -> Result { let outcome = with_sessions_writer(|| { - 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 status: Option = tx - .query_row( - "SELECT status FROM agent_org_runtime_runs WHERE id=?1", - params![run_id], - |row| row.get(0), - ) - .optional() - .map_err(|err| err.to_string())?; - let Some(status) = status else { - return Err(format!("agent_org_run_not_found: {run_id}")); - }; - if status != AgentOrgRunStatus::Running.as_str() { - return Err(super::mutation_blocked_error(run_id, &status)); - } - - let unresolved_task_ids = { - let mut stmt = tx - .prepare( - "SELECT id FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND status IN ('pending','in_progress') - ORDER BY created_at ASC, id ASC", - ) - .map_err(|err| err.to_string())?; - let rows = stmt - .query_map(params![run_id], |row| row.get::<_, String>(0)) - .map_err(|err| err.to_string())?; - rows.collect::, _>>() - .map_err(|err| err.to_string())? - }; - if !unresolved_task_ids.is_empty() { - tx.commit().map_err(|err| err.to_string())?; - return Ok(AgentOrgCompletionRequestOutcome::OpenTasks { - unresolved_task_ids, - }); - } - let progress = record_completion_request_in_tx(&tx, run_id, summary)?; + let conn = get_connection().map_err(|err| err.to_string())?; + let tx = database::db::begin_immediate(&conn).map_err(|err| err.to_string())?; + let outcome = Self::request_completion_in_tx(&tx, run_id, summary)?; tx.commit().map_err(|err| err.to_string())?; - Ok(AgentOrgCompletionRequestOutcome::Recorded { progress }) + Ok::<_, String>(outcome) })?; if matches!(&outcome, AgentOrgCompletionRequestOutcome::Recorded { .. }) { crate::coordination::agent_org_run_events::notify_agent_org_run_changed(run_id); @@ -803,6 +766,48 @@ impl AgentOrgRunStore { Ok(outcome) } + pub(crate) fn request_completion_in_tx( + conn: &rusqlite::Connection, + run_id: &str, + summary: &str, + ) -> Result { + let status: Option = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + params![run_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| err.to_string())?; + let Some(status) = status else { + return Err(format!("agent_org_run_not_found: {run_id}")); + }; + if status != AgentOrgRunStatus::Running.as_str() { + return Err(super::mutation_blocked_error(run_id, &status)); + } + let unresolved_task_ids = { + let mut stmt = conn + .prepare( + "SELECT id FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND status IN ('pending','in_progress') + ORDER BY created_at ASC, id ASC", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map(params![run_id], |row| row.get::<_, String>(0)) + .map_err(|err| err.to_string())?; + rows.collect::, _>>() + .map_err(|err| err.to_string())? + }; + if !unresolved_task_ids.is_empty() { + return Ok(AgentOrgCompletionRequestOutcome::OpenTasks { + unresolved_task_ids, + }); + } + let progress = record_completion_request_in_tx(conn, run_id, summary)?; + Ok(AgentOrgCompletionRequestOutcome::Recorded { progress }) + } + pub fn assess_run_quiescence(run_id: &str) -> Result { let mut conn = get_connection().map_err(|err| err.to_string())?; let tx = conn @@ -1098,6 +1103,103 @@ impl AgentOrgRunStore { Ok(status_raw.as_deref().and_then(AgentOrgRunStatus::parse)) } + /// Promote the canonical Root Coordinator's current Idle Turn only when + /// that same transaction is about to commit new formal Task graph work. + /// The caller owns the transaction, so any later Task/history/outbox or + /// receipt failure rolls this generation change back as well. + pub(crate) fn activate_idle_for_task_graph_in_tx( + conn: &Connection, + run_id: &str, + session_id: &str, + turn_intent_id: &str, + ) -> Result { + let run: Option<(String, i64)> = conn + .query_row( + "SELECT status,activation_generation + FROM agent_org_runtime_runs WHERE id=?1", + [run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((status_raw, generation)) = run else { + return Err(format!("agent_org_run_not_found: {run_id}")); + }; + let status = AgentOrgRunStatus::parse(&status_raw) + .ok_or_else(|| format!("unknown Agent Org run status: {status_raw}"))?; + match status { + AgentOrgRunStatus::Running => return Ok(false), + AgentOrgRunStatus::Paused => { + return Err(format!( + "team_paused_resume_required: Agent Org run {run_id} must be resumed before creating formal work" + )); + } + AgentOrgRunStatus::Archived => { + return Err(format!( + "team_archived: Agent Org run {run_id} is read-only" + )); + } + AgentOrgRunStatus::Starting | AgentOrgRunStatus::Failed => { + return Err(super::mutation_blocked_error(run_id, status.as_str())); + } + AgentOrgRunStatus::Idle => {} + } + + let context = + crate::coordination::agent_org_turn_contexts::revalidate_context_with_connection( + conn, + session_id, + turn_intent_id, + )?; + if context.org_run_id != run_id + || context.participant_id != COORDINATOR_MEMBER_ID + || context.turn_kind + != crate::coordination::agent_org_turn_contexts::AgentOrgTurnKind::Coordinator + { + return Err( + "task_graph_writer_idle_activation_requires_canonical_coordinator_turn".to_string(), + ); + } + let next_generation = generation + .checked_add(1) + .ok_or_else(|| format!("Agent Org run {run_id} generation overflow"))?; + let now = chrono::Utc::now().to_rfc3339(); + let changed = conn + .execute( + "UPDATE agent_org_runtime_runs + SET status='running',activation_generation=?2,updated_at=?3, + idled_at=NULL,last_activity_outcome=NULL + WHERE id=?1 AND status='idle' AND activation_generation=?4", + params![run_id, next_generation, &now, generation], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err(format!( + "agent_org_idle_activation_conflict: run {run_id} changed before commit" + )); + } + let marked = conn + .execute( + "UPDATE agent_org_runtime_turn_contexts + SET activation_generation=?4 + WHERE session_id=?1 AND turn_intent_id=?2 AND org_run_id=?3 + AND participant_id='coordinator' AND turn_kind='coordinator' + AND activation_generation=?5", + params![ + session_id, + turn_intent_id, + run_id, + next_generation, + generation + ], + ) + .map_err(|error| error.to_string())?; + if marked != 1 { + return Err("task_graph_writer_idle_activation_turn_marker_conflict".to_string()); + } + Ok(true) + } + /// Read the canonical quiescence facts and decision from an existing /// connection or read transaction. Run View and task-list projections use /// this to keep all of their independently-shaped rows on one SQLite diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs index 021a94302..887abb74c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs @@ -38,26 +38,40 @@ impl TaskGraphWriterAdmin { ) -> Result { let context = require_context_with_connection(conn, &self.session_id, &self.turn_intent_id)?; - validate_run_and_generation(conn, org_run_id, context.activation_generation)?; - if context.org_run_id != org_run_id - || context.turn_kind != AgentOrgTurnKind::Coordinator - || context.participant_id != COORDINATOR_MEMBER_ID - || context.task_id.is_some() - || context.owner_member_id.is_some() - { + let snapshot = + validate_run_and_generation(conn, org_run_id, context.activation_generation)?; + if context.org_run_id != org_run_id { return Err("task_graph_writer_context_mismatch".to_string()); } - let root_session_id: Option = conn - .query_row( - "SELECT root_session_id FROM agent_org_runtime_runs WHERE id=?1", - [org_run_id], - |row| row.get(0), - ) - .optional() - .map_err(|error| error.to_string())? - .flatten(); - if root_session_id.as_deref() != Some(self.session_id.as_str()) { - return Err("task_graph_writer_not_canonical_root".to_string()); + let is_coordinator = context.turn_kind == AgentOrgTurnKind::Coordinator + && context.participant_id == COORDINATOR_MEMBER_ID + && context.task_id.is_none() + && context.owner_member_id.is_none(); + if is_coordinator { + let root_session_id: Option = conn + .query_row( + "SELECT root_session_id FROM agent_org_runtime_runs WHERE id=?1", + [org_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .flatten(); + if root_session_id.as_deref() != Some(self.session_id.as_str()) { + return Err("task_graph_writer_not_canonical_root".to_string()); + } + } else { + let is_bound_writer_execution = context.turn_kind == AgentOrgTurnKind::TaskExecution + && context.task_id.is_some() + && context.owner_member_id.as_deref() == Some(context.participant_id.as_str()) + && context.participant_id != COORDINATOR_MEMBER_ID + && snapshot + .additional_task_graph_writer_member_ids + .iter() + .any(|member_id| member_id == &context.participant_id); + if !is_bound_writer_execution { + return Err("task_graph_writer_context_mismatch".to_string()); + } } Ok(TaskActorAudit { kind: TaskActorKind::GraphWriter, @@ -65,6 +79,18 @@ impl TaskGraphWriterAdmin { turn_intent_id: Some(context.turn_intent_id), }) } + + pub(crate) fn validate_canonical_coordinator( + &self, + conn: &rusqlite::Connection, + org_run_id: &str, + ) -> Result<(), String> { + let audit = self.validate(conn, org_run_id)?; + if audit.participant_id != COORDINATOR_MEMBER_ID { + return Err("agent_org_coordinator_context_required".to_string()); + } + Ok(()) + } } #[derive(Debug, Clone)] @@ -279,7 +305,7 @@ fn validate_run_and_generation( conn: &rusqlite::Connection, org_run_id: &str, expected_generation: Option, -) -> Result<(), String> { +) -> Result { let row: Option<(String, i64, Option)> = conn .query_row( "SELECT status, activation_generation, org_snapshot_json @@ -308,5 +334,6 @@ fn validate_run_and_generation( serde_json::from_str(&snapshot_json) .map_err(|error| format!("task_actor_snapshot_invalid: {error}"))?; crate::definitions::orgs::validate_launch_snapshot(&snapshot) - .map_err(|error| format!("task_actor_snapshot_invalid: {error}")) + .map_err(|error| format!("task_actor_snapshot_invalid: {error}"))?; + Ok(snapshot) } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs index 88e735c11..7222e6380 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs @@ -1,4 +1,6 @@ -use rusqlite::{params, Connection, Result as SqliteResult, Transaction}; +#[cfg(test)] +use rusqlite::Transaction; +use rusqlite::{params, Connection, Result as SqliteResult}; #[cfg(test)] use super::TaskHistoryEvent; @@ -225,7 +227,7 @@ pub(super) fn insert_task_history_event( } pub(super) fn insert_task_history_event_as( - tx: &Transaction<'_>, + tx: &Connection, org_run_id: &str, task_id: &str, event_type: &str, diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs index df6030e56..eefdc204f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs @@ -573,7 +573,13 @@ pub struct PendingTaskGraphPatch { pub owner: Option>, pub execution_mode: Option, pub blocked_by: Option>, - pub metadata: Option>, + /// RFC 7396-style object merge patch. Absent keys retain their persisted + /// value, non-null values replace it, and null removes it. Reserved typed + /// Task fields never enter this bag. + pub metadata_merge_patch: Option, + pub eligible_member_ids: Option>, + /// `Some("")` explicitly clears the typed role hint; `None` retains it. + pub required_role: Option, } /// Test-only compatibility patch for Store fixtures that exercise the diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs index 3e5535a99..8e5f56c3b 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs @@ -40,6 +40,31 @@ impl AgentOrgTaskStore { }) } + pub(crate) fn append_owner_annotation_in_tx( + conn: &rusqlite::Connection, + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + kind: TaskAnnotationKind, + body: String, + ) -> Result { + if !matches!( + kind, + TaskAnnotationKind::Progress | TaskAnnotationKind::Evidence + ) { + return Err("Owner may append only progress or evidence".to_string()); + } + append_annotation_in_tx(conn, org_run_id, task_id, kind, body, |tx, task| { + let audit = actor.validate(tx, org_run_id, task_id)?; + if task.status != TaskStatus::InProgress + || task.owner.as_deref() != Some(audit.participant_id.as_str()) + { + return Err("Owner annotations require the Owner's in-progress task".to_string()); + } + Ok(audit) + }) + } + pub fn append_audit_annotation( actor: TaskGraphWriterAdmin, org_run_id: &str, @@ -61,6 +86,29 @@ impl AgentOrgTaskStore { ) } + pub(crate) fn append_audit_annotation_in_tx( + conn: &rusqlite::Connection, + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, + body: String, + ) -> Result { + append_annotation_in_tx( + conn, + org_run_id, + task_id, + TaskAnnotationKind::AuditNote, + body, + |tx, task| { + let audit = actor.validate(tx, org_run_id)?; + if !task.status.is_terminal() { + return Err("audit_note is available only after a task is terminal".to_string()); + } + Ok(audit) + }, + ) + } + pub fn list_annotation_page( org_run_id: &str, task_id: &str, @@ -178,53 +226,70 @@ fn append_annotation( TASK_ANNOTATION_BODY_MAX_BYTES, )?; let annotation = with_sessions_writer(|| -> Result { - let mut conn = get_connection().map_err(|error| error.to_string())?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| error.to_string())?; - let sql = format!( - "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND id=?2" - ); - let task = tx - .query_row(&sql, params![org_run_id, task_id], row_to_task) - .optional() - .map_err(|error| error.to_string())? - .ok_or_else(|| format!("task_not_found: {task_id} in run {org_run_id}"))?; - let audit = validate_actor(&tx, &task)?; - let annotation = TaskAnnotation { - id: uuid::Uuid::new_v4().to_string(), - org_run_id: org_run_id.to_string(), - task_id: task_id.to_string(), - kind, - body, - actor_kind: audit.kind.as_wire().to_string(), - actor_participant_id: audit.participant_id, - source_turn_intent_id: audit.turn_intent_id, - created_at: now_rfc3339(), - }; - tx.execute( - "INSERT INTO agent_org_runtime_task_annotations( - id, org_run_id, task_id, kind, body, actor_kind, - actor_participant_id, source_turn_intent_id, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - &annotation.id, - &annotation.org_run_id, - &annotation.task_id, - annotation.kind.as_wire(), - &annotation.body, - &annotation.actor_kind, - &annotation.actor_participant_id, - annotation.source_turn_intent_id.as_deref(), - &annotation.created_at, - ], - ) - .map_err(|error| error.to_string())?; - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, org_run_id)?; + let conn = get_connection().map_err(|error| error.to_string())?; + let tx = database::db::begin_immediate(&conn).map_err(|error| error.to_string())?; + let annotation = + append_annotation_in_tx(&tx, org_run_id, task_id, kind, body, validate_actor)?; tx.commit().map_err(|error| error.to_string())?; Ok(annotation) })?; crate::coordination::agent_org_run_events::notify_agent_org_run_changed(org_run_id); Ok(annotation) } + +fn append_annotation_in_tx( + conn: &rusqlite::Connection, + org_run_id: &str, + task_id: &str, + kind: TaskAnnotationKind, + body: String, + validate_actor: impl FnOnce(&rusqlite::Connection, &Task) -> Result, +) -> Result { + crate::coordination::agent_org_payload_limits::validate_required_text( + "task annotation body", + &body, + TASK_ANNOTATION_BODY_MAX_CHARS, + TASK_ANNOTATION_BODY_MAX_BYTES, + )?; + let sql = format!( + "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id=?2" + ); + let task = conn + .query_row(&sql, params![org_run_id, task_id], row_to_task) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("task_not_found: {task_id} in run {org_run_id}"))?; + let audit = validate_actor(conn, &task)?; + let annotation = TaskAnnotation { + id: uuid::Uuid::new_v4().to_string(), + org_run_id: org_run_id.to_string(), + task_id: task_id.to_string(), + kind, + body, + actor_kind: audit.kind.as_wire().to_string(), + actor_participant_id: audit.participant_id, + source_turn_intent_id: audit.turn_intent_id, + created_at: now_rfc3339(), + }; + conn.execute( + "INSERT INTO agent_org_runtime_task_annotations( + id, org_run_id, task_id, kind, body, actor_kind, + actor_participant_id, source_turn_intent_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + &annotation.id, + &annotation.org_run_id, + &annotation.task_id, + annotation.kind.as_wire(), + &annotation.body, + &annotation.actor_kind, + &annotation.actor_participant_id, + annotation.source_turn_intent_id.as_deref(), + &annotation.created_at, + ], + ) + .map_err(|error| error.to_string())?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(conn, org_run_id)?; + Ok(annotation) +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs index 57c940e8e..8416e749f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs @@ -18,7 +18,8 @@ use super::super::{ SystemArchiveOrRecovery, Task, TaskCreateSchedulingPolicy, TaskGraphWriterAdmin, TaskMutationOutcome, TaskOutput, TaskOutputInput, TaskOwnerExecution, TaskStatus, TaskTerminalReason, TASK_EVENT_CREATED, TASK_EVENT_UPDATED, - TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, TASK_MUTATION_CONFLICT_ERROR, + TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, TASK_METADATA_ELIGIBLE_MEMBER_IDS, + TASK_METADATA_EXECUTION_MODE, TASK_METADATA_OUTPUT, TASK_METADATA_REQUIRED_ROLE, TASK_TERMINAL_IMMUTABLE_ERROR, }; use super::dependencies::canonicalize_dependencies; @@ -77,51 +78,90 @@ impl AgentOrgTaskStore { scheduling_policy: TaskCreateSchedulingPolicy, effects: impl FnOnce(&rusqlite::Connection, &Task, &[Task]) -> Result, ) -> Result<(Task, T), String> { - validate_create_params(¶ms)?; let run_id = params.org_run_id.clone(); let (task, effect) = with_sessions_writer(|| -> Result<(Task, T), String> { - let mut conn = get_connection().map_err(|error| error.to_string())?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| error.to_string())?; - let audit = actor.validate(&tx, &run_id)?; - let mut tasks = list_tasks_with_conn(&tx, &run_id)?; - ensure_task_run_capacity(tasks.iter().filter(|task| task.status.is_open()).count(), 1)?; - let now = now_rfc3339(); - let task = pending_task_from_params(params, &audit, &now)?; - validate_task_model_invariants(&tx, &task)?; - validate_replacement_reference(&tx, &task)?; - tasks.push(task); - canonicalize_dependencies(&mut tasks, &run_id)?; - let task = tasks - .last() - .cloned() - .expect("candidate graph includes newly-created task"); - enforce_scheduling_policy(&task, &tasks, scheduling_policy)?; - insert_task_row(&tx, &task)?; - insert_task_history_event_as( - &tx, - &run_id, - &task.id, - TASK_EVENT_CREATED, - None, - &task, - &audit, - )?; - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; - let effect = effects(&tx, &task, &tasks)?; + let conn = get_connection().map_err(|error| error.to_string())?; + let tx = database::db::begin_immediate(&conn).map_err(|error| error.to_string())?; + let result = + Self::create_pending_in_tx(&tx, actor, params, scheduling_policy, effects)?; tx.commit().map_err(|error| error.to_string())?; - Ok((task, effect)) + Ok(result) })?; crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); Ok((task, effect)) } + pub(crate) fn create_pending_in_tx( + conn: &rusqlite::Connection, + actor: TaskGraphWriterAdmin, + params: CreatePendingTaskParams, + scheduling_policy: TaskCreateSchedulingPolicy, + effects: impl FnOnce(&rusqlite::Connection, &Task, &[Task]) -> Result, + ) -> Result<(Task, T), String> { + validate_create_params(¶ms)?; + let run_id = params.org_run_id.clone(); + let audit = actor.validate(conn, &run_id)?; + let mut tasks = list_tasks_with_conn(conn, &run_id)?; + ensure_task_run_capacity(tasks.iter().filter(|task| task.status.is_open()).count(), 1)?; + let now = now_rfc3339(); + let task = pending_task_from_params(params, &audit, &now)?; + validate_task_model_invariants(conn, &task)?; + validate_replacement_reference(conn, &task)?; + tasks.push(task); + canonicalize_dependencies(&mut tasks, &run_id)?; + let task = tasks + .last() + .cloned() + .expect("candidate graph includes newly-created task"); + enforce_scheduling_policy(&task, &tasks, scheduling_policy)?; + insert_task_row(conn, &task)?; + insert_task_history_event_as( + conn, + &run_id, + &task.id, + TASK_EVENT_CREATED, + None, + &task, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(conn, &run_id)?; + let effect = effects(conn, &task, &tasks)?; + Ok((task, effect)) + } + pub fn create_pending_batch_with_transactional_effects( actor: TaskGraphWriterAdmin, params_list: Vec, allow_parallel_with_existing_open_tasks: bool, effects: impl FnOnce(&rusqlite::Connection, &[Task], &[Task]) -> Result, + ) -> Result<(Vec, T), String> { + let run_id = params_list + .first() + .map(|params| params.org_run_id.clone()) + .ok_or_else(|| "task graph must contain at least one task".to_string())?; + let (created, effect) = with_sessions_writer(|| -> Result<(Vec, T), String> { + let conn = get_connection().map_err(|error| error.to_string())?; + let tx = database::db::begin_immediate(&conn).map_err(|error| error.to_string())?; + let result = Self::create_pending_batch_in_tx( + &tx, + actor, + params_list, + allow_parallel_with_existing_open_tasks, + effects, + )?; + tx.commit().map_err(|error| error.to_string())?; + Ok(result) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + Ok((created, effect)) + } + + pub(crate) fn create_pending_batch_in_tx( + conn: &rusqlite::Connection, + actor: TaskGraphWriterAdmin, + params_list: Vec, + allow_parallel_with_existing_open_tasks: bool, + effects: impl FnOnce(&rusqlite::Connection, &[Task], &[Task]) -> Result, ) -> Result<(Vec, T), String> { if params_list.is_empty() { return Err("task graph must contain at least one task".to_string()); @@ -133,73 +173,64 @@ impl AgentOrgTaskStore { if params_list.iter().any(|params| params.org_run_id != run_id) { return Err("every task in a graph must belong to the same org run".to_string()); } - let (created, effect) = with_sessions_writer(|| -> Result<(Vec, T), String> { - let mut conn = get_connection().map_err(|error| error.to_string())?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| error.to_string())?; - let audit = actor.validate(&tx, &run_id)?; - let mut tasks = list_tasks_with_conn(&tx, &run_id)?; - let existing_count = tasks.len(); - ensure_task_run_capacity( - tasks.iter().filter(|task| task.status.is_open()).count(), - params_list.len(), - )?; - let now = now_rfc3339(); - let mut ids = tasks - .iter() - .map(|task| task.id.as_str()) - .collect::>(); - for params in ¶ms_list { - if !ids.insert(params.id.as_str()) { - return Err(format!("duplicate task id: {}", params.id)); - } - } - for params in params_list { - let task = pending_task_from_params(params, &audit, &now)?; - validate_task_model_invariants(&tx, &task)?; - validate_replacement_reference(&tx, &task)?; - tasks.push(task); - } - canonicalize_dependencies(&mut tasks, &run_id)?; - if !allow_parallel_with_existing_open_tasks { - let referenced = tasks[existing_count..] - .iter() - .flat_map(|task| task.blocked_by.iter()) - .cloned() - .collect::>(); - let covered = task_dependency_closure(&referenced, &tasks[..existing_count]); - let omitted = tasks[..existing_count] - .iter() - .filter(|task| task.status.is_open() && !covered.contains(&task.id)) - .map(|task| task.id.clone()) - .collect::>(); - if !omitted.is_empty() { - return Err(format!( - "{TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR}:{}", - omitted.join(",") - )); - } + let audit = actor.validate(conn, &run_id)?; + let mut tasks = list_tasks_with_conn(conn, &run_id)?; + let existing_count = tasks.len(); + ensure_task_run_capacity( + tasks.iter().filter(|task| task.status.is_open()).count(), + params_list.len(), + )?; + let now = now_rfc3339(); + let mut ids = tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(); + for params in ¶ms_list { + if !ids.insert(params.id.as_str()) { + return Err(format!("duplicate task id: {}", params.id)); } - let created = tasks[existing_count..].to_vec(); - for task in &created { - insert_task_row(&tx, task)?; - insert_task_history_event_as( - &tx, - &run_id, - &task.id, - TASK_EVENT_CREATED, - None, - task, - &audit, - )?; + } + for params in params_list { + let task = pending_task_from_params(params, &audit, &now)?; + validate_task_model_invariants(conn, &task)?; + validate_replacement_reference(conn, &task)?; + tasks.push(task); + } + canonicalize_dependencies(&mut tasks, &run_id)?; + if !allow_parallel_with_existing_open_tasks { + let referenced = tasks[existing_count..] + .iter() + .flat_map(|task| task.blocked_by.iter()) + .cloned() + .collect::>(); + let covered = task_dependency_closure(&referenced, &tasks[..existing_count]); + let omitted = tasks[..existing_count] + .iter() + .filter(|task| task.status.is_open() && !covered.contains(&task.id)) + .map(|task| task.id.clone()) + .collect::>(); + if !omitted.is_empty() { + return Err(format!( + "{TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR}:{}", + omitted.join(",") + )); } - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; - let effect = effects(&tx, &created, &tasks)?; - tx.commit().map_err(|error| error.to_string())?; - Ok((created, effect)) - })?; - crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + } + let created = tasks[existing_count..].to_vec(); + for task in &created { + insert_task_row(conn, task)?; + insert_task_history_event_as( + conn, + &run_id, + &task.id, + TASK_EVENT_CREATED, + None, + task, + &audit, + )?; + } + crate::coordination::agent_org_runs::bump_work_revision_in_tx(conn, &run_id)?; + let effect = effects(conn, &created, &tasks)?; Ok((created, effect)) } @@ -207,88 +238,94 @@ impl AgentOrgTaskStore { actor: TaskGraphWriterAdmin, org_run_id: &str, task_id: &str, - expected_updated_at: &str, patch: PendingTaskGraphPatch, effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, ) -> Result<(TaskMutationOutcome, T), String> { - if let Some(blocked_by) = patch.blocked_by.as_ref() { - validate_task_dependency_ids("blocked_by", blocked_by)?; - } let run_id = org_run_id.to_string(); let (outcome, effect) = with_sessions_writer(|| -> Result<_, String> { - let mut conn = get_connection().map_err(|error| error.to_string())?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| error.to_string())?; - let audit = actor.validate(&tx, &run_id)?; - let mut tasks = list_tasks_with_conn(&tx, &run_id)?; - let index = tasks - .iter() - .position(|task| task.id == task_id) - .ok_or_else(|| format!("task_not_found: {task_id} in run {run_id}"))?; - let previous = tasks[index].clone(); - if previous.updated_at != expected_updated_at { - return Err(format!( - "{TASK_MUTATION_CONFLICT_ERROR}: task {task_id} changed after authorization" - )); - } - if previous.status != TaskStatus::Pending { - return Err(format!( - "task_graph_edit_requires_pending: task {task_id} is {}", - previous.status.as_wire() - )); - } - let task = &mut tasks[index]; - if let Some(subject) = patch.subject { - task.subject = subject; - } - if let Some(description) = patch.description { - task.description = description; - } - if let Some(active_form) = patch.active_form { - task.active_form = active_form; - } - if let Some(owner) = patch.owner { - task.owner = owner; - } - if let Some(execution_mode) = patch.execution_mode { - task.execution_mode = execution_mode; - } - if let Some(blocked_by) = patch.blocked_by { - task.blocked_by = blocked_by; - } - if let Some(metadata) = patch.metadata { - task.metadata = metadata; - } - task.updated_at = now_rfc3339(); - validate_task_model_invariants(&tx, task)?; - canonicalize_dependencies(&mut tasks, &run_id)?; - let current = tasks[index].clone(); - update_task_row(&tx, ¤t)?; - insert_task_history_event_as( - &tx, - &run_id, - task_id, - TASK_EVENT_UPDATED, - Some(&previous), - ¤t, - &audit, - )?; - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; - let outcome = mutation_outcome(previous, current, &tasks); - let effect = effects(&tx, &outcome, &tasks)?; + let conn = get_connection().map_err(|error| error.to_string())?; + let tx = database::db::begin_immediate(&conn).map_err(|error| error.to_string())?; + let result = Self::patch_pending_in_tx(&tx, actor, &run_id, task_id, patch, effects)?; tx.commit().map_err(|error| error.to_string())?; - Ok((outcome, effect)) + Ok(result) })?; crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); Ok((outcome, effect)) } + pub(crate) fn patch_pending_in_tx( + conn: &rusqlite::Connection, + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, + patch: PendingTaskGraphPatch, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + if let Some(blocked_by) = patch.blocked_by.as_ref() { + validate_task_dependency_ids("blocked_by", blocked_by)?; + } + let audit = actor.validate(conn, org_run_id)?; + let mut tasks = list_tasks_with_conn(conn, org_run_id)?; + let index = tasks + .iter() + .position(|task| task.id == task_id) + .ok_or_else(|| format!("task_not_found: {task_id} in run {org_run_id}"))?; + let previous = tasks[index].clone(); + if previous.status != TaskStatus::Pending { + return Err(format!( + "task_graph_edit_requires_pending: task {task_id} is {}", + previous.status.as_wire() + )); + } + let task = &mut tasks[index]; + if let Some(subject) = patch.subject { + task.subject = subject; + } + if let Some(description) = patch.description { + task.description = description; + } + if let Some(active_form) = patch.active_form { + task.active_form = active_form; + } + if let Some(owner) = patch.owner { + task.owner = owner; + } + if let Some(execution_mode) = patch.execution_mode { + task.execution_mode = execution_mode; + } + if let Some(blocked_by) = patch.blocked_by { + task.blocked_by = blocked_by; + } + apply_metadata_merge_patch( + task, + patch.metadata_merge_patch, + patch.eligible_member_ids, + patch.required_role, + )?; + task.updated_at = now_rfc3339(); + validate_task_model_invariants(conn, task)?; + canonicalize_dependencies(&mut tasks, org_run_id)?; + let current = tasks[index].clone(); + update_task_row(conn, ¤t)?; + insert_task_history_event_as( + conn, + org_run_id, + task_id, + TASK_EVENT_UPDATED, + Some(&previous), + ¤t, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(conn, org_run_id)?; + let outcome = mutation_outcome(previous, current, &tasks); + let effect = effects(conn, &outcome, &tasks)?; + Ok((outcome, effect)) + } + pub fn cancel_with_transactional_effects( actor: TaskGraphWriterAdmin, org_run_id: &str, task_id: &str, - expected_updated_at: &str, reason: TaskTerminalReason, effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, ) -> Result<(TaskMutationOutcome, T), String> { @@ -300,7 +337,40 @@ impl AgentOrgTaskStore { mutate_lifecycle( org_run_id, task_id, - Some(expected_updated_at), + |tx, _previous| actor.validate(tx, org_run_id), + move |task, _audit| { + if task.status.is_terminal() { + return Err(format!( + "{TASK_TERMINAL_IMMUTABLE_ERROR}: task {} is {}", + task.id, + task.status.as_wire() + )); + } + task.status = TaskStatus::Cancelled; + task.cancel_reason = Some(reason); + Ok(()) + }, + effects, + ) + } + + pub(crate) fn cancel_in_tx( + conn: &rusqlite::Connection, + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, + reason: TaskTerminalReason, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + if reason.code.starts_with("system.") { + return Err( + "system.* cancel reason codes are reserved for system recovery".to_string(), + ); + } + mutate_lifecycle_in_tx( + conn, + org_run_id, + task_id, |tx, _previous| actor.validate(tx, org_run_id), move |task, _audit| { if task.status.is_terminal() { @@ -322,7 +392,40 @@ impl AgentOrgTaskStore { actor: TaskGraphWriterAdmin, org_run_id: &str, task_id: &str, - expected_updated_at: &str, + reason: TaskTerminalReason, + replacement: CreatePendingTaskParams, + effects: impl FnOnce( + &rusqlite::Connection, + &TaskMutationOutcome, + &Task, + &[Task], + ) -> Result, + ) -> Result<(TaskMutationOutcome, Task, T), String> { + let run_id = org_run_id.to_string(); + let (outcome, replacement_task, effect) = with_sessions_writer(|| -> Result<_, String> { + let conn = get_connection().map_err(|error| error.to_string())?; + let tx = database::db::begin_immediate(&conn).map_err(|error| error.to_string())?; + let result = Self::cancel_and_replace_in_tx( + &tx, + actor, + &run_id, + task_id, + reason, + replacement, + effects, + )?; + tx.commit().map_err(|error| error.to_string())?; + Ok(result) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + Ok((outcome, replacement_task, effect)) + } + + pub(crate) fn cancel_and_replace_in_tx( + conn: &rusqlite::Connection, + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, reason: TaskTerminalReason, mut replacement: CreatePendingTaskParams, effects: impl FnOnce( @@ -342,81 +445,65 @@ impl AgentOrgTaskStore { return Err("replacement must belong to the same org run".to_string()); } replacement.replaces_task_id = Some(task_id.to_string()); - let run_id = org_run_id.to_string(); - let old_task_id = task_id.to_string(); - let (outcome, replacement_task, effect) = with_sessions_writer(|| -> Result<_, String> { - let mut conn = get_connection().map_err(|error| error.to_string())?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| error.to_string())?; - let audit = actor.validate(&tx, &run_id)?; - let mut tasks = list_tasks_with_conn(&tx, &run_id)?; - let old_index = tasks + let audit = actor.validate(conn, org_run_id)?; + let mut tasks = list_tasks_with_conn(conn, org_run_id)?; + let old_index = tasks + .iter() + .position(|task| task.id == task_id) + .ok_or_else(|| format!("task_not_found: {task_id} in run {org_run_id}"))?; + let previous = tasks[old_index].clone(); + if previous.status.is_terminal() { + return Err(format!( + "{TASK_TERMINAL_IMMUTABLE_ERROR}: task {task_id} is {}", + previous.status.as_wire() + )); + } + ensure_task_run_capacity( + tasks .iter() - .position(|task| task.id == old_task_id) - .ok_or_else(|| format!("task_not_found: {old_task_id} in run {run_id}"))?; - let previous = tasks[old_index].clone(); - if previous.updated_at != expected_updated_at { - return Err(format!( - "{TASK_MUTATION_CONFLICT_ERROR}: task {old_task_id} changed after authorization" - )); - } - if previous.status.is_terminal() { - return Err(format!( - "{TASK_TERMINAL_IMMUTABLE_ERROR}: task {old_task_id} is {}", - previous.status.as_wire() - )); - } - ensure_task_run_capacity( - tasks - .iter() - .filter(|task| task.status.is_open()) - .count() - .saturating_sub(1), - 1, - )?; - let now = now_rfc3339(); - tasks[old_index].status = TaskStatus::Cancelled; - tasks[old_index].cancel_reason = Some(reason); - tasks[old_index].updated_at = now.clone(); - validate_task_model_invariants(&tx, &tasks[old_index])?; - let cancelled = tasks[old_index].clone(); - update_task_row(&tx, &cancelled)?; - - let replacement_task = pending_task_from_params(replacement, &audit, &now)?; - validate_task_model_invariants(&tx, &replacement_task)?; - tasks.push(replacement_task); - canonicalize_dependencies(&mut tasks, &run_id)?; - let replacement_task = tasks - .last() - .cloned() - .expect("replacement remains in candidate graph"); - insert_task_row(&tx, &replacement_task)?; - insert_task_history_event_as( - &tx, - &run_id, - &old_task_id, - TASK_EVENT_UPDATED, - Some(&previous), - &cancelled, - &audit, - )?; - insert_task_history_event_as( - &tx, - &run_id, - &replacement_task.id, - TASK_EVENT_CREATED, - None, - &replacement_task, - &audit, - )?; - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; - let outcome = mutation_outcome(previous, cancelled, &tasks); - let effect = effects(&tx, &outcome, &replacement_task, &tasks)?; - tx.commit().map_err(|error| error.to_string())?; - Ok((outcome, replacement_task, effect)) - })?; - crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + .filter(|task| task.status.is_open()) + .count() + .saturating_sub(1), + 1, + )?; + let now = now_rfc3339(); + tasks[old_index].status = TaskStatus::Cancelled; + tasks[old_index].cancel_reason = Some(reason); + tasks[old_index].updated_at = now.clone(); + validate_task_model_invariants(conn, &tasks[old_index])?; + let cancelled = tasks[old_index].clone(); + update_task_row(conn, &cancelled)?; + + let replacement_task = pending_task_from_params(replacement, &audit, &now)?; + validate_task_model_invariants(conn, &replacement_task)?; + tasks.push(replacement_task); + canonicalize_dependencies(&mut tasks, org_run_id)?; + let replacement_task = tasks + .last() + .cloned() + .expect("replacement remains in candidate graph"); + insert_task_row(conn, &replacement_task)?; + insert_task_history_event_as( + conn, + org_run_id, + task_id, + TASK_EVENT_UPDATED, + Some(&previous), + &cancelled, + &audit, + )?; + insert_task_history_event_as( + conn, + org_run_id, + &replacement_task.id, + TASK_EVENT_CREATED, + None, + &replacement_task, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(conn, org_run_id)?; + let outcome = mutation_outcome(previous, cancelled, &tasks); + let effect = effects(conn, &outcome, &replacement_task, &tasks)?; Ok((outcome, replacement_task, effect)) } @@ -429,7 +516,36 @@ impl AgentOrgTaskStore { mutate_lifecycle( org_run_id, task_id, - None, + |tx, _previous| actor.validate(tx, org_run_id, task_id), + |task, audit| { + if task.status != TaskStatus::Pending { + return Err(format!( + "task_owner_start_requires_pending: task {} is {}", + task.id, + task.status.as_wire() + )); + } + if task.owner.as_deref() != Some(audit.participant_id.as_str()) { + return Err("task_owner_mismatch".to_string()); + } + task.status = TaskStatus::InProgress; + Ok(()) + }, + effects, + ) + } + + pub(crate) fn owner_start_in_tx( + conn: &rusqlite::Connection, + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + mutate_lifecycle_in_tx( + conn, + org_run_id, + task_id, |tx, _previous| actor.validate(tx, org_run_id, task_id), |task, audit| { if task.status != TaskStatus::Pending { @@ -459,7 +575,35 @@ impl AgentOrgTaskStore { mutate_lifecycle( org_run_id, task_id, - None, + |tx, _previous| actor.validate(tx, org_run_id, task_id), + move |task, audit| { + require_in_progress_owner(task, audit)?; + task.status = TaskStatus::Completed; + task.output = Some(TaskOutput { + summary: output.summary, + content: output.content, + artifact_ids: output.artifact_ids, + produced_by_member_id: audit.participant_id.clone(), + produced_at: now_rfc3339(), + }); + Ok(()) + }, + effects, + ) + } + + pub(crate) fn owner_complete_in_tx( + conn: &rusqlite::Connection, + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + output: TaskOutputInput, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + mutate_lifecycle_in_tx( + conn, + org_run_id, + task_id, |tx, _previous| actor.validate(tx, org_run_id, task_id), move |task, audit| { require_in_progress_owner(task, audit)?; @@ -492,7 +636,34 @@ impl AgentOrgTaskStore { mutate_lifecycle( org_run_id, task_id, - None, + |tx, _previous| actor.validate(tx, org_run_id, task_id), + move |task, audit| { + require_in_progress_owner(task, audit)?; + task.status = TaskStatus::Failed; + task.failure_reason = Some(reason); + Ok(()) + }, + effects, + ) + } + + pub(crate) fn owner_fail_in_tx( + conn: &rusqlite::Connection, + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + reason: TaskTerminalReason, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + if reason.code.starts_with("system.") { + return Err( + "system.* failure reason codes are reserved for system recovery".to_string(), + ); + } + mutate_lifecycle_in_tx( + conn, + org_run_id, + task_id, |tx, _previous| actor.validate(tx, org_run_id, task_id), move |task, audit| { require_in_progress_owner(task, audit)?; @@ -519,6 +690,62 @@ fn validate_create_params(params: &CreatePendingTaskParams) -> Result<(), String Ok(()) } +fn apply_metadata_merge_patch( + task: &mut Task, + merge_patch: Option, + eligible_member_ids: Option>, + required_role: Option, +) -> Result<(), String> { + let mut metadata = match task.metadata.take() { + Some(serde_json::Value::Object(object)) => object, + Some(_) => return Err("persisted task metadata is not an object".to_string()), + None => serde_json::Map::new(), + }; + if let Some(merge_patch) = merge_patch { + let patch = merge_patch + .as_object() + .ok_or_else(|| "metadata patch must be an object".to_string())?; + for (key, value) in patch { + if [ + TASK_METADATA_ELIGIBLE_MEMBER_IDS, + TASK_METADATA_REQUIRED_ROLE, + TASK_METADATA_EXECUTION_MODE, + TASK_METADATA_OUTPUT, + ] + .contains(&key.as_str()) + { + return Err(format!( + "metadata contains reserved Agent Org task field: {key}; use the typed parameter instead" + )); + } + if value.is_null() { + metadata.remove(key); + } else { + metadata.insert(key.clone(), value.clone()); + } + } + } + if let Some(eligible_member_ids) = eligible_member_ids { + metadata.insert( + TASK_METADATA_ELIGIBLE_MEMBER_IDS.to_string(), + serde_json::json!(eligible_member_ids), + ); + } + if let Some(required_role) = required_role { + let required_role = required_role.trim(); + if required_role.is_empty() { + metadata.remove(TASK_METADATA_REQUIRED_ROLE); + } else { + metadata.insert( + TASK_METADATA_REQUIRED_ROLE.to_string(), + serde_json::Value::String(required_role.to_string()), + ); + } + } + task.metadata = (!metadata.is_empty()).then_some(serde_json::Value::Object(metadata)); + Ok(()) +} + fn pending_task_from_params( params: CreatePendingTaskParams, audit: &TaskActorAudit, @@ -681,7 +908,6 @@ fn update_task_row(tx: &rusqlite::Connection, task: &Task) -> Result<(), String> fn mutate_lifecycle( org_run_id: &str, task_id: &str, - expected_updated_at: Option<&str>, validate_actor: impl FnOnce(&rusqlite::Connection, &Task) -> Result, mutation: impl FnOnce(&mut Task, &TaskActorAudit) -> Result<(), String>, effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, @@ -689,65 +915,71 @@ fn mutate_lifecycle( let run_id = org_run_id.to_string(); let task_id = task_id.to_string(); let (outcome, effect) = with_sessions_writer(|| -> Result<_, String> { - let mut conn = get_connection().map_err(|error| error.to_string())?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|error| error.to_string())?; - let sql = format!( - "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND id=?2" - ); - let previous: Task = tx - .query_row(&sql, params![&run_id, &task_id], row_to_task) - .optional() - .map_err(|error| error.to_string())? - .ok_or_else(|| format!("task_not_found: {task_id} in run {run_id}"))?; - if expected_updated_at.is_some_and(|expected| expected != previous.updated_at) { - return Err(format!( - "{TASK_MUTATION_CONFLICT_ERROR}: task {task_id} changed after authorization" - )); - } - let audit = validate_actor(&tx, &previous)?; - let mut tasks = list_tasks_with_conn(&tx, &run_id)?; - let index = tasks - .iter() - .position(|task| task.id == task_id) - .expect("task selected above remains in same transaction"); - if previous.status == TaskStatus::Pending { - let graph = super::super::TaskGraphIndex::new(&tasks); - let starting = matches!( - audit.kind, - super::super::actor::TaskActorKind::OwnerExecution - ); - if starting && !graph.is_ready(&previous) { - return Err("task_dependencies_not_completed".to_string()); - } - } - let task = &mut tasks[index]; - mutation(task, &audit)?; - task.updated_at = now_rfc3339(); - validate_task_model_invariants(&tx, task)?; - let current = task.clone(); - update_task_row(&tx, ¤t)?; - insert_task_history_event_as( - &tx, - &run_id, - &task_id, - TASK_EVENT_UPDATED, - Some(&previous), - ¤t, - &audit, - )?; - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; - let outcome = mutation_outcome(previous, current, &tasks); - let effect = effects(&tx, &outcome, &tasks)?; + let conn = get_connection().map_err(|error| error.to_string())?; + let tx = database::db::begin_immediate(&conn).map_err(|error| error.to_string())?; + let result = + mutate_lifecycle_in_tx(&tx, &run_id, &task_id, validate_actor, mutation, effects)?; tx.commit().map_err(|error| error.to_string())?; - Ok((outcome, effect)) + Ok(result) })?; crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); Ok((outcome, effect)) } +fn mutate_lifecycle_in_tx( + conn: &rusqlite::Connection, + org_run_id: &str, + task_id: &str, + validate_actor: impl FnOnce(&rusqlite::Connection, &Task) -> Result, + mutation: impl FnOnce(&mut Task, &TaskActorAudit) -> Result<(), String>, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, +) -> Result<(TaskMutationOutcome, T), String> { + let sql = format!( + "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id=?2" + ); + let previous: Task = conn + .query_row(&sql, params![org_run_id, task_id], row_to_task) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("task_not_found: {task_id} in run {org_run_id}"))?; + let audit = validate_actor(conn, &previous)?; + let mut tasks = list_tasks_with_conn(conn, org_run_id)?; + let index = tasks + .iter() + .position(|task| task.id == task_id) + .expect("task selected above remains in same transaction"); + if previous.status == TaskStatus::Pending { + let graph = super::super::TaskGraphIndex::new(&tasks); + let starting = matches!( + audit.kind, + super::super::actor::TaskActorKind::OwnerExecution + ); + if starting && !graph.is_ready(&previous) { + return Err("task_dependencies_not_completed".to_string()); + } + } + let task = &mut tasks[index]; + mutation(task, &audit)?; + task.updated_at = now_rfc3339(); + validate_task_model_invariants(conn, task)?; + let current = task.clone(); + update_task_row(conn, ¤t)?; + insert_task_history_event_as( + conn, + org_run_id, + task_id, + TASK_EVENT_UPDATED, + Some(&previous), + ¤t, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(conn, org_run_id)?; + let outcome = mutation_outcome(previous, current, &tasks); + let effect = effects(conn, &outcome, &tasks)?; + Ok((outcome, effect)) +} + fn require_in_progress_owner(task: &Task, audit: &TaskActorAudit) -> Result<(), String> { if task.status != TaskStatus::InProgress { return Err(format!( diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs index 49a0d1890..9170001ac 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs @@ -2,7 +2,7 @@ //! approval, plus a `cfg(test)` legacy fixture surface retained only while //! older owning-boundary tests are migrated to the typed actor API. -use rusqlite::{params, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension}; #[cfg(test)] use database::db::{get_connection, with_sessions_writer}; @@ -54,7 +54,7 @@ impl AgentOrgTaskStore { /// transaction. Agent Org plan approval uses this together with its /// approval-row CAS so neither side can commit without the other. pub(crate) fn complete_planning_task_in_tx( - tx: &rusqlite::Transaction<'_>, + tx: &Connection, actor: TaskOwnerExecution, org_run_id: &str, task_id: &str, diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/task_store_contract_tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/task_store_contract_tests.rs index 6ae3f8870..07349294d 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/task_store_contract_tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/task_store_contract_tests.rs @@ -227,14 +227,10 @@ fn start(task_id: &str, session_id: &str, turn_id: &str) -> TaskMutationOutcome } fn assign_pending(task_id: &str, owner_member_id: &str) -> Task { - let current = AgentOrgTaskStore::get(RUN_ID, task_id) - .unwrap() - .expect("Task to assign"); AgentOrgTaskStore::patch_pending_with_transactional_effects( graph_actor(), RUN_ID, task_id, - ¤t.updated_at, PendingTaskGraphPatch { owner: Some(Some(owner_member_id.to_string())), ..Default::default() @@ -246,6 +242,72 @@ fn assign_pending(task_id: &str, owner_member_id: &str) -> Task { .current } +#[test] +fn sparse_graph_patches_merge_latest_fields_and_metadata_subkeys() { + let _fixture = fixture(); + let mut task = pending("sparse-lww", Some(MEMBER_A), vec![]); + task.metadata = Some(serde_json::json!({ + TASK_METADATA_ELIGIBLE_MEMBER_IDS: [MEMBER_A], + "retained": "keep", + "removed": "old" + })); + create(task); + + AgentOrgTaskStore::patch_pending_with_transactional_effects( + graph_actor(), + RUN_ID, + "sparse-lww", + PendingTaskGraphPatch { + description: Some("first description".to_string()), + metadata_merge_patch: Some(serde_json::json!({ + "first": 1, + "removed": null + })), + ..Default::default() + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect("first sparse patch"); + AgentOrgTaskStore::patch_pending_with_transactional_effects( + graph_actor(), + RUN_ID, + "sparse-lww", + PendingTaskGraphPatch { + subject: Some("latest subject".to_string()), + metadata_merge_patch: Some(serde_json::json!({"second": 2})), + ..Default::default() + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect("second sparse patch"); + AgentOrgTaskStore::patch_pending_with_transactional_effects( + graph_actor(), + RUN_ID, + "sparse-lww", + PendingTaskGraphPatch { + description: Some("last description".to_string()), + ..Default::default() + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect("same field uses last legal commit"); + + let stored = AgentOrgTaskStore::get(RUN_ID, "sparse-lww") + .unwrap() + .unwrap(); + assert_eq!(stored.subject, "latest subject"); + assert_eq!(stored.description, "last description"); + let metadata = stored.metadata.unwrap(); + assert_eq!(metadata["retained"], "keep"); + assert_eq!(metadata["first"], 1); + assert_eq!(metadata["second"], 2); + assert!(metadata.get("removed").is_none()); + assert_eq!( + metadata[TASK_METADATA_ELIGIBLE_MEMBER_IDS], + serde_json::json!([MEMBER_A]) + ); +} + fn recovery_attempts(task_id: &str) -> i64 { get_connection() .unwrap() @@ -430,7 +492,6 @@ fn owner_fsm_stamps_output_and_freezes_terminal_task() { graph_actor(), RUN_ID, "owned", - &completed.updated_at, TaskTerminalReason { code: "scope.changed".to_string(), message: "cannot rewrite terminal work".to_string(), @@ -496,13 +557,11 @@ fn cancel_and_replace_is_atomic_and_rejects_late_owner_callback() { let conn = get_connection().unwrap(); insert_owner_context(&conn, MEMBER_A, MEMBER_A_SESSION, "turn-old", "old", 1); start("old", MEMBER_A_SESSION, "turn-old"); - let old = AgentOrgTaskStore::get(RUN_ID, "old").unwrap().unwrap(); let (_outcome, replacement, ()) = AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( graph_actor(), RUN_ID, "old", - &old.updated_at, TaskTerminalReason { code: "scope.changed".to_string(), message: "replace the goal".to_string(), @@ -534,12 +593,11 @@ fn cancel_and_replace_is_atomic_and_rejects_late_owner_callback() { .expect_err("cancelled Task rejects late callback"); assert!(late.contains("requires_in_progress"), "{late}"); - let current = create(pending("fault-old", Some(MEMBER_A), vec![])); + create(pending("fault-old", Some(MEMBER_A), vec![])); let error = AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( graph_actor(), RUN_ID, "fault-old", - ¤t.updated_at, TaskTerminalReason { code: "scope.changed".to_string(), message: "fault injection".to_string(), @@ -959,12 +1017,10 @@ fn replacement_and_explicit_owner_failure_do_not_share_or_consume_budget() { "turn-original-2", 1, ); - let original = AgentOrgTaskStore::get(RUN_ID, "original").unwrap().unwrap(); AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( graph_actor(), RUN_ID, "original", - &original.updated_at, TaskTerminalReason { code: "scope.changed".to_string(), message: "replace failed work".to_string(), diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tool_receipts.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tool_receipts.rs new file mode 100644 index 000000000..815bb9dab --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tool_receipts.rs @@ -0,0 +1,617 @@ +//! Exactly-once receipts for model-initiated Agent Org durable writes. +//! +//! One tool call owns one composite identity inside a Team. The receipt lookup, +//! business mutation, and deterministic result are committed in the same +//! `BEGIN IMMEDIATE` transaction. A byte-equivalent retry is therefore a +//! read-only replay; reusing the identity for different canonical parameters +//! fails closed. + +use database::db::{get_connection, with_sessions_writer}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::coordination::agent_org_payload_limits::{ + validate_message_identifier, RFC3339_TIMESTAMP_MAX_BYTES, RFC3339_TIMESTAMP_MAX_CHARS, +}; +use crate::tools::traits::{CallContext, ToolError}; + +pub(super) const TABLE_NAME: &str = "agent_org_runtime_tool_call_receipts"; + +const TOOL_NAME_MAX_BYTES: usize = 128; +const OPERATION_MAX_BYTES: usize = 128; +const RESULT_MAX_BYTES: usize = 512 * 1024; +const ERROR_MAX_BYTES: usize = 64 * 1024; +const RECEIPT_CONFLICT: &str = "agent_org_tool_call_receipt_conflict"; + +pub(super) fn create_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(&format!( + "CREATE TABLE IF NOT EXISTS {TABLE_NAME} ( + org_run_id TEXT NOT NULL, + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + call_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + operation TEXT NOT NULL, + canonical_digest TEXT NOT NULL, + result_text TEXT, + error_kind TEXT, + error_text TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (org_run_id, session_id, turn_intent_id, call_id), + FOREIGN KEY (org_run_id) + REFERENCES agent_org_runtime_runs(id) ON DELETE CASCADE, + CHECK (trim(org_run_id) <> '' AND org_run_id = trim(org_run_id)), + CHECK (trim(session_id) <> '' AND session_id = trim(session_id)), + CHECK (trim(turn_intent_id) <> '' AND turn_intent_id = trim(turn_intent_id)), + CHECK (trim(call_id) <> '' AND call_id = trim(call_id)), + CHECK (trim(tool_name) <> '' AND length(CAST(tool_name AS BLOB)) <= {TOOL_NAME_MAX_BYTES}), + CHECK (trim(operation) <> '' AND length(CAST(operation AS BLOB)) <= {OPERATION_MAX_BYTES}), + CHECK (length(canonical_digest) = 64 AND canonical_digest NOT GLOB '*[^0-9a-f]*'), + CHECK ( + (result_text IS NOT NULL AND error_kind IS NULL AND error_text IS NULL) + OR + (result_text IS NULL AND error_kind IN ('invalid_params','execution_failed','permission_denied','timeout') AND error_text IS NOT NULL) + ), + CHECK (result_text IS NULL OR length(CAST(result_text AS BLOB)) <= {RESULT_MAX_BYTES}), + CHECK (error_text IS NULL OR length(CAST(error_text AS BLOB)) <= {ERROR_MAX_BYTES}), + CHECK (length(created_at) <= {RFC3339_TIMESTAMP_MAX_CHARS} AND length(CAST(created_at AS BLOB)) <= {RFC3339_TIMESTAMP_MAX_BYTES}) + );" + )) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentOrgToolReceiptKey { + pub org_run_id: String, + pub session_id: String, + pub turn_intent_id: String, + pub call_id: String, +} + +impl AgentOrgToolReceiptKey { + pub(crate) fn from_call_context( + org_run_id: impl Into, + context: &CallContext, + ) -> Result { + let key = Self { + org_run_id: org_run_id.into(), + session_id: context.session_id.clone(), + turn_intent_id: context.turn_intent_id.clone(), + call_id: context.call_id.clone(), + }; + key.validate()?; + Ok(key) + } + + fn validate(&self) -> Result<(), ToolError> { + for (field, value) in [ + ("org_run_id", self.org_run_id.as_str()), + ("session_id", self.session_id.as_str()), + ("turn_intent_id", self.turn_intent_id.as_str()), + ("call_id", self.call_id.as_str()), + ] { + validate_message_identifier(field, value).map_err(ToolError::InvalidParams)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentOrgToolReceiptDisposition { + Fresh, + Replayed, +} + +#[derive(Debug)] +pub(crate) struct AgentOrgToolReceiptOutcome { + pub result: Result, + pub disposition: AgentOrgToolReceiptDisposition, +} + +impl AgentOrgToolReceiptOutcome { + pub(crate) fn is_fresh(&self) -> bool { + self.disposition == AgentOrgToolReceiptDisposition::Fresh + } +} + +/// Abort a first-time call without creating a receipt. +/// +/// `Rejected` is for stale authority and lifecycle fences. `Storage` is for +/// infrastructure failures. Deterministic domain failures should instead be +/// returned as the inner `Err(ToolError)` so that retries replay the same +/// error rather than executing the mutation path again. +#[derive(Debug)] +pub(crate) enum AgentOrgToolReceiptAbort { + Rejected(ToolError), + Storage(String), +} + +impl AgentOrgToolReceiptAbort { + pub(crate) fn rejected(error: ToolError) -> Self { + Self::Rejected(error) + } + + pub(crate) fn storage(error: impl ToString) -> Self { + Self::Storage(error.to_string()) + } +} + +#[derive(Debug)] +struct StoredReceipt { + tool_name: String, + operation: String, + canonical_digest: String, + result_text: Option, + error_kind: Option, + error_text: Option, +} + +impl StoredReceipt { + fn into_result(self) -> Result { + if let Some(result) = self.result_text { + return Ok(result); + } + let message = self + .error_text + .unwrap_or_else(|| "stored Agent Org tool error is missing its message".to_string()); + Err(match self.error_kind.as_deref() { + Some("invalid_params") => ToolError::InvalidParams(message), + Some("permission_denied") => ToolError::PermissionDenied(message), + Some("timeout") => ToolError::Timeout(message), + _ => ToolError::ExecutionFailed(message), + }) + } +} + +pub(crate) struct AgentOrgToolReceiptStore; + +impl AgentOrgToolReceiptStore { + pub(crate) fn execute( + key: AgentOrgToolReceiptKey, + tool_name: &str, + operation: &str, + canonical_params: &Value, + mutation: F, + ) -> Result + where + F: FnOnce(&Connection) -> Result, AgentOrgToolReceiptAbort>, + { + validate_tool_identity(tool_name, operation)?; + let digest = canonical_tool_digest(tool_name, operation, canonical_params)?; + with_sessions_writer(|| { + let conn = get_connection().map_err(receipt_storage_error)?; + Self::execute_with_connection(&conn, key, tool_name, operation, &digest, mutation) + }) + } + + fn execute_with_connection( + conn: &Connection, + key: AgentOrgToolReceiptKey, + tool_name: &str, + operation: &str, + canonical_digest: &str, + mutation: F, + ) -> Result + where + F: FnOnce(&Connection) -> Result, AgentOrgToolReceiptAbort>, + { + let tx = database::db::begin_immediate(conn).map_err(receipt_storage_error)?; + if let Some(stored) = read_receipt(&tx, &key).map_err(receipt_storage_error)? { + if stored.tool_name != tool_name + || stored.operation != operation + || stored.canonical_digest != canonical_digest + { + return Err(ToolError::InvalidParams(RECEIPT_CONFLICT.to_string())); + } + let result = stored.into_result(); + tx.commit().map_err(receipt_storage_error)?; + return Ok(AgentOrgToolReceiptOutcome { + result, + disposition: AgentOrgToolReceiptDisposition::Replayed, + }); + } + + let result = match mutation(&tx) { + Ok(result) => result, + Err(AgentOrgToolReceiptAbort::Rejected(error)) => return Err(error), + Err(AgentOrgToolReceiptAbort::Storage(error)) => { + return Err(receipt_storage_error(error)); + } + }; + insert_receipt(&tx, &key, tool_name, operation, canonical_digest, &result) + .map_err(receipt_storage_error)?; + tx.commit().map_err(receipt_storage_error)?; + Ok(AgentOrgToolReceiptOutcome { + result, + disposition: AgentOrgToolReceiptDisposition::Fresh, + }) + } +} + +fn validate_tool_identity(tool_name: &str, operation: &str) -> Result<(), ToolError> { + for (field, value, max_bytes) in [ + ("tool_name", tool_name, TOOL_NAME_MAX_BYTES), + ("operation", operation, OPERATION_MAX_BYTES), + ] { + if value.trim().is_empty() || value != value.trim() || value.len() > max_bytes { + return Err(ToolError::InvalidParams(format!( + "{field} must be trimmed, non-empty, and <= {max_bytes} bytes" + ))); + } + } + Ok(()) +} + +fn canonical_tool_digest( + tool_name: &str, + operation: &str, + canonical_params: &Value, +) -> Result { + let envelope = Value::Object( + [ + ( + "operation".to_string(), + Value::String(operation.to_string()), + ), + ("params".to_string(), canonicalize_json(canonical_params)), + ("tool".to_string(), Value::String(tool_name.to_string())), + ] + .into_iter() + .collect(), + ); + let encoded = serde_json::to_vec(&envelope).map_err(|error| { + ToolError::ExecutionFailed(format!( + "failed to encode canonical Agent Org tool parameters: {error}" + )) + })?; + Ok(format!("{:x}", Sha256::digest(encoded))) +} + +fn canonicalize_json(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()), + Value::Object(object) => { + let mut entries = object.iter().collect::>(); + entries.sort_by_key(|(key, _)| *key); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.clone(), canonicalize_json(value))) + .collect(), + ) + } + _ => value.clone(), + } +} + +fn read_receipt( + conn: &Connection, + key: &AgentOrgToolReceiptKey, +) -> rusqlite::Result> { + conn.query_row( + &format!( + "SELECT tool_name,operation,canonical_digest,result_text,error_kind,error_text + FROM {TABLE_NAME} + WHERE org_run_id=?1 AND session_id=?2 AND turn_intent_id=?3 AND call_id=?4" + ), + params![ + &key.org_run_id, + &key.session_id, + &key.turn_intent_id, + &key.call_id + ], + |row| { + Ok(StoredReceipt { + tool_name: row.get(0)?, + operation: row.get(1)?, + canonical_digest: row.get(2)?, + result_text: row.get(3)?, + error_kind: row.get(4)?, + error_text: row.get(5)?, + }) + }, + ) + .optional() +} + +fn insert_receipt( + conn: &Connection, + key: &AgentOrgToolReceiptKey, + tool_name: &str, + operation: &str, + canonical_digest: &str, + result: &Result, +) -> rusqlite::Result<()> { + let (result_text, error_kind, error_text) = match result { + Ok(result) => (Some(result.as_str()), None, None), + Err(ToolError::InvalidParams(error)) => { + (None, Some("invalid_params"), Some(error.as_str())) + } + Err(ToolError::ExecutionFailed(error)) => { + (None, Some("execution_failed"), Some(error.as_str())) + } + Err(ToolError::PermissionDenied(error)) => { + (None, Some("permission_denied"), Some(error.as_str())) + } + Err(ToolError::Timeout(error)) => (None, Some("timeout"), Some(error.as_str())), + }; + conn.execute( + &format!( + "INSERT INTO {TABLE_NAME} ( + org_run_id,session_id,turn_intent_id,call_id, + tool_name,operation,canonical_digest, + result_text,error_kind,error_text,created_at + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)" + ), + params![ + &key.org_run_id, + &key.session_id, + &key.turn_intent_id, + &key.call_id, + tool_name, + operation, + canonical_digest, + result_text, + error_kind, + error_text, + chrono::Utc::now().to_rfc3339(), + ], + )?; + Ok(()) +} + +fn receipt_storage_error(error: impl ToString) -> ToolError { + ToolError::ExecutionFailed(format!( + "Agent Org tool receipt storage failed: {}", + error.to_string() + )) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + use crate::coordination::init_agent_org_schemas; + + fn fixture() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory SQLite"); + conn.execute_batch("PRAGMA foreign_keys=ON;") + .expect("foreign keys"); + conn.execute_batch( + "CREATE TABLE session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + PRIMARY KEY(session_id, turn_intent_id) + );", + ) + .expect("shared Turn fixture schema"); + init_agent_org_schemas(&conn).expect("Agent Org schema"); + conn.execute( + "INSERT INTO agent_org_runtime_runs ( + id,org_id,coordinator_agent_id,entry_mode,status,created_at,updated_at + ) VALUES ('run-a','org-a','agent-a','standalone_session','running',?1,?1)", + [chrono::Utc::now().to_rfc3339()], + ) + .expect("run fixture"); + conn + } + + fn key() -> AgentOrgToolReceiptKey { + AgentOrgToolReceiptKey { + org_run_id: "run-a".to_string(), + session_id: "session-a".to_string(), + turn_intent_id: "turn-a".to_string(), + call_id: "call-a".to_string(), + } + } + + #[test] + fn identical_retry_replays_result_without_running_mutation() { + let conn = fixture(); + let calls = Cell::new(0); + let digest = + canonical_tool_digest("task_update", "patch", &serde_json::json!({"b": 2, "a": 1})) + .expect("digest"); + let first = AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_update", + "patch", + &digest, + |_| { + calls.set(calls.get() + 1); + Ok(Ok("saved".to_string())) + }, + ) + .expect("first execution"); + let replay = AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_update", + "patch", + &digest, + |_| { + calls.set(calls.get() + 1); + Ok(Ok("must not run".to_string())) + }, + ) + .expect("replay"); + assert!(first.is_fresh()); + assert_eq!(first.result.expect("first result"), "saved"); + assert_eq!(replay.disposition, AgentOrgToolReceiptDisposition::Replayed); + assert_eq!(replay.result.expect("replayed result"), "saved"); + assert_eq!(calls.get(), 1); + } + + #[test] + fn object_key_order_has_one_digest_but_changed_params_conflict() { + let conn = fixture(); + let first_digest = canonical_tool_digest( + "task_update", + "patch", + &serde_json::json!({"b": {"z": 2, "a": 1}, "a": 0}), + ) + .expect("first digest"); + let reordered_digest = canonical_tool_digest( + "task_update", + "patch", + &serde_json::json!({"a": 0, "b": {"a": 1, "z": 2}}), + ) + .expect("reordered digest"); + assert_eq!(first_digest, reordered_digest); + AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_update", + "patch", + &first_digest, + |_| Ok(Ok("saved".to_string())), + ) + .expect("first execution"); + let changed_digest = canonical_tool_digest( + "task_update", + "patch", + &serde_json::json!({"a": 9, "b": {"a": 1, "z": 2}}), + ) + .expect("changed digest"); + let error = AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_update", + "patch", + &changed_digest, + |_| Ok(Ok("changed".to_string())), + ) + .expect_err("same key with changed params must conflict"); + assert!(matches!(error, ToolError::InvalidParams(ref text) if text == RECEIPT_CONFLICT)); + } + + #[test] + fn deterministic_error_replays_but_rejected_fence_leaves_no_receipt() { + let conn = fixture(); + let digest = + canonical_tool_digest("task_update", "patch", &serde_json::json!({})).expect("digest"); + let stored = AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_update", + "patch", + &digest, + |_| Ok(Err(ToolError::InvalidParams("task cycle".to_string()))), + ) + .expect("store deterministic error"); + assert!( + matches!(stored.result, Err(ToolError::InvalidParams(ref text)) if text == "task cycle") + ); + let replay = AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_update", + "patch", + &digest, + |_| panic!("stored error must replay"), + ) + .expect("replay deterministic error"); + assert_eq!(replay.disposition, AgentOrgToolReceiptDisposition::Replayed); + + let rejected_key = AgentOrgToolReceiptKey { + call_id: "call-rejected".to_string(), + ..key() + }; + let error = AgentOrgToolReceiptStore::execute_with_connection( + &conn, + rejected_key, + "task_update", + "patch", + &digest, + |_| { + Err(AgentOrgToolReceiptAbort::rejected( + ToolError::PermissionDenied("paused".to_string()), + )) + }, + ) + .expect_err("fence rejection"); + assert!(matches!(error, ToolError::PermissionDenied(ref text) if text == "paused")); + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {TABLE_NAME}"), [], |row| { + row.get(0) + }) + .expect("receipt count"); + assert_eq!(count, 1); + } + + #[test] + fn receipt_insert_failure_rolls_back_the_business_mutation() { + let conn = fixture(); + conn.execute_batch( + "CREATE TABLE business_effects ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + );", + ) + .expect("business fixture schema"); + let digest = canonical_tool_digest( + "task_create", + "create", + &serde_json::json!({"subject": "oversized result"}), + ) + .expect("digest"); + + let error = AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_create", + "create", + &digest, + |tx| { + tx.execute( + "INSERT INTO business_effects (id,value) VALUES (1,'must roll back')", + [], + ) + .expect("business mutation"); + Ok(Ok("x".repeat(RESULT_MAX_BYTES + 1))) + }, + ) + .expect_err("receipt CHECK failure must fail the whole transaction"); + assert!(error + .to_string() + .contains("Agent Org tool receipt storage failed")); + let business_count: i64 = conn + .query_row("SELECT COUNT(*) FROM business_effects", [], |row| { + row.get(0) + }) + .expect("business count"); + let receipt_count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {TABLE_NAME}"), [], |row| { + row.get(0) + }) + .expect("receipt count"); + assert_eq!(business_count, 0); + assert_eq!(receipt_count, 0); + } + + #[test] + fn team_delete_cascades_receipts() { + let conn = fixture(); + let digest = + canonical_tool_digest("task_create", "create", &serde_json::json!({})).expect("digest"); + AgentOrgToolReceiptStore::execute_with_connection( + &conn, + key(), + "task_create", + "create", + &digest, + |_| Ok(Ok("created".to_string())), + ) + .expect("receipt"); + conn.execute("DELETE FROM agent_org_runtime_runs WHERE id='run-a'", []) + .expect("delete Team"); + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {TABLE_NAME}"), [], |row| { + row.get(0) + }) + .expect("receipt count"); + assert_eq!(count, 0); + } +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs index 66873a581..7d2bff21e 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs @@ -594,7 +594,9 @@ fn revalidate_live_formal_context_with_connection( context.org_run_id )); } - if status != AgentOrgRunStatus::Running { + let idle_coordinator = + status == AgentOrgRunStatus::Idle && context.turn_kind == AgentOrgTurnKind::Coordinator; + if status != AgentOrgRunStatus::Running && !idle_coordinator { return Err(invariant_error(format!( "Turn execution requires a running Team, found {status}" ))); @@ -1034,6 +1036,12 @@ fn validate_task_assistant_persistence_target( "TaskExecution target {task_id} terminal provenance does not belong to Turn {}", context.turn_intent_id ))), + // Writer cancellation/reassignment freezes the durable Task but does + // not revoke or stop the already-running Provider Turn. The exact + // bound Turn must therefore be allowed to append its ordinary + // assistant transcript and become terminal; Task lifecycle/output and + // other formal mutations remain rejected by their owning Store gates. + Some(("cancelled", _)) => Ok(()), Some((status, _)) => Err(invariant_error(format!( "TaskExecution target {task_id} cannot authorize assistant persistence (status {status})" ))), @@ -1197,9 +1205,9 @@ fn resolve_canonical_admission( "team_archived: Agent Org run {} is read-only", request.org_run_id )); - } else if status != AgentOrgRunStatus::Running { + } else if !matches!(status, AgentOrgRunStatus::Running | AgentOrgRunStatus::Idle) { return Err(invariant_error(format!( - "Coordinator Turn requires a running Team, found {status}" + "Coordinator Turn requires a running or Idle Team, found {status}" ))); } resolve_materialization_version( diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs index e5135d7c0..9d97b7932 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs @@ -351,6 +351,43 @@ fn coordinator_is_root_scoped_and_never_allocates_member_sequence() { assert_eq!(row_count(&conn, "session_turn_intents"), 3); } +#[test] +fn idle_root_coordinator_turn_runs_and_persists_without_activating_team() { + let mut conn = connection(); + conn.execute( + "UPDATE agent_org_runtime_runs SET status='idle' WHERE id=?1", + [RUN_ID], + ) + .unwrap(); + let request = AgentOrgTurnAdmission::coordinator( + RUN_ID, + ROOT_SESSION_ID, + "turn-idle-root", + Some("message-idle-root".into()), + TurnIntentBridgeSource::UserSubmit, + ); + let context = accept_in_transaction(&mut conn, &request).expect("Idle Root Turn admission"); + assert_eq!(context.turn_kind, AgentOrgTurnKind::Coordinator); + revalidate_context_with_connection(&conn, ROOT_SESSION_ID, "turn-idle-root") + .expect("Idle Root can enter provider execution"); + conn.execute( + "UPDATE session_turn_intents SET status='running' + WHERE session_id=?1 AND turn_intent_id='turn-idle-root'", + [ROOT_SESSION_ID], + ) + .unwrap(); + revalidate_assistant_persistence_with_connection(&conn, ROOT_SESSION_ID, "turn-idle-root") + .expect("Idle Root can persist its final assistant answer"); + let status: String = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + [RUN_ID], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(status, "idle", "Q&A alone must not activate formal work"); +} + #[test] fn member_wake_binds_oldest_dependency_ready_assignment_and_revalidates_at_start() { let mut conn = connection(); @@ -525,7 +562,7 @@ fn assistant_persistence_accepts_only_exact_same_turn_terminal_provenance() { } #[test] -fn assistant_persistence_allows_exact_owner_failure_but_rejects_cancel_and_actor_drift() { +fn assistant_persistence_allows_exact_owner_failure_and_cancelled_turn_end() { let mut conn = connection(); let turn_id = "turn-failed-assistant"; accept_in_transaction(&mut conn, &task_request(turn_id)).expect("accept TaskExecution Turn"); @@ -564,17 +601,18 @@ fn assistant_persistence_allows_exact_owner_failure_but_rejects_cancel_and_actor [RUN_ID], ) .expect("cancel Task"); - let cancelled = - revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) - .expect_err("cancelled Task never authorizes owner final output"); + let cancelled_admission = revalidate_context_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect_err("cancelled Task must stay closed to formal execution"); assert!( - cancelled.contains("cannot authorize assistant persistence (status cancelled)"), - "{cancelled}" + cancelled_admission.contains("not runnable (status cancelled)"), + "{cancelled_admission}" ); + revalidate_assistant_persistence_with_connection(&conn, MEMBER_SESSION_ID, turn_id) + .expect("the already-running exact Turn may persist its transcript and end naturally"); conn.execute( "UPDATE agent_org_runtime_tasks - SET status='failed',updated_at='failed-at',owner='member-reassigned' + SET updated_at='reassigned-at',owner='member-reassigned' WHERE org_run_id=?1 AND id='task-a'", [RUN_ID], ) diff --git a/src-tauri/crates/agent-core/src/core/coordination/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/mod.rs index 00362f205..e50bedf5c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/mod.rs @@ -31,6 +31,7 @@ pub mod agent_org_plan_approvals; pub mod agent_org_run_events; pub mod agent_org_runs; pub mod agent_org_tasks; +pub(crate) mod agent_org_tool_receipts; pub(crate) mod agent_org_turn_contexts; pub mod agent_org_watchdog; pub mod child_done_wake; diff --git a/src-tauri/crates/agent-core/src/core/coordination/schema.rs b/src-tauri/crates/agent-core/src/core/coordination/schema.rs index a129ec87b..4f5a5f322 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/schema.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/schema.rs @@ -12,11 +12,11 @@ use rusqlite::{ffi, Connection, Error as SqliteError, Result as SqliteResult}; use super::{ agent_inbox, agent_member_interventions, agent_org_archive, agent_org_pause, - agent_org_plan_approvals, agent_org_runs, agent_org_tasks, agent_org_turn_contexts, - agent_org_watchdog, + agent_org_plan_approvals, agent_org_runs, agent_org_tasks, agent_org_tool_receipts, + agent_org_turn_contexts, agent_org_watchdog, }; -const RUNTIME_TABLES: [&str; 19] = [ +const RUNTIME_TABLES: [&str; 20] = [ "agent_org_runtime_runs", "agent_org_runtime_run_progress", "agent_org_runtime_member_materializations", @@ -36,6 +36,7 @@ const RUNTIME_TABLES: [&str; 19] = [ "agent_org_runtime_pause_handoffs", "agent_org_runtime_archive_episodes", "agent_org_runtime_archive_teardowns", + "agent_org_runtime_tool_call_receipts", ]; const LEGACY_TABLES: [&str; 13] = [ @@ -135,7 +136,8 @@ fn create_runtime_schema(conn: &Connection) -> SqliteResult<()> { agent_org_watchdog::create_schema(conn)?; agent_org_turn_contexts::create_schema(conn)?; agent_org_pause::create_schema(conn)?; - agent_org_archive::create_schema(conn) + agent_org_archive::create_schema(conn)?; + agent_org_tool_receipts::create_schema(conn) } fn expected_manifest() -> SqliteResult { @@ -617,7 +619,7 @@ mod tests { DROP TABLE agent_org_runtime_member_dispatch_allocators;", ) .expect("make partial schema"); - assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 17); + assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 18); } "changed" => { conn.execute_batch( @@ -645,21 +647,21 @@ mod tests { } #[test] - fn previous_seventeen_table_manifest_requires_an_isolated_database() { + fn incomplete_runtime_manifest_requires_an_isolated_database() { let conn = connection(); initialize(&conn).expect("canonical pause runtime"); conn.execute_batch( "DROP TABLE agent_org_runtime_pause_handoffs; DROP TABLE agent_org_runtime_pause_episodes;", ) - .expect("simulate the previous strict seventeen-table manifest"); - assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 17); + .expect("simulate an incomplete strict runtime manifest"); + assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 18); let error = initialize(&conn).expect_err("previous runtime must not be migrated in place"); assert!( error .to_string() - .contains("found 17 of 19 canonical tables"), + .contains("found 18 of 20 canonical tables"), "unexpected strict-schema error: {error}" ); } @@ -728,7 +730,7 @@ mod tests { let conn = Connection::open(path).expect("reopen shared database"); verify_manifest(&conn, &expected_manifest().expect("expected manifest")) .expect("canonical manifest after concurrent init"); - assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 19); + assert_eq!(count_known_tables(&conn, &RUNTIME_TABLES).unwrap(), 20); } #[test] diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs index 64d8e7dc7..ce593b09e 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/inbox_repair.rs @@ -10,10 +10,14 @@ use crate::coordination::agent_inbox::{ ResolveInboxDeliveryParams, }; use crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID; +use crate::coordination::agent_org_tasks::TaskGraphWriterAdmin; +use crate::coordination::agent_org_tool_receipts::{ + AgentOrgToolReceiptAbort, AgentOrgToolReceiptKey, AgentOrgToolReceiptStore, +}; use crate::tools::names as tool_names; use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; -use super::TaskToolsContext; +use super::{classify_task_receipt_error, TaskToolsContext}; /// Explicit operator action for an Inbox row that cannot reach its original /// recipient. This is intentionally not an automatic forwarding API: typed @@ -70,13 +74,14 @@ impl Tool for OrgInboxRepairTool { async fn execute_text( &self, params_value: Value, - _ctx: &CallContext, + call_ctx: &CallContext, ) -> Result { if !self.ctx.is_coordinator() { return Err(ToolError::InvalidParams( "org_inbox_repair is coordinator-only".to_string(), )); } + let canonical_params = params_value.clone(); let params: OrgInboxRepairParams = parse_params(params_value)?; let run_id = self.ctx.org_context.run_id.clone(); @@ -117,11 +122,17 @@ impl Tool for OrgInboxRepairTool { OrgInboxRepairParams::Cancel { inbox_id, reason } => { resolve( &run_id, - inbox_id, - AgentInboxDeliveryResolutionKind::Cancelled, - reason, - None, - None, + call_ctx, + canonical_params, + ResolveInboxDeliveryParams { + inbox_id, + org_run_id: run_id.clone(), + resolved_by_member_id: COORDINATOR_MEMBER_ID.to_string(), + resolution_kind: AgentInboxDeliveryResolutionKind::Cancelled, + reason, + replacement_inbox_id: None, + replacement_task_id: None, + }, ) .await } @@ -133,11 +144,17 @@ impl Tool for OrgInboxRepairTool { } => { resolve( &run_id, - inbox_id, - AgentInboxDeliveryResolutionKind::Superseded, - reason, - replacement_inbox_id, - replacement_task_id, + call_ctx, + canonical_params, + ResolveInboxDeliveryParams { + inbox_id, + org_run_id: run_id.clone(), + resolved_by_member_id: COORDINATOR_MEMBER_ID.to_string(), + resolution_kind: AgentInboxDeliveryResolutionKind::Superseded, + reason, + replacement_inbox_id, + replacement_task_id, + }, ) .await } @@ -147,45 +164,60 @@ impl Tool for OrgInboxRepairTool { async fn resolve( run_id: &str, - inbox_id: i64, - resolution_kind: AgentInboxDeliveryResolutionKind, - reason: String, - replacement_inbox_id: Option, - replacement_task_id: Option, + call_ctx: &CallContext, + canonical_params: Value, + params: ResolveInboxDeliveryParams, ) -> Result { - let params = ResolveInboxDeliveryParams { - inbox_id, - org_run_id: run_id.to_string(), - resolved_by_member_id: COORDINATOR_MEMBER_ID.to_string(), - resolution_kind, - reason, - replacement_inbox_id, - replacement_task_id, - }; - let resolution = tokio::task::spawn_blocking(move || AgentInboxStore::resolve_delivery(params)) + let actor = + TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams)?; + let receipt_key = AgentOrgToolReceiptKey::from_call_context(run_id.to_string(), call_ctx)?; + let run_id = run_id.to_string(); + let receipt = tokio::task::spawn_blocking({ + let run_id = run_id.clone(); + move || { + AgentOrgToolReceiptStore::execute( + receipt_key, + tool_names::ORG_INBOX_REPAIR, + params.resolution_kind.as_str(), + &canonical_params, + |tx| { + if let Err(error) = actor.validate_canonical_coordinator(tx, &run_id) { + return match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }; + } + match AgentInboxStore::resolve_delivery_in_tx(tx, params) { + Ok(resolution) => serde_json::to_string(&json!({ + "outcome": resolution.resolution_kind.as_str(), + "org_run_id": run_id, + "delivery_resolution": resolution, + "guidance": "The original Inbox row remains durable and unread as audit evidence, but no longer blocks delivery/Quiescence. Re-inspect task_list and the replacement work before requesting completion." + })) + .map(Ok) + .map_err(AgentOrgToolReceiptAbort::storage), + Err(ResolveInboxDeliveryError::Constraint(message)) => Ok(Err( + ToolError::InvalidParams(format!( + "Agent Org Inbox repair was not applied: {message}" + )), + )), + Err(ResolveInboxDeliveryError::Storage(message)) => { + Err(AgentOrgToolReceiptAbort::storage(message)) + } + } + }, + ) + } + }) .await .map_err(|err| { ToolError::ExecutionFailed(format!("org_inbox_repair worker failed: {err}")) - })? - .map_err(|error| match error { - ResolveInboxDeliveryError::Constraint(message) => ToolError::InvalidParams(format!( - "Agent Org Inbox repair was not applied: {message}" - )), - ResolveInboxDeliveryError::Storage(message) => ToolError::ExecutionFailed(format!( - "Agent Org Inbox repair storage failed: {message}" - )), - })?; - serde_json::to_string(&json!({ - "outcome": resolution.resolution_kind.as_str(), - "org_run_id": run_id, - "delivery_resolution": resolution, - "guidance": "The original Inbox row remains durable and unread as audit evidence, but no longer blocks delivery/Quiescence. Re-inspect task_list and the replacement work before requesting completion." - })) - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "org_inbox_repair result serialization failed: {err}" - )) - }) + })??; + if receipt.is_fresh() { + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + } + receipt.result } #[cfg(test)] @@ -202,6 +234,21 @@ mod tests { use crate::tools::traits::Tool; use database::db::get_connection; use rusqlite::params; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_CALL_ID: AtomicU64 = AtomicU64::new(1); + + fn repair_call_context() -> CallContext { + CallContext { + session_id: "root-inbox-repair".to_string(), + turn_intent_id: "repair-turn".to_string(), + call_id: format!( + "repair-call-{}", + NEXT_CALL_ID.fetch_add(1, Ordering::Relaxed) + ), + ..Default::default() + } + } struct Fixture { _sandbox: test_helpers::test_env::SandboxGuard, @@ -216,6 +263,20 @@ mod tests { let conn = get_connection().expect("test sqlite connection"); crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); crate::session::persistence::init(&conn).expect("session schema"); + conn.execute_batch( + "CREATE TABLE session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) + );", + ) + .expect("Turn intent schema"); crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); let org = OrgDefinition { @@ -260,6 +321,39 @@ mod tests { ..Default::default() }) .expect("seed coordinator session"); + conn.execute( + "INSERT INTO agent_org_runtime_member_materializations ( + org_run_id,member_id,agent_id,generation,session_id, + authority_class,status,created_at,updated_at + ) VALUES (?1,'coordinator','coordinator-agent',?2, + 'root-inbox-repair','formal','succeeded',?3,?3)", + params![ + &run.id, + run.activation_generation, + chrono::Utc::now().to_rfc3339() + ], + ) + .expect("seed canonical coordinator materialization"); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES ('root-inbox-repair','repair-turn',?1,'agent_org','running',?2,?2)", + params![&run.id, chrono::Utc::now().to_rfc3339()], + ) + .expect("seed base Turn"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts ( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + source_kind,source_id,activation_generation,created_at + ) VALUES ('root-inbox-repair','repair-turn',?1,'coordinator','coordinator', + 'root_turn','repair-turn',?2,?3)", + params![ + &run.id, + run.activation_generation, + chrono::Utc::now().to_rfc3339() + ], + ) + .expect("seed coordinator Turn context"); let message = AgentMessage::Plain { summary: "Undeliverable work".into(), @@ -323,17 +417,21 @@ mod tests { #[tokio::test] async fn coordinator_can_cancel_an_undeliverable_row_without_faking_read() { let fixture = fixture(); - let result = OrgInboxRepairTool::new(fixture.coordinator) - .execute_text( - json!({ - "action": "cancel", - "inbox_id": fixture.inbox_id, - "reason": "The removed member's work is intentionally abandoned" - }), - &CallContext::default(), - ) + let call = repair_call_context(); + let request = json!({ + "action": "cancel", + "inbox_id": fixture.inbox_id, + "reason": "The removed member's work is intentionally abandoned" + }); + let result = OrgInboxRepairTool::new(fixture.coordinator.clone()) + .execute_text(request.clone(), &call) .await .expect("coordinator repair succeeds"); + let replay = OrgInboxRepairTool::new(fixture.coordinator.clone()) + .execute_text(request, &call) + .await + .expect("same repair call replays"); + assert_eq!(replay, result); assert_eq!( serde_json::from_str::(&result).unwrap()["outcome"], "cancelled" @@ -359,7 +457,7 @@ mod tests { "inbox_id": fixture.inbox_id, "reason": "Worker must not discard it" }), - &CallContext::default(), + &repair_call_context(), ) .await .expect_err("worker repair is denied"); @@ -394,11 +492,11 @@ mod tests { "inbox_id": fixture.inbox_id, "reason": "Too late" }), - &CallContext::default(), + &repair_call_context(), ) .await .expect_err("terminal run mutation is denied"); - assert!(matches!(error, ToolError::InvalidParams(_))); + assert!(error.to_string().contains("team_archived"), "{error}"); assert!( AgentInboxStore::delivery_resolution_for_inbox(&fixture.run_id, fixture.inbox_id) .unwrap() diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/run_complete.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/run_complete.rs index 3755b396c..475b3145d 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/run_complete.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/run_complete.rs @@ -6,10 +6,14 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::coordination::agent_org_runs::{AgentOrgCompletionRequestOutcome, AgentOrgRunStore}; +use crate::coordination::agent_org_tasks::TaskGraphWriterAdmin; +use crate::coordination::agent_org_tool_receipts::{ + AgentOrgToolReceiptKey, AgentOrgToolReceiptStore, +}; use crate::tools::names as tool_names; use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; -use super::TaskToolsContext; +use super::{classify_task_receipt_error, TaskToolsContext}; #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -55,44 +59,78 @@ impl Tool for OrgRunCompleteTool { async fn execute_text( &self, params_value: Value, - _ctx: &CallContext, + call_ctx: &CallContext, ) -> Result { if !self.ctx.is_coordinator() { return Err(ToolError::InvalidParams( "org_run_complete is coordinator-only".to_string(), )); } + let canonical_params = params_value.clone(); let params: OrgRunCompleteParams = parse_params(params_value)?; let run_id = self.ctx.org_context.run_id.clone(); let summary = params.summary; - let outcome = tokio::task::spawn_blocking({ + let actor = + TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams)?; + let receipt_key = AgentOrgToolReceiptKey::from_call_context(run_id.clone(), call_ctx)?; + let (receipt, recorded) = tokio::task::spawn_blocking({ let run_id = run_id.clone(); - move || AgentOrgRunStore::request_completion(&run_id, &summary) + move || { + let mut recorded = false; + let receipt = AgentOrgToolReceiptStore::execute( + receipt_key, + tool_names::ORG_RUN_COMPLETE, + "request_completion", + &canonical_params, + |tx| { + if let Err(error) = actor.validate_canonical_coordinator(tx, &run_id) { + return match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }; + } + match AgentOrgRunStore::request_completion_in_tx(tx, &run_id, &summary) { + Ok(outcome) => { + recorded = matches!( + outcome, + AgentOrgCompletionRequestOutcome::Recorded { .. } + ); + let body = match outcome { + AgentOrgCompletionRequestOutcome::Recorded { progress } => json!({ + "outcome": "recorded", + "org_run_id": run_id, + "work_revision": progress.work_revision, + "guidance": "Completion was requested durably. Finish this coordinator turn normally; Quiescence will move the Team to Idle only after every blocker settles." + }), + AgentOrgCompletionRequestOutcome::OpenTasks { unresolved_task_ids } => json!({ + "outcome": "open_tasks", + "org_run_id": run_id, + "unresolved_task_ids": unresolved_task_ids, + "guidance": "The completion request was not recorded. Resolve or cancel these durable Tasks first." + }), + }; + serde_json::to_string(&body) + .map(Ok) + .map_err(crate::coordination::agent_org_tool_receipts::AgentOrgToolReceiptAbort::storage) + } + Err(error) => match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }, + } + }, + )?; + Ok::<_, ToolError>((receipt, recorded)) + } }) .await .map_err(|err| { ToolError::ExecutionFailed(format!("org_run_complete worker failed: {err}")) - })? - .map_err(ToolError::ExecutionFailed)?; - - let body = match outcome { - AgentOrgCompletionRequestOutcome::Recorded { progress } => json!({ - "outcome": "recorded", - "org_run_id": run_id, - "work_revision": progress.work_revision, - "guidance": "Completion was requested durably. Finish this coordinator turn normally; the canonical Quiescence reconciler will move the Team to Idle only after every remaining delivery and lifecycle blocker has settled." - }), - AgentOrgCompletionRequestOutcome::OpenTasks { - unresolved_task_ids, - } => json!({ - "outcome": "open_tasks", - "org_run_id": run_id, - "unresolved_task_ids": unresolved_task_ids, - "guidance": "The completion request was not recorded. Resolve, cancel, or explicitly repair these durable tasks, then inspect task_list again." - }), - }; - serde_json::to_string(&body).map_err(|err| { - ToolError::ExecutionFailed(format!("org_run_complete serialization failed: {err}")) - }) + })??; + if receipt.is_fresh() && recorded { + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + } + receipt.result } } diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs index ebf269b22..53df84cd1 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs @@ -24,8 +24,11 @@ use crate::coordination::agent_org_plan_approvals::{ use crate::coordination::agent_org_runs::{ AgentOrgParticipant, AgentOrgRunContext, RoutingDecision, COORDINATOR_MEMBER_ID, }; +use crate::coordination::agent_org_tool_receipts::{ + AgentOrgToolReceiptAbort, AgentOrgToolReceiptKey, AgentOrgToolReceiptStore, +}; use crate::tools::names as tool_names; -use crate::tools::traits::{params_schema, parse_params, Tool, ToolError}; +use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; mod hooks; mod params; @@ -36,7 +39,7 @@ mod tests; pub use hooks::{InboxWakeHook, NoopInboxWakeHook, NoopSelfAbortHook, SelfAbortHook}; pub use params::OrgSendMessageParams; use persistence::{ - ensure_recipients_deliverable, persist_ordinary_message_if_running, + ensure_recipients_deliverable_in_tx, persist_ordinary_message_in_tx, OrdinaryMessagePersistOutcome, OrgRecipientTarget, }; @@ -58,6 +61,24 @@ fn parse_agent_org_remote_mode( Ok(mode) } +fn classify_message_string_error( + error: String, +) -> Result, AgentOrgToolReceiptAbort> { + match super::tasks::classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + } +} + +fn classify_message_tool_error( + error: ToolError, +) -> Result, AgentOrgToolReceiptAbort> { + match error { + ToolError::ExecutionFailed(message) => classify_message_string_error(message), + other => Ok(Err(other)), + } +} + pub struct OrgSendMessageTool { org_context: Arc, sender: AgentOrgParticipant, @@ -418,8 +439,9 @@ impl Tool for OrgSendMessageTool { async fn execute_text( &self, params_value: Value, - _ctx: &crate::tools::traits::CallContext, + call_ctx: &CallContext, ) -> Result { + let canonical_params = params_value.clone(); let params: OrgSendMessageParams = parse_params(params_value)?; let recipients = self .resolve_recipient(¶ms) @@ -450,198 +472,219 @@ impl Tool for OrgSendMessageTool { return Err(ToolError::InvalidParams(hint)); } } - if let AgentMessage::PlanApprovalResponse { - request_id, - accepted, - feedback, - .. - } = &message - { - if !accepted { - let deliverable_run_id = self.org_context.run_id.clone(); - let deliverable_recipients = recipients.clone(); - tokio::task::spawn_blocking(move || { - ensure_recipients_deliverable(&deliverable_run_id, &deliverable_recipients) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "recipient-delivery validation worker failed: {err}" - )) - })??; - } - let lookup_run_id = self.org_context.run_id.clone(); - let lookup_request_id = request_id.as_str().to_string(); - let approval = tokio::task::spawn_blocking(move || { - AgentOrgPlanApprovalStore::get_pending_by_request_id( - &lookup_run_id, - &lookup_request_id, - ) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("plan approval lookup worker failed: {err}")) - })? - .map_err(ToolError::ExecutionFailed)? - .ok_or_else(|| { - ToolError::InvalidParams(format!( - "No pending Agent Org plan approval matches request_id '{}'", - request_id.as_str() - )) - })?; - if recipients.len() != 1 || recipients[0].member_id != approval.source_member_id { - return Err(ToolError::InvalidParams(format!( - "plan_approval_response request_id '{}' must target source member '{}'", - request_id.as_str(), - approval.source_member_id - ))); - } - - if *accepted { - let approval_id = approval.approval_id.clone(); - let plan_revision_id = approval.plan_revision_id.clone(); - let approved = tokio::task::spawn_blocking(move || { - AgentOrgPlanApprovalStore::approve( - &approval_id, - &plan_revision_id, - AgentOrgPlanDecisionBy::Coordinator, - None, - ) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("plan approval worker failed: {err}")) - })? - .map_err(ToolError::ExecutionFailed)?; - let wake_member_ids = approved.wake_member_ids.clone(); - for member_id in &wake_member_ids { - self.wake_hook - .wake_member(member_id, &self.org_context.run_id); - } - return serde_json::to_string(&json!({ - "kind": "plan_approval_response", - "request_id": request_id.as_str(), - "org_run_id": self.org_context.run_id, - "sender_member_id": self.sender.member_id, - "approval_id": approval.approval_id, - "source_task_id": approval.source_task_id, - "decision": "approved", - "woken_member_ids": wake_member_ids, - })) - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "serialize org_send_message result failed: {err}" - )) - }); - } - - let feedback = feedback - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - ToolError::InvalidParams( - "A rejected plan requires non-empty feedback".to_string(), - ) - })?; - let recipient = &recipients[0]; - let approval_id = approval.approval_id.clone(); - let plan_revision_id = approval.plan_revision_id.clone(); - let feedback = feedback.to_string(); - let delivery = AgentOrgPlanInboxDelivery { - recipient_agent_id: recipient.agent_id.clone(), - sender_agent_id: self.sender.agent_id.clone(), - sender_member_id: Some(self.sender.member_id.clone()), - }; - let (_, record) = tokio::task::spawn_blocking(move || { - AgentOrgPlanApprovalStore::request_changes( - &approval_id, - &plan_revision_id, - AgentOrgPlanDecisionBy::Coordinator, - &feedback, - delivery, - ) + let run_id = self.org_context.run_id.clone(); + let sender = self.sender.clone(); + let receipt_key = AgentOrgToolReceiptKey::from_call_context(run_id.clone(), call_ctx)?; + let call_session_id = call_ctx.session_id.clone(); + let call_turn_intent_id = call_ctx.turn_intent_id.clone(); + let operation = message.kind_tag(); + let (receipt, wake_member_ids, abort_sender_after_commit) = + tokio::task::spawn_blocking(move || { + let mut wake_member_ids = Vec::new(); + let mut abort_sender_after_commit = false; + let receipt = AgentOrgToolReceiptStore::execute( + receipt_key, + tool_names::ORG_SEND_MESSAGE, + operation, + &canonical_params, + |tx| { + let context = crate::coordination::agent_org_turn_contexts::revalidate_context_with_connection( + tx, + &call_session_id, + &call_turn_intent_id, + ) + .map_err(|error| { + AgentOrgToolReceiptAbort::rejected(ToolError::InvalidParams(error)) + })?; + if context.org_run_id != run_id + || context.participant_id != sender.member_id + { + return Err(AgentOrgToolReceiptAbort::rejected( + ToolError::PermissionDenied( + "org_send_message caller does not match the persisted Agent Org Turn" + .to_string(), + ), + )); + } + + if let AgentMessage::PlanApprovalResponse { + request_id, + accepted, + feedback, + .. + } = &message + { + if !accepted { + if let Err(error) = ensure_recipients_deliverable_in_tx( + tx, + &run_id, + &recipients, + ) { + return classify_message_tool_error(error); + } + } + let approval = match AgentOrgPlanApprovalStore::get_pending_by_request_id_with_connection( + tx, + &run_id, + request_id.as_str(), + ) { + Ok(Some(approval)) => approval, + Ok(None) => { + return Ok(Err(ToolError::InvalidParams(format!( + "No pending Agent Org plan approval matches request_id '{}'", + request_id.as_str() + )))); + } + Err(error) => return classify_message_string_error(error), + }; + if recipients.len() != 1 + || recipients[0].member_id != approval.source_member_id + { + return Ok(Err(ToolError::InvalidParams(format!( + "plan_approval_response request_id '{}' must target source member '{}'", + request_id.as_str(), + approval.source_member_id + )))); + } + + if *accepted { + let approved = match AgentOrgPlanApprovalStore::approve_in_tx( + tx, + &approval.approval_id, + &approval.plan_revision_id, + AgentOrgPlanDecisionBy::Coordinator, + None, + ) { + Ok(approved) => approved, + Err(error) => return classify_message_string_error(error), + }; + wake_member_ids = approved.wake_member_ids.clone(); + return serde_json::to_string(&json!({ + "kind": "plan_approval_response", + "request_id": request_id.as_str(), + "org_run_id": run_id, + "sender_member_id": sender.member_id, + "approval_id": approval.approval_id, + "source_task_id": approval.source_task_id, + "decision": "approved", + "woken_member_ids": wake_member_ids, + })) + .map(Ok) + .map_err(AgentOrgToolReceiptAbort::storage); + } + + let feedback = feedback + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AgentOrgToolReceiptAbort::rejected(ToolError::InvalidParams( + "A rejected plan requires non-empty feedback".to_string(), + )) + })?; + let recipient = &recipients[0]; + let delivery = AgentOrgPlanInboxDelivery { + recipient_agent_id: recipient.agent_id.clone(), + sender_agent_id: sender.agent_id.clone(), + sender_member_id: Some(sender.member_id.clone()), + }; + let (_, record) = match AgentOrgPlanApprovalStore::request_changes_in_tx( + tx, + &approval.approval_id, + &approval.plan_revision_id, + AgentOrgPlanDecisionBy::Coordinator, + feedback, + delivery, + ) { + Ok(result) => result, + Err(error) => return classify_message_string_error(error), + }; + wake_member_ids.push(recipient.member_id.clone()); + return serde_json::to_string(&json!({ + "kind": "plan_approval_response", + "request_id": request_id.as_str(), + "org_run_id": run_id, + "sender_member_id": sender.member_id, + "approval_id": approval.approval_id, + "source_task_id": approval.source_task_id, + "decision": "changes_requested", + "inbox_id": record.id, + "woken_member_ids": wake_member_ids, + })) + .map(Ok) + .map_err(AgentOrgToolReceiptAbort::storage); + } + + let persist_outcome = match persist_ordinary_message_in_tx( + tx, + &run_id, + &sender, + ¶ms, + &message, + &recipients, + ) { + Ok(outcome) => outcome, + Err(error) => return classify_message_tool_error(error), + }; + let delivered_rows = match persist_outcome { + OrdinaryMessagePersistOutcome::Guidance(guidance) => { + return Ok(Ok(guidance)); + } + OrdinaryMessagePersistOutcome::Delivered(delivered_rows) => { + delivered_rows + } + }; + wake_member_ids.extend( + delivered_rows + .iter() + .map(|(recipient_member_id, _)| recipient_member_id.clone()), + ); + abort_sender_after_commit = matches!( + message, + AgentMessage::ShutdownResponse { accepted: true, .. } + ) && !sender.is_coordinator; + let delivered = delivered_rows + .iter() + .map(|(recipient_member_id, inbox_id)| { + json!({ + "recipient_member_id": recipient_member_id, + "inbox_id": inbox_id, + }) + }) + .collect::>(); + serde_json::to_string(&json!({ + "kind": message.kind_tag(), + "request_id": message.request_id().map(|r| r.as_str().to_string()), + "related_task_id": params.related_task_id.as_deref().map(str::trim).filter(|value| !value.is_empty()), + "org_run_id": run_id, + "sender_member_id": sender.member_id, + "delivered": delivered, + "live_channel": false, + })) + .map(Ok) + .map_err(AgentOrgToolReceiptAbort::storage) + }, + )?; + Ok::<_, ToolError>((receipt, wake_member_ids, abort_sender_after_commit)) }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("plan changes-request worker failed: {err}")) - })? - .map_err(ToolError::ExecutionFailed)?; - self.wake_hook - .wake_member(&recipient.member_id, &self.org_context.run_id); - return serde_json::to_string(&json!({ - "kind": "plan_approval_response", - "request_id": request_id.as_str(), - "org_run_id": self.org_context.run_id, - "sender_member_id": self.sender.member_id, - "approval_id": approval.approval_id, - "source_task_id": approval.source_task_id, - "decision": "changes_requested", - "inbox_id": record.id, - "woken_member_ids": [recipient.member_id.clone()], - })) - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "serialize org_send_message result failed: {err}" - )) - }); - } - - let persist_run_id = self.org_context.run_id.clone(); - let persist_sender = self.sender.clone(); - let persist_params = params.clone(); - let persist_message = message.clone(); - let persist_recipients = recipients.clone(); - let persist_outcome = tokio::task::spawn_blocking(move || { - persist_ordinary_message_if_running( - &persist_run_id, - &persist_sender, - &persist_params, - &persist_message, - &persist_recipients, - ) - }) .await .map_err(|err| { ToolError::ExecutionFailed(format!("org message persistence worker failed: {err}")) })??; - let delivered_rows = match persist_outcome { - OrdinaryMessagePersistOutcome::Guidance(guidance) => return Ok(guidance), - OrdinaryMessagePersistOutcome::Delivered(delivered_rows) => delivered_rows, - }; - let delivered = delivered_rows - .iter() - .map(|(recipient_member_id, inbox_id)| { - json!({ - "recipient_member_id": recipient_member_id, - "inbox_id": inbox_id, - }) - }) - .collect::>(); - for (recipient_member_id, _) in &delivered_rows { - self.wake_hook - .wake_member(recipient_member_id, &self.org_context.run_id); - } - - if let AgentMessage::ShutdownResponse { accepted: true, .. } = &message { - if !self.sender.is_coordinator { + if receipt.is_fresh() { + for member_id in &wake_member_ids { + self.wake_hook + .wake_member(member_id, &self.org_context.run_id); + } + if abort_sender_after_commit { self.self_abort_hook .abort_self(&self.sender.member_id, &self.org_context.run_id); } + crate::coordination::agent_org_run_events::notify_agent_org_run_changed( + &self.org_context.run_id, + ); } - - let result = json!({ - "kind": message.kind_tag(), - "request_id": message.request_id().map(|r| r.as_str().to_string()), - "related_task_id": params.related_task_id.as_deref().map(str::trim).filter(|value| !value.is_empty()), - "org_run_id": self.org_context.run_id, - "sender_member_id": self.sender.member_id, - "delivered": delivered, - "live_channel": false, - }); - serde_json::to_string(&result).map_err(|err| { - ToolError::ExecutionFailed(format!("serialize org_send_message result failed: {err}")) - }) + receipt.result } /// Recipient resolution + JSON validation are read-only side-channel diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs index 2107573a3..18f21f08d 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs @@ -3,8 +3,7 @@ //! plain-message-requires-a-task guidance check, archived-recipient //! rejection, and the transactional inbox insert. -use database::db::{get_connection, with_sessions_writer}; -use rusqlite::{params, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension}; use serde_json::json; use crate::coordination::agent_inbox::{AgentInboxStore, AgentMessage, InsertInboxParams}; @@ -103,34 +102,29 @@ fn plain_work_context_guidance( .map_err(|err| ToolError::ExecutionFailed(err.to_string())) } -pub(super) fn persist_ordinary_message_if_running( +pub(super) fn persist_ordinary_message_in_tx( + conn: &Connection, run_id: &str, sender: &AgentOrgParticipant, params: &OrgSendMessageParams, message: &AgentMessage, recipients: &[OrgRecipientTarget], ) -> Result { - with_sessions_writer(|| { - let mut conn = - get_connection().map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; - let run_status: Option = tx - .query_row( - "SELECT status FROM agent_org_runtime_runs WHERE id=?1", - params![run_id], - |row| row.get(0), - ) - .optional() - .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; - if run_status.as_deref() == Some("archived") { - return Err(ToolError::ExecutionFailed( - crate::coordination::agent_org_runs::mutation_blocked_error(run_id, "archived"), - )); - } - if run_status.as_deref() != Some("running") { - let guidance = serde_json::to_string(&json!({ + let run_status: Option = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + params![run_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; + if run_status.as_deref() == Some("archived") { + return Err(ToolError::ExecutionFailed( + crate::coordination::agent_org_runs::mutation_blocked_error(run_id, "archived"), + )); + } + if run_status.as_deref() != Some("running") { + let guidance = serde_json::to_string(&json!({ "delivered": false, "reason": "run_not_running", "org_run_id": run_id, @@ -138,69 +132,61 @@ pub(super) fn persist_ordinary_message_if_running( "guidance": "The Agent Org Team is not Running, so this formal peer message was not persisted. Starting, Paused, Idle, and Failed Teams do not accept this mutation.", })) .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; - tx.commit() - .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; - return Ok(OrdinaryMessagePersistOutcome::Guidance(guidance)); - } + return Ok(OrdinaryMessagePersistOutcome::Guidance(guidance)); + } - let all_tasks = AgentOrgTaskStore::list_with_connection(&tx, run_id) - .map_err(ToolError::ExecutionFailed)?; - if let Some(guidance) = - plain_work_context_guidance(params, message, recipients, &all_tasks)? - { - tx.commit() - .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; - return Ok(OrdinaryMessagePersistOutcome::Guidance(guidance)); - } + let all_tasks = AgentOrgTaskStore::list_with_connection(conn, run_id) + .map_err(ToolError::ExecutionFailed)?; + if let Some(guidance) = plain_work_context_guidance(params, message, recipients, &all_tasks)? { + return Ok(OrdinaryMessagePersistOutcome::Guidance(guidance)); + } - let member_ids = recipients + let member_ids = recipients + .iter() + .filter(|recipient| recipient.member_id != COORDINATOR_MEMBER_ID) + .map(|recipient| recipient.member_id.clone()) + .collect::>(); + let runtimes = AgentOrgRunStore::list_worker_sessions_by_member_ids_with_connection( + conn, + run_id, + &member_ids, + ) + .map_err(ToolError::ExecutionFailed)?; + for recipient in recipients { + if let Some(runtime) = runtimes .iter() - .filter(|recipient| recipient.member_id != COORDINATOR_MEMBER_ID) - .map(|recipient| recipient.member_id.clone()) - .collect::>(); - let runtimes = AgentOrgRunStore::list_worker_sessions_by_member_ids_with_connection( - &tx, - run_id, - &member_ids, - ) - .map_err(ToolError::ExecutionFailed)?; - for recipient in recipients { - if let Some(runtime) = runtimes - .iter() - .find(|runtime| runtime.member_id.as_deref() == Some(recipient.member_id.as_str())) - { - if runtime.status == SessionStatus::Archived { - return Err(ToolError::InvalidParams(format!( + .find(|runtime| runtime.member_id.as_deref() == Some(recipient.member_id.as_str())) + { + if runtime.status == SessionStatus::Archived { + return Err(ToolError::InvalidParams(format!( "delivery_blocked: recipient_member_id '{}' is archived/closed (session_id='{}'); reopen the member session or start a new Agent Org run before sending", recipient.member_id, runtime.session_id ))); - } } } + } - let mut delivered = Vec::with_capacity(recipients.len()); - for recipient in recipients { - let record = AgentInboxStore::insert_in_tx( - &tx, - InsertInboxParams { - recipient_agent_id: recipient.agent_id.clone(), - recipient_member_id: Some(recipient.member_id.clone()), - sender_agent_id: sender.agent_id.clone(), - sender_member_id: Some(sender.member_id.clone()), - org_run_id: Some(run_id.to_string()), - message: message.clone(), - }, - ) - .map_err(ToolError::ExecutionFailed)?; - delivered.push((recipient.member_id.clone(), record.id)); - } - tx.commit() - .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; - Ok(OrdinaryMessagePersistOutcome::Delivered(delivered)) - }) + let mut delivered = Vec::with_capacity(recipients.len()); + for recipient in recipients { + let record = AgentInboxStore::insert_in_tx( + conn, + InsertInboxParams { + recipient_agent_id: recipient.agent_id.clone(), + recipient_member_id: Some(recipient.member_id.clone()), + sender_agent_id: sender.agent_id.clone(), + sender_member_id: Some(sender.member_id.clone()), + org_run_id: Some(run_id.to_string()), + message: message.clone(), + }, + ) + .map_err(ToolError::ExecutionFailed)?; + delivered.push((recipient.member_id.clone(), record.id)); + } + Ok(OrdinaryMessagePersistOutcome::Delivered(delivered)) } -pub(super) fn ensure_recipients_deliverable( +pub(super) fn ensure_recipients_deliverable_in_tx( + conn: &Connection, run_id: &str, recipients: &[OrgRecipientTarget], ) -> Result<(), ToolError> { @@ -209,8 +195,12 @@ pub(super) fn ensure_recipients_deliverable( .filter(|recipient| recipient.member_id != COORDINATOR_MEMBER_ID) .map(|recipient| recipient.member_id.clone()) .collect::>(); - let runtimes = AgentOrgRunStore::list_worker_sessions_by_member_ids(run_id, &member_ids) - .map_err(ToolError::ExecutionFailed)?; + let runtimes = AgentOrgRunStore::list_worker_sessions_by_member_ids_with_connection( + conn, + run_id, + &member_ids, + ) + .map_err(ToolError::ExecutionFailed)?; for recipient in recipients { if let Some(runtime) = runtimes .iter() diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs index d7665b5f8..95729499c 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/tests.rs @@ -8,8 +8,25 @@ use crate::coordination::agent_org_runs::{AgentOrgContextMember, COORDINATOR_MEM use crate::coordination::agent_org_tasks::{ new_task_id, AgentOrgTaskStore, CreateTaskParams, TaskStatus, TASK_METADATA_ELIGIBLE_MEMBER_IDS, }; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; +static NEXT_CALL_ID: AtomicU64 = AtomicU64::new(1); + +fn call_context(sender_member_id: &str) -> crate::tools::call_context::CallContext { + let (session_id, turn_intent_id) = if sender_member_id == COORDINATOR_MEMBER_ID { + ("root-1", "coordinator-turn") + } else { + ("builder-session", "builder-turn") + }; + crate::tools::call_context::CallContext { + session_id: session_id.to_string(), + turn_intent_id: turn_intent_id.to_string(), + call_id: format!("send-call-{}", NEXT_CALL_ID.fetch_add(1, Ordering::Relaxed)), + ..Default::default() + } +} + fn context() -> Arc { Arc::new(AgentOrgRunContext { run_id: "run-1".to_string(), @@ -114,11 +131,7 @@ fn init_inbox_schema() -> test_helpers::test_env::SandboxGuard { crate::foundation::persistence::session_snapshots::ensure_tables_with(&conn) .expect("agent sessions schema"); crate::session::persistence::init(&conn).expect("session schema"); - crate::coordination::agent_org_runs::init_schema(&conn).expect("agent org runs schema"); - crate::coordination::agent_org_tasks::init_schema(&conn).expect("agent org tasks schema"); - crate::coordination::agent_inbox::init_schema(&conn).expect("agent inbox schema"); - crate::coordination::agent_member_interventions::init_schema(&conn) - .expect("member intervention schema"); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); conn.execute_batch( "CREATE TABLE IF NOT EXISTS code_sessions ( session_id TEXT PRIMARY KEY, @@ -127,19 +140,118 @@ fn init_inbox_schema() -> test_helpers::test_env::SandboxGuard { parent_session_id TEXT, org_member_id TEXT, updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) );", ) .expect("CLI session schema"); + let snapshot = crate::definitions::orgs::AgentOrgLaunchSnapshot { + schema_version: 1, + org_id: "org-1".to_string(), + org_name: "Org".to_string(), + coordinator_role: "lead".to_string(), + coordinator_agent_id: "agent-coord".to_string(), + members: vec![ + crate::definitions::orgs::FlatOrgMember { + member_id: "planner".to_string(), + name: "Planner".to_string(), + role: "plan".to_string(), + agent_id: "agent-shared".to_string(), + runtime_config: None, + }, + crate::definitions::orgs::FlatOrgMember { + member_id: "builder".to_string(), + name: "Builder".to_string(), + role: "build".to_string(), + agent_id: "agent-shared".to_string(), + runtime_config: None, + }, + ], + plan_approval_policy: crate::definitions::orgs::PlanApprovalPolicy::Coordinator, + additional_task_graph_writer_member_ids: Vec::new(), + member_communication_links: Vec::new(), + }; let now = chrono::Utc::now().to_rfc3339(); conn.execute( "INSERT INTO agent_org_runtime_runs ( - id, org_id, coordinator_agent_id, root_session_id, - entry_mode, status, created_at, updated_at - ) VALUES ('run-1', 'org-1', 'agent-coord', 'root-1', - 'build', 'running', ?1, ?1)", - rusqlite::params![now], + id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, + entry_mode,status,activation_generation,created_at,updated_at + ) VALUES ('run-1','org-1','agent-coord','root-1',?1, + 'standalone_session','running',1,?2,?2)", + rusqlite::params![serde_json::to_string(&snapshot).unwrap(), &now], ) .expect("seed running Agent Org run"); + for (session_id, member_id, agent_id) in [ + ("root-1", COORDINATOR_MEMBER_ID, "agent-coord"), + ("builder-session", "builder", "agent-shared"), + ] { + crate::session::persistence::upsert_session( + &crate::session::persistence::UnifiedSessionRecord { + session_id: session_id.to_string(), + name: member_id.to_string(), + status: "running".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + session_type: "sde".to_string(), + org_member_id: Some(member_id.to_string()), + agent_definition_id: Some(agent_id.to_string()), + parent_session_id: (member_id != COORDINATOR_MEMBER_ID) + .then(|| "root-1".to_string()), + ..Default::default() + }, + ) + .expect("seed Agent Org participant session"); + } + let authority_task_id = new_task_id(); + AgentOrgTaskStore::create(CreateTaskParams { + id: authority_task_id.clone(), + org_run_id: "run-1".to_string(), + subject: "Builder execution authority".to_string(), + description: String::new(), + active_form: None, + owner: Some("builder".to_string()), + status: TaskStatus::InProgress, + blocks: Vec::new(), + blocked_by: Vec::new(), + metadata: None, + }) + .expect("seed builder authority Task"); + conn.execute_batch(&format!( + "INSERT INTO agent_org_runtime_member_materializations ( + org_run_id,member_id,agent_id,generation,session_id, + authority_class,status,created_at,updated_at + ) VALUES + ('run-1','coordinator','agent-coord',1,'root-1','formal','succeeded','{now}','{now}'), + ('run-1','builder','agent-shared',1,'builder-session','formal','succeeded','{now}','{now}');" + )) + .expect("seed canonical materializations"); + conn.execute_batch(&format!( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES + ('root-1','coordinator-turn','run-1','agent_org','running','{now}','{now}'), + ('builder-session','builder-turn','run-1','agent_org','running','{now}','{now}'); + INSERT INTO agent_org_runtime_turn_contexts ( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + task_id,owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,root_authority_turn_id,actor_version, + activation_generation,created_at + ) VALUES + ('root-1','coordinator-turn','run-1','coordinator','coordinator', + NULL,NULL,NULL,NULL,'root_turn','coordinator-turn',NULL,NULL,1,'{now}'), + ('builder-session','builder-turn','run-1','builder','task_execution', + '{authority_task_id}','builder','builder',1,'task','{authority_task_id}',NULL,NULL,1,'{now}');" + )) + .expect("seed formal Turn contexts"); sandbox } @@ -301,10 +413,16 @@ async fn execute_persists_and_wakes_by_member_id() { let mut input = params("builder"); input["related_task_id"] = Value::String(task_id); + let call = call_context(COORDINATOR_MEMBER_ID); let result = tool - .execute_text(input, &crate::tools::call_context::CallContext::default()) + .execute_text(input.clone(), &call) .await .expect("send should succeed"); + let replay = tool + .execute_text(input, &call) + .await + .expect("lost response retry replays the original delivery"); + assert_eq!(replay, result); let value: serde_json::Value = serde_json::from_str(&result).expect("json result"); assert_eq!(value["sender_member_id"].as_str(), Some("coordinator")); @@ -336,10 +454,7 @@ async fn plain_message_to_worker_without_task_returns_guidance_and_does_not_wake ); let result = tool - .execute_text( - params("builder"), - &crate::tools::call_context::CallContext::default(), - ) + .execute_text(params("builder"), &call_context(COORDINATOR_MEMBER_ID)) .await .expect("missing task is recoverable guidance, not a red tool error"); let value: Value = serde_json::from_str(&result).expect("guidance json"); @@ -377,10 +492,7 @@ async fn ordinary_message_does_not_create_unread_work_after_run_is_archived() { ); let error = tool - .execute_text( - params("coordinator"), - &crate::tools::call_context::CallContext::default(), - ) + .execute_text(params("coordinator"), &call_context("builder")) .await .expect_err("Archived Team rejects the write with a stable error"); assert!(error.to_string().contains("team_archived")); @@ -422,7 +534,7 @@ async fn plain_message_cannot_turn_ownerless_eligibility_into_assignment() { input["related_task_id"] = json!(task_id); let result = tool - .execute_text(input, &crate::tools::call_context::CallContext::default()) + .execute_text(input, &call_context(COORDINATOR_MEMBER_ID)) .await .expect("ownerless work returns structured guidance"); let value: Value = serde_json::from_str(&result).expect("guidance json"); @@ -465,7 +577,7 @@ async fn plain_message_cannot_wake_worker_before_related_task_dependencies_compl input["related_task_id"] = json!(child_id); let result = tool - .execute_text(input, &crate::tools::call_context::CallContext::default()) + .execute_text(input, &call_context(COORDINATOR_MEMBER_ID)) .await .expect("blocked work returns guidance"); let value: Value = serde_json::from_str(&result).unwrap(); @@ -479,10 +591,7 @@ async fn worker_status_message_to_coordinator_does_not_require_task() { let tool = OrgSendMessageTool::new(context(), "builder".to_string()); let result = tool - .execute_text( - params("coordinator"), - &crate::tools::call_context::CallContext::default(), - ) + .execute_text(params("coordinator"), &call_context("builder")) .await .expect("worker escalation to coordinator remains available"); let value: Value = serde_json::from_str(&result).expect("result json"); @@ -510,7 +619,7 @@ async fn shutdown_response_to_coordinator_self_aborts_sender_member() { "request_id": "req-1", "accepted": true }), - &crate::tools::call_context::CallContext::default(), + &call_context("builder"), ) .await .expect("shutdown response should send"); diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs index 1d8304f2c..a4fad061e 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs @@ -5,32 +5,28 @@ use schemars::JsonSchema; use serde::Deserialize; use serde_json::{json, Value}; -use crate::coordination::agent_org_payload_limits::{ - validate_task_identifier, validate_task_identifier_list, -}; +use crate::coordination::agent_org_payload_limits::validate_task_identifier_list; +use crate::coordination::agent_org_runs::AgentOrgRunStore; use crate::coordination::agent_org_tasks::{ self, task_dependency_closure, AgentOrgTaskStore, CreatePendingTaskParams, TaskCreateSchedulingPolicy, TaskExecutionMode, TaskGraphWriterAdmin, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, }; +use crate::coordination::agent_org_tool_receipts::{ + AgentOrgToolReceiptKey, AgentOrgToolReceiptStore, +}; use crate::tools::names as tool_names; use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; use super::{ - map_task_write_error, merge_task_metadata, task_to_json, validate_freeform_task_metadata, - TaskToolsContext, + classify_task_receipt_error, merge_task_metadata, task_to_json, + validate_freeform_task_metadata, TaskOutboxCommit, TaskToolsContext, }; /// Explicit decision about when a newly-created task may be dispatched. -/// -/// This is deliberately required at the LLM tool boundary. An omitted -/// `blocked_by` array used to silently mean "run now", which allowed review -/// and test tasks to race ahead of the work whose output they consume. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TaskDispatchPolicy { - /// The task has no upstream input and may be dispatched immediately. Immediate, - /// The task consumes durable output from all listed upstream tasks. AfterDependencies, } @@ -60,10 +56,8 @@ impl TaskDispatchPolicy { let mut normalized = Vec::new(); for task_id in task_ids { let task_id = task_id.trim(); - if task_id.is_empty() { - continue; - } - if !normalized.iter().any(|existing| existing == task_id) { + if !task_id.is_empty() && !normalized.iter().any(|existing| existing == task_id) + { normalized.push(task_id.to_string()); } } @@ -79,58 +73,28 @@ impl TaskDispatchPolicy { } } -/// Params for `task_create`. `id` is optional — the store mints a -/// UUID if absent so the LLM does not have to. +/// Model-facing create request. Durable ids are always minted after receipt +/// lookup inside the Task Store transaction and returned in the result. #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct TaskCreateParams { - /// Optional caller-supplied bounded identifier. Defaults to a freshly - /// minted v4 UUID. Use only when porting an external task or stamping a - /// deterministic id in tests. - #[serde(default)] - pub id: Option, - /// One-line task title. Required, non-empty. pub subject: String, - /// Optional long-form description. Defaults to empty string. #[serde(default)] pub description: Option, - /// Optional present-progressive form ("Refactoring auth layer") - /// shown by the UI while the task is in_progress. #[serde(default)] pub active_form: Option, - /// Optional initial owner member_id. It must be an exact non-Coordinator - /// roster member_id. When set, `task_create` posts a - /// `TaskAssigned` row to the new owner's inbox if the task is pending. #[serde(default)] pub owner_member_id: Option, - /// Required dispatch decision. Use `immediate` only for independent work; - /// use `after_dependencies` for review, test, aggregation, or any task - /// that consumes another task's output. pub dispatch_policy: String, - /// Required execution mode for the assigned member. Use `plan` only when - /// the task must produce an explicit plan via `create_plan`; all ordinary - /// implementation, review, test, research, and writing work uses `build`. pub execution_mode: String, - /// Existing upstream task ids. Required and non-empty when - /// `dispatch_policy=after_dependencies`; must be empty for `immediate`. #[serde(default)] pub dependency_task_ids: Vec, - /// Explicit confirmation that this task may run before other - /// currently-open tasks not covered by its dependency chain. This is also - /// required when a Build task intentionally bypasses an open Plan task. - /// Defaults to false so an accidentally incomplete dependency list is - /// returned as recoverable guidance instead of being persisted. #[serde(default)] pub allow_parallel_with_unlisted_open_tasks: bool, - /// Free-form metadata bag. Stored verbatim. #[serde(default)] pub metadata: Option, - /// Optional hard eligibility list for ownerless tasks. These are valid - /// candidates for explicit coordinator assignment; eligibility never - /// authorizes autonomous claim, update, or deletion. #[serde(default)] pub eligible_member_ids: Option>, - /// Optional human-readable role hint for display/prompt context only. #[serde(default)] pub required_role: Option, } @@ -153,32 +117,11 @@ impl Tool for TaskCreateTool { fn description(&self) -> &str { concat!( - "Create a task on the org run's task board. The board is shared by every ", - "agent in this Agent Org run. Only the current Coordinator turn may create ", - "or assign graph work; Task Owners use task_update lifecycle operations. ", - "Set `owner_member_id` to an exact worker member_id for ", - "direct assignment — a pending assignee will receive a `task_assigned` inbox ", - "row on their next turn. If you leave `owner_member_id` unset, the task is ", - "parked as awaiting explicit coordinator assignment and MUST provide ", - "`eligible_member_ids` with the exact candidate worker member_ids. No worker ", - "will self-claim or be woken for an ownerless task. ", - "You MUST choose exactly one `dispatch_policy`: `immediate` only when the ", - "task can start independently with the information already available, or ", - "`after_dependencies` plus `dependency_task_ids` with every upstream task id when this task reviews, ", - "tests, aggregates, or otherwise consumes earlier work. Do not create producer ", - "and reviewer/consumer tasks as unrelated parallel work: dependent tasks are ", - "held until every upstream task is completed, ", - "then receive the upstream tasks' durable outputs in their TaskAssigned message. ", - "If other tasks are still open but omitted from `dependency_task_ids`, creation ", - "pauses with recoverable guidance. Add the omitted task ids when their output is ", - "needed. Only the coordinator may set ", - "`allow_parallel_with_unlisted_open_tasks=true` after deciding that the new ", - "task is intentionally independent of every omitted open task. ", - "You MUST also choose `execution_mode`: use `plan` only for a task whose ", - "deliverable is an explicit plan submitted through `create_plan`; use `build` ", - "for implementation, writing, review, testing, research, and all other work. ", - "`required_role` is a human-readable hint only; it does not authorize claim ", - "by itself. Every new task is persisted as pending." + "Create one pending Task on the current Team's durable board. The Coordinator and ", + "a configured Writer may manage graph work; an ordinary Owner may only update the ", + "Task bound to its exact TaskExecution turn. The runtime mints the durable Task id. ", + "Choose immediate only for independent work, or after_dependencies with every ", + "upstream durable Task id for review, test, synthesis, and other consumer work." ) } @@ -188,7 +131,7 @@ impl Tool for TaskCreateTool { fn llm_description(&self) -> Option { Some(format!( - "{}\n\nYour task authority: {}\nAuthorized owner_member_id values for this caller: {}\nUse only `owner_member_id`; do not pass agent_id or display name as ownership. For `eligible_member_ids`, use only worker member_ids from the same authorized catalog except `coordinator`; do not use display names or agent_definition_id.", + "{}\n\nYour task authority: {}\nAuthorized owner_member_id values: {}", self.description(), self.ctx.task_authority_summary(), self.ctx.authorized_task_target_catalog() @@ -204,60 +147,38 @@ impl Tool for TaskCreateTool { params_value: Value, call_ctx: &CallContext, ) -> Result { + let canonical_params = params_value.clone(); let params: TaskCreateParams = parse_params(params_value)?; - if !self.ctx.is_coordinator() { + if !self.ctx.is_task_graph_writer() { return self.ctx.authorization_denied_response( "task_create", vec![self.ctx.caller_owner_member_id()], - "Only the Coordinator may create Task graph work. Send the proposal to the Coordinator.", + "Only the Coordinator or a configured Writer may create Task graph work.", ); } - let actor = - TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) - .map_err(ToolError::InvalidParams)?; - validate_freeform_task_metadata(params.metadata.as_ref()) - .map_err(ToolError::InvalidParams)?; if params.subject.trim().is_empty() { return Err(ToolError::InvalidParams( - "task_create requires a non-empty `subject`".into(), + "task_create requires a non-empty `subject`".to_string(), )); } + validate_freeform_task_metadata(params.metadata.as_ref()) + .map_err(ToolError::InvalidParams)?; let dispatch_policy = TaskDispatchPolicy::parse(¶ms.dispatch_policy).map_err(ToolError::InvalidParams)?; - let execution_mode = TaskExecutionMode::from_wire(¶ms.execution_mode) - .map_err(ToolError::InvalidParams)?; - if dispatch_policy == TaskDispatchPolicy::AfterDependencies - && params - .dependency_task_ids - .iter() - .all(|task_id| task_id.trim().is_empty()) - { - return serde_json::to_string(&json!({ - "created": false, - "requires_dependency_ids": true, - "guidance": "dispatch_policy=after_dependencies requires at least one real dependency_task_id. Prefer task_graph_create for a new multi-stage workflow, or retry task_create with the upstream durable task ids.", - })) - .map_err(|err| ToolError::ExecutionFailed(err.to_string())); - } let blocked_by = dispatch_policy .into_blocked_by(params.dependency_task_ids) .map_err(ToolError::InvalidParams)?; - if params.allow_parallel_with_unlisted_open_tasks && !self.ctx.is_coordinator() { - return self.ctx.authorization_denied_response( - "task_create.override_unlisted_open_tasks", - Vec::new(), - "Only the coordinator may confirm that a new task can bypass other open work. Send the proposed parallel work to the coordinator for approval.", - ); - } - let resolved_owner = match params.owner_member_id.as_deref() { - Some(owner_member_id) => Some( - self.ctx - .resolve_owner_member_id(owner_member_id) - .map_err(ToolError::InvalidParams)?, - ), - None => None, - }; - if let Some(owner_member_id) = resolved_owner.as_ref() { + validate_task_identifier_list("task_create.dependency_task_ids", &blocked_by) + .map_err(ToolError::InvalidParams)?; + let execution_mode = TaskExecutionMode::from_wire(¶ms.execution_mode) + .map_err(ToolError::InvalidParams)?; + let owner = params + .owner_member_id + .as_deref() + .map(|member_id| self.ctx.resolve_owner_member_id(member_id)) + .transpose() + .map_err(ToolError::InvalidParams)?; + if let Some(owner_member_id) = owner.as_ref() { let denied = self .ctx .unauthorized_task_target_member_ids(std::slice::from_ref(owner_member_id)); @@ -265,7 +186,7 @@ impl Tool for TaskCreateTool { return self.ctx.authorization_denied_response( "task_create.assign_owner", denied, - "You may create work only for yourself. Ask the coordinator to create or assign work for another member.", + "The requested owner is outside this Writer's frozen Task authority.", ); } } @@ -280,224 +201,166 @@ impl Tool for TaskCreateTool { return self.ctx.authorization_denied_response( "task_create.set_eligibility", denied, - "An ownerless task may list only candidates you are authorized to manage. Ask the coordinator to create cross-peer or cross-branch unassigned work.", + "The requested eligibility list is outside this Writer's frozen Task authority.", ); } } - let explicit_id = params - .id - .as_ref() - .is_some_and(|value| !value.trim().is_empty()); - let id = params - .id - .clone() - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(agent_org_tasks::new_task_id); - validate_task_identifier("task_create.id", &id).map_err(ToolError::InvalidParams)?; - validate_task_identifier_list("task_create.dependency_task_ids", &blocked_by) - .map_err(ToolError::InvalidParams)?; - if blocked_by.iter().any(|dependency_id| dependency_id == &id) { - return Err(ToolError::InvalidParams(format!( - "{}: task '{id}' cannot depend on itself", - crate::coordination::agent_org_tasks::TASK_DEPENDENCY_CYCLE_ERROR - ))); - } - let read_run_id = self.ctx.org_context.run_id.clone(); - let read_task_id = explicit_id.then(|| id.clone()); - let (existing, existing_tasks) = tokio::task::spawn_blocking(move || { - let existing = read_task_id - .as_deref() - .map(|task_id| AgentOrgTaskStore::get(&read_run_id, task_id)) - .transpose()? - .flatten(); - let tasks = if existing.is_some() { - Vec::new() - } else { - AgentOrgTaskStore::list(&read_run_id)? - }; - Ok::<_, String>((existing, tasks)) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("task_create read worker failed: {err}")) - })? - .map_err(map_task_write_error)?; - if explicit_id { - if let Some(existing) = existing { - let body = json!({ - "task": task_to_json(&existing), - "already_exists": true, - "guidance": "Task id already exists in this run; use task_update for changes instead of creating a duplicate.", - "task_assigned_dispatched": false, - }); - return serde_json::to_string(&body).map_err(|err| { - ToolError::ExecutionFailed(format!( - "task_create: failed to serialize result: {err}" - )) - }); - } - } - - if !blocked_by.is_empty() { - let missing_dependency_ids = blocked_by - .iter() - .filter(|dependency_id| { - !existing_tasks.iter().any(|task| &task.id == *dependency_id) - }) - .cloned() - .collect::>(); - if !missing_dependency_ids.is_empty() { - return Err(ToolError::InvalidParams(format!( - "dispatch_policy references task ids that do not exist in this run: {}. Create upstream tasks first, then use their returned ids.", - missing_dependency_ids.join(", ") - ))); - } - } - - // The old guard trusted `dispatch_policy=immediate` and inspected - // omitted work only after the model had already declared the task a - // dependency consumer. A coordinator could therefore recover from a - // rejected review/synthesis task by relabelling it `immediate`. Treat - // every open task as a scheduling decision: either it is covered by - // this dependency closure, or the coordinator explicitly confirms a - // genuinely independent branch. - let covered_dependency_ids = task_dependency_closure(&blocked_by, &existing_tasks); - let unlisted_open_tasks = existing_tasks - .iter() - .filter(|task| task.status.is_open()) - .filter(|task| !covered_dependency_ids.contains(&task.id)) - .collect::>(); - if !unlisted_open_tasks.is_empty() && !params.allow_parallel_with_unlisted_open_tasks { - let requires_dependency_confirmation = !blocked_by.is_empty() - || unlisted_open_tasks.iter().any(|task| { - agent_org_tasks::task_execution_mode(task) == TaskExecutionMode::Plan - }); - let suggested_dependency_task_ids = blocked_by - .iter() - .cloned() - .chain(unlisted_open_tasks.iter().map(|task| task.id.clone())) - .collect::>(); - let unlisted_open_tasks = unlisted_open_tasks - .into_iter() - .map(|task| { - json!({ - "id": task.id, - "subject": task.subject, - "owner_member_id": task.owner, - "status": task.status.as_wire(), - }) - }) - .collect::>(); - let body = json!({ - "created": false, - "requires_dependency_confirmation": requires_dependency_confirmation, - "requires_parallel_confirmation": !requires_dependency_confirmation, - "unlisted_open_tasks": unlisted_open_tasks, - "suggested_retry": { - "dispatch_policy": "after_dependencies", - "dependency_task_ids": suggested_dependency_task_ids, - }, - "guidance": "Open work is not covered by this task's dependency chain. If the new task consumes those outputs, retry with suggested_retry. If it is intentionally independent of every listed task, only the coordinator may retry with allow_parallel_with_unlisted_open_tasks=true.", - }); - return serde_json::to_string(&body).map_err(|err| { - ToolError::ExecutionFailed(format!( - "task_create: failed to serialize scheduling guidance: {err}" - )) - }); - } - - if resolved_owner.is_none() && eligible_member_ids.as_ref().is_none_or(Vec::is_empty) { + if owner.is_none() && eligible_member_ids.as_ref().is_none_or(Vec::is_empty) { return Err(ToolError::InvalidParams( "ownerless pending tasks require a non-empty eligible_member_ids list".to_string(), )); } + + let actor = + TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams)?; + let activation_session_id = call_ctx.session_id.clone(); + let activation_turn_intent_id = call_ctx.turn_intent_id.clone(); + let receipt_key = AgentOrgToolReceiptKey::from_call_context( + self.ctx.org_context.run_id.clone(), + call_ctx, + )?; + let run_id = self.ctx.org_context.run_id.clone(); + let context = Arc::clone(&self.ctx); + let requested_dependency_task_ids = blocked_by.clone(); + let allow_parallel = params.allow_parallel_with_unlisted_open_tasks; let metadata = merge_task_metadata(params.metadata, eligible_member_ids, params.required_role); + let subject = params.subject; + let description = params.description.unwrap_or_default(); + let active_form = params.active_form; - let create_context = Arc::clone(&self.ctx); - let allow_parallel_with_unlisted_open_tasks = - params.allow_parallel_with_unlisted_open_tasks; - let requested_dependency_task_ids = blocked_by.clone(); - let create_params = CreatePendingTaskParams { - id, - org_run_id: self.ctx.org_context.run_id.clone(), - subject: params.subject, - description: params.description.unwrap_or_default(), - active_form: params.active_form, - owner: resolved_owner, - execution_mode, - blocked_by, - metadata, - originating_message_id: None, - replaces_task_id: None, - }; - let create_result = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::create_pending_with_transactional_effects( - actor, - create_params, - TaskCreateSchedulingPolicy { - allow_parallel_with_unlisted_open_tasks, - }, - |tx, task, tasks| { - create_context.persist_created_tasks_outbox_in_tx( - tx, - std::slice::from_ref(task), - tasks, - ) - }, - ) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("task_create mutation worker failed: {err}")) - })?; - let (task, outbox) = match create_result { - Ok(created) => created, - Err(error) => { - if let Some(task_ids) = error - .strip_prefix(TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR) - .and_then(|suffix| suffix.strip_prefix(':')) - { - let unlisted_open_task_ids = task_ids - .split(',') - .filter(|task_id| !task_id.is_empty()) - .map(str::to_string) - .collect::>(); - let suggested_dependency_task_ids = requested_dependency_task_ids + let (receipt, committed_outbox) = tokio::task::spawn_blocking(move || { + let mut committed_outbox: Option = None; + let receipt = AgentOrgToolReceiptStore::execute( + receipt_key, + tool_names::TASK_CREATE, + "create", + &canonical_params, + |tx| { + let existing_tasks = AgentOrgTaskStore::list_with_connection(tx, &run_id) + .map_err(crate::coordination::agent_org_tool_receipts::AgentOrgToolReceiptAbort::storage)?; + let missing_dependency_ids = requested_dependency_task_ids .iter() + .filter(|dependency_id| { + !existing_tasks.iter().any(|task| &task.id == *dependency_id) + }) .cloned() - .chain(unlisted_open_task_ids.iter().cloned()) .collect::>(); - return serde_json::to_string(&json!({ - "created": false, - "requires_dependency_confirmation": true, - "requires_parallel_confirmation": true, - "unlisted_open_task_ids": unlisted_open_task_ids, - "suggested_retry": { - "dispatch_policy": "after_dependencies", - "dependency_task_ids": suggested_dependency_task_ids, + if !missing_dependency_ids.is_empty() { + return Ok(Err(ToolError::InvalidParams(format!( + "dispatch_policy references task ids that do not exist in this run: {}. Create upstream tasks first, then use their returned ids.", + missing_dependency_ids.join(", ") + )))); + } + let covered = task_dependency_closure( + &requested_dependency_task_ids, + &existing_tasks, + ); + let omitted = existing_tasks + .iter() + .filter(|task| task.status.is_open() && !covered.contains(&task.id)) + .map(|task| task.id.clone()) + .collect::>(); + if !omitted.is_empty() && !allow_parallel { + let response = serde_json::to_string(&json!({ + "created": false, + "requires_parallel_confirmation": true, + "unlisted_open_task_ids": omitted, + "guidance": "Open work is outside this Task's dependency chain. Add its ids when this Task consumes that output, or explicitly confirm an independent branch." + })) + .map_err(crate::coordination::agent_org_tool_receipts::AgentOrgToolReceiptAbort::storage)?; + return Ok(Ok(response)); + } + let create_params = CreatePendingTaskParams { + id: agent_org_tasks::new_task_id(), + org_run_id: run_id.clone(), + subject, + description, + active_form, + owner, + execution_mode, + blocked_by: requested_dependency_task_ids.clone(), + metadata, + originating_message_id: None, + replaces_task_id: None, + }; + if let Err(error) = AgentOrgRunStore::activate_idle_for_task_graph_in_tx( + tx, + &run_id, + &activation_session_id, + &activation_turn_intent_id, + ) { + return match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }; + } + match AgentOrgTaskStore::create_pending_in_tx( + tx, + actor, + create_params, + TaskCreateSchedulingPolicy { + allow_parallel_with_unlisted_open_tasks: allow_parallel, }, - "guidance": "Open work changed while this task was being validated. Add the listed durable task ids when this task consumes their output, or retry with allow_parallel_with_unlisted_open_tasks=true only when the new task is intentionally independent.", - })) - .map_err(|err| ToolError::ExecutionFailed(err.to_string())); - } - return Err(map_task_write_error(error)); - } - }; - self.ctx.wake_committed_task_outbox(&outbox); - let task_assigned_dispatched = outbox.task_assigned_ids.iter().any(|id| id == &task.id); - let assignment_required = task.owner.is_none(); - - let body = json!({ - "task": task_to_json(&task), - "already_exists": false, - "task_assigned_dispatched": task_assigned_dispatched, - "assignment_required": assignment_required, - "guidance": assignment_required.then_some("This task is waiting for an explicit owner assignment. No worker will self-claim or be woken."), - }); - serde_json::to_string(&body).map_err(|err| { - ToolError::ExecutionFailed(format!("task_create: failed to serialize result: {err}")) + |tx, task, tasks| { + context.persist_created_tasks_outbox_in_tx( + tx, + std::slice::from_ref(task), + tasks, + ) + }, + ) { + Ok((task, outbox)) => { + let assignment_required = task.owner.is_none(); + let task_assigned_dispatched = + outbox.task_assigned_ids.iter().any(|id| id == &task.id); + let response = serde_json::to_string(&json!({ + "task": task_to_json(&task), + "already_exists": false, + "task_assigned_dispatched": task_assigned_dispatched, + "assignment_required": assignment_required, + "guidance": assignment_required.then_some("This Task is waiting for explicit owner assignment. No worker will self-claim or be woken."), + })) + .map_err(crate::coordination::agent_org_tool_receipts::AgentOrgToolReceiptAbort::storage)?; + committed_outbox = Some(outbox); + Ok(Ok(response)) + } + Err(error) if error.starts_with(TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR) => { + let ids = error + .split_once(':') + .map(|(_, ids)| ids.split(',').collect::>()) + .unwrap_or_default(); + let response = serde_json::to_string(&json!({ + "created": false, + "requires_parallel_confirmation": true, + "unlisted_open_task_ids": ids, + "guidance": "Open work changed inside the transaction; retry with dependencies or explicit independent-branch confirmation." + })) + .map_err(crate::coordination::agent_org_tool_receipts::AgentOrgToolReceiptAbort::storage)?; + Ok(Ok(response)) + } + Err(error) => match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }, + } + }, + )?; + Ok::<_, ToolError>((receipt, committed_outbox)) }) + .await + .map_err(|error| ToolError::ExecutionFailed(format!("task_create worker failed: {error}")))??; + + if receipt.is_fresh() { + if let Some(outbox) = committed_outbox.as_ref() { + self.ctx.wake_committed_task_outbox(outbox); + } + crate::coordination::agent_org_run_events::notify_agent_org_run_changed( + &self.ctx.org_context.run_id, + ); + } + receipt.result } fn is_read_only(&self) -> bool { diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs index 834696b1b..f3c5e3655 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs @@ -9,23 +9,25 @@ use serde_json::{json, Value}; use crate::coordination::agent_org_payload_limits::{ validate_task_identifier_list, TASK_GRAPH_CREATE_MAX_TASKS, }; +use crate::coordination::agent_org_runs::AgentOrgRunStore; use crate::coordination::agent_org_tasks::{ self, task_dependency_closure, AgentOrgTaskStore, CreatePendingTaskParams, TaskExecutionMode, TaskGraphWriterAdmin, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, }; +use crate::coordination::agent_org_tool_receipts::{ + AgentOrgToolReceiptAbort, AgentOrgToolReceiptKey, AgentOrgToolReceiptStore, +}; use crate::tools::names as tool_names; use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; use super::{ - map_task_write_error, merge_task_metadata, task_to_json, validate_freeform_task_metadata, - TaskToolsContext, + classify_task_receipt_error, merge_task_metadata, task_to_json, + validate_freeform_task_metadata, TaskOutboxCommit, TaskToolsContext, }; #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct TaskGraphNodeParams { - /// Short unique key used only inside this request (for example `plan`, - /// `implement`, `review`). The runtime resolves it to a durable UUID. pub key: String, pub subject: String, #[serde(default)] @@ -34,10 +36,7 @@ pub struct TaskGraphNodeParams { pub active_form: Option, #[serde(default)] pub owner_member_id: Option, - /// `plan` only for a plan submitted through create_plan; otherwise build. pub execution_mode: String, - /// Local node keys from this request or durable task ids already on the - /// same run. Every listed task must complete before this node is assigned. #[serde(default)] pub depends_on: Vec, #[serde(default)] @@ -51,14 +50,22 @@ pub struct TaskGraphNodeParams { #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct TaskGraphCreateParams { - /// Complete graph patch to create atomically. Use at most 32 nodes. pub tasks: Vec, - /// Required only when this graph intentionally starts a new independent - /// branch while older open tasks remain outside the graph. #[serde(default)] pub allow_parallel_with_existing_open_tasks: bool, } +struct PreparedGraphNode { + key: String, + subject: String, + description: String, + active_form: Option, + owner: Option, + execution_mode: TaskExecutionMode, + depends_on: Vec, + metadata: Option, +} + pub struct TaskGraphCreateTool { ctx: Arc, } @@ -77,23 +84,19 @@ impl Tool for TaskGraphCreateTool { fn description(&self) -> &str { concat!( - "Create a complete Agent Org task dependency graph atomically. Use this as the ", - "coordinator's preferred way to decompose a new multi-stage request. Each node ", - "has a short local `key`; `depends_on` references those keys, so you do not need ", - "to create upstream tasks first or copy UUIDs between tool calls. The runtime ", - "validates owners, eligibility, dependency references, cycles, execution modes, ", - "and the run mutation boundary before inserting anything. If validation fails, ", - "zero tasks are created. Roots may run in parallel; review, test, synthesis, and ", - "other consumers must list every upstream key whose output they consume." + "Create a complete pending Task graph atomically. Each node has a request-local key, ", + "and depends_on may reference local keys or existing durable Task ids. The runtime ", + "mints all durable ids after the exactly-once receipt lookup, validates the complete ", + "candidate graph, then commits Tasks, audit history, Inbox outbox, and receipt together." ) } fn llm_description(&self) -> Option { Some(format!( - "{}\n\nYour task authority: {}\nAuthorized owner_member_id values: {}\nUse local keys such as plan/write/review/final; do not invent UUID dependencies for nodes in the same request.", + "{}\n\nYour task authority: {}\nAuthorized owner_member_id values: {}", self.description(), self.ctx.task_authority_summary(), - self.ctx.authorized_task_target_catalog(), + self.ctx.authorized_task_target_catalog() )) } @@ -110,101 +113,40 @@ impl Tool for TaskGraphCreateTool { params_value: Value, call_ctx: &CallContext, ) -> Result { + let canonical_params = params_value.clone(); let params: TaskGraphCreateParams = parse_params(params_value)?; - if !self.ctx.is_coordinator() { + if !self.ctx.is_task_graph_writer() { return self.ctx.authorization_denied_response( "task_graph_create", vec![self.ctx.caller_owner_member_id()], - "Only the coordinator may create a cross-member task graph. Send the proposed graph to the coordinator.", + "Only the Coordinator or a configured Writer may create Task graph work.", ); } - let actor = - TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) - .map_err(ToolError::InvalidParams)?; if params.tasks.is_empty() || params.tasks.len() > TASK_GRAPH_CREATE_MAX_TASKS { return Err(ToolError::InvalidParams(format!( "task_graph_create requires 1..={TASK_GRAPH_CREATE_MAX_TASKS} tasks per request" ))); } - for (index, node) in params.tasks.iter().enumerate() { - validate_task_identifier_list( - &format!("task_graph_create.tasks[{index}].depends_on"), - &node.depends_on, - ) - .map_err(ToolError::InvalidParams)?; - } - let read_run_id = self.ctx.org_context.run_id.clone(); - let existing_tasks = - tokio::task::spawn_blocking(move || AgentOrgTaskStore::list(&read_run_id)) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "task_graph_create read worker failed: {err}" - )) - })? - .map_err(ToolError::ExecutionFailed)?; - let existing_ids = existing_tasks - .iter() - .map(|task| task.id.clone()) - .collect::>(); - let open_existing = existing_tasks - .iter() - .filter(|task| task.status.is_open()) - .map(|task| task.id.clone()) - .collect::>(); - let directly_referenced_existing = params - .tasks - .iter() - .flat_map(|node| node.depends_on.iter()) - .filter(|dependency| existing_ids.contains(dependency.as_str())) - .cloned() - .collect::>(); - let referenced_existing = task_dependency_closure( - &directly_referenced_existing.into_iter().collect::>(), - &existing_tasks, - ); - let omitted_existing = open_existing - .iter() - .filter(|task_id| !referenced_existing.contains(task_id.as_str())) - .cloned() - .collect::>(); - if !omitted_existing.is_empty() && !params.allow_parallel_with_existing_open_tasks { - return serde_json::to_string(&json!({ - "created": false, - "requires_parallel_confirmation": true, - "unlisted_open_task_ids": omitted_existing, - "guidance": "This graph would start while older open tasks remain outside it. Add those durable task ids to the appropriate depends_on lists, or retry with allow_parallel_with_existing_open_tasks=true only when the new graph is intentionally independent.", - })) - .map_err(|err| ToolError::ExecutionFailed(err.to_string())); - } - - let mut key_to_id = HashMap::with_capacity(params.tasks.len()); - for node in ¶ms.tasks { - let key = node.key.trim(); - if key.is_empty() || key.chars().count() > 80 { - return Err(ToolError::InvalidParams( - "every task graph key must be 1..=80 characters".to_string(), - )); - } - if key_to_id - .insert(key.to_string(), agent_org_tasks::new_task_id()) - .is_some() - { + let mut keys = HashSet::with_capacity(params.tasks.len()); + let mut prepared = Vec::with_capacity(params.tasks.len()); + for (index, node) in params.tasks.into_iter().enumerate() { + let key = node.key.trim().to_string(); + if key.is_empty() || key.chars().count() > 80 || !keys.insert(key.clone()) { return Err(ToolError::InvalidParams(format!( - "duplicate task graph key: {key}" + "task graph key at index {index} must be unique and 1..=80 characters" ))); } - } - - let mut create_params = Vec::with_capacity(params.tasks.len()); - for node in params.tasks { if node.subject.trim().is_empty() { return Err(ToolError::InvalidParams(format!( - "task graph node '{}' requires a non-empty subject", - node.key + "task graph node '{key}' requires a non-empty subject" ))); } + validate_task_identifier_list( + &format!("task_graph_create.tasks[{index}].depends_on"), + &node.depends_on, + ) + .map_err(ToolError::InvalidParams)?; validate_freeform_task_metadata(node.metadata.as_ref()) .map_err(ToolError::InvalidParams)?; let owner = node @@ -221,131 +163,223 @@ impl Tool for TaskGraphCreateTool { return self.ctx.authorization_denied_response( "task_graph_create.assign_owner", denied, - "The graph contains an owner outside your task authority.", + "The graph contains an owner outside this Writer's frozen Task authority.", ); } } let eligible_member_ids = node .eligible_member_ids - .map(|member_ids| self.ctx.resolve_eligible_member_ids(member_ids)) + .map(|ids| self.ctx.resolve_eligible_member_ids(ids)) .transpose() .map_err(ToolError::InvalidParams)?; - if owner.is_none() - && eligible_member_ids - .as_ref() - .is_none_or(|ids| ids.is_empty()) - { + if owner.is_none() && eligible_member_ids.as_ref().is_none_or(Vec::is_empty) { return Err(ToolError::InvalidParams(format!( - "ownerless graph node '{}' requires eligible_member_ids", - node.key + "ownerless graph node '{key}' requires eligible_member_ids" ))); } - let execution_mode = TaskExecutionMode::from_wire(&node.execution_mode) - .map_err(ToolError::InvalidParams)?; - let blocked_by = node - .depends_on - .iter() - .map(|dependency| { - let dependency = dependency.trim(); - key_to_id - .get(dependency) - .cloned() - .or_else(|| { - existing_ids - .contains(dependency) - .then(|| dependency.to_string()) - }) - .ok_or_else(|| { - ToolError::InvalidParams(format!( - "task graph node '{}' references unknown dependency '{dependency}'", - node.key - )) - }) - }) - .collect::, _>>()?; - let id = key_to_id.get(node.key.trim()).cloned().ok_or_else(|| { - ToolError::ExecutionFailed(format!( - "task graph key '{}' lost its generated id before persistence", - node.key - )) - })?; - let metadata = - merge_task_metadata(node.metadata, eligible_member_ids, node.required_role); - create_params.push(CreatePendingTaskParams { - id, - org_run_id: self.ctx.org_context.run_id.clone(), + if let Some(ids) = eligible_member_ids.as_ref() { + let denied = self.ctx.unauthorized_task_target_member_ids(ids); + if !denied.is_empty() { + return self.ctx.authorization_denied_response( + "task_graph_create.set_eligibility", + denied, + "The graph contains an eligibility target outside this Writer's frozen Task authority.", + ); + } + } + prepared.push(PreparedGraphNode { + key, subject: node.subject, description: node.description.unwrap_or_default(), active_form: node.active_form, owner, - execution_mode, - blocked_by, - metadata, - originating_message_id: None, - replaces_task_id: None, + execution_mode: TaskExecutionMode::from_wire(&node.execution_mode) + .map_err(ToolError::InvalidParams)?, + depends_on: node.depends_on, + metadata: merge_task_metadata( + node.metadata, + eligible_member_ids, + node.required_role, + ), }); } - let create_context = Arc::clone(&self.ctx); + let actor = + TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams)?; + let activation_session_id = call_ctx.session_id.clone(); + let activation_turn_intent_id = call_ctx.turn_intent_id.clone(); + let receipt_key = AgentOrgToolReceiptKey::from_call_context( + self.ctx.org_context.run_id.clone(), + call_ctx, + )?; + let run_id = self.ctx.org_context.run_id.clone(); + let context = Arc::clone(&self.ctx); let allow_parallel = params.allow_parallel_with_existing_open_tasks; - let create_result = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::create_pending_batch_with_transactional_effects( - actor, - create_params, - allow_parallel, - |tx, created, all_tasks| { - create_context.persist_created_tasks_outbox_in_tx(tx, created, all_tasks) + + let (receipt, committed_outbox) = tokio::task::spawn_blocking(move || { + let mut committed_outbox: Option = None; + let receipt = AgentOrgToolReceiptStore::execute( + receipt_key, + tool_names::TASK_GRAPH_CREATE, + "create_graph", + &canonical_params, + |tx| { + let existing_tasks = AgentOrgTaskStore::list_with_connection(tx, &run_id) + .map_err(AgentOrgToolReceiptAbort::storage)?; + let existing_ids = existing_tasks + .iter() + .map(|task| task.id.clone()) + .collect::>(); + let mut key_to_id = HashMap::with_capacity(prepared.len()); + for node in &prepared { + key_to_id.insert(node.key.clone(), agent_org_tasks::new_task_id()); + } + let mut create_params = Vec::with_capacity(prepared.len()); + for node in prepared { + let blocked_by = node + .depends_on + .iter() + .map(|dependency| { + let dependency = dependency.trim(); + key_to_id + .get(dependency) + .cloned() + .or_else(|| { + existing_ids + .contains(dependency) + .then(|| dependency.to_string()) + }) + .ok_or_else(|| { + ToolError::InvalidParams(format!( + "task graph node '{}' references unknown dependency '{dependency}'", + node.key + )) + }) + }) + .collect::, _>>(); + let blocked_by = match blocked_by { + Ok(blocked_by) => blocked_by, + Err(error) => return Ok(Err(error)), + }; + create_params.push(CreatePendingTaskParams { + id: key_to_id[&node.key].clone(), + org_run_id: run_id.clone(), + subject: node.subject, + description: node.description, + active_form: node.active_form, + owner: node.owner, + execution_mode: node.execution_mode, + blocked_by, + metadata: node.metadata, + originating_message_id: None, + replaces_task_id: None, + }); + } + let directly_referenced_existing = create_params + .iter() + .flat_map(|task| task.blocked_by.iter()) + .filter(|dependency| existing_ids.contains(dependency.as_str())) + .cloned() + .collect::>(); + let referenced_existing = + task_dependency_closure(&directly_referenced_existing, &existing_tasks); + let omitted = existing_tasks + .iter() + .filter(|task| { + task.status.is_open() && !referenced_existing.contains(&task.id) + }) + .map(|task| task.id.clone()) + .collect::>(); + if !omitted.is_empty() && !allow_parallel { + let response = serde_json::to_string(&json!({ + "created": false, + "requires_parallel_confirmation": true, + "unlisted_open_task_ids": omitted, + "guidance": "This graph starts while older open work remains outside it. Add dependencies or explicitly confirm an independent branch." + })) + .map_err(AgentOrgToolReceiptAbort::storage)?; + return Ok(Ok(response)); + } + if let Err(error) = AgentOrgRunStore::activate_idle_for_task_graph_in_tx( + tx, + &run_id, + &activation_session_id, + &activation_turn_intent_id, + ) { + return match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }; + } + match AgentOrgTaskStore::create_pending_batch_in_tx( + tx, + actor, + create_params, + allow_parallel, + |tx, created, all_tasks| { + context.persist_created_tasks_outbox_in_tx(tx, created, all_tasks) + }, + ) { + Ok((created, outbox)) => { + let assignment_required_task_ids = created + .iter() + .filter(|task| task.owner.is_none()) + .map(|task| task.id.clone()) + .collect::>(); + let task_id_by_key = key_to_id + .into_iter() + .map(|(key, id)| (key, Value::String(id))) + .collect::>(); + let response = serde_json::to_string(&json!({ + "created": true, + "org_run_id": run_id, + "tasks": created.iter().map(task_to_json).collect::>(), + "task_id_by_key": task_id_by_key, + "task_assigned_dispatched_ids": outbox.task_assigned_ids, + "assignment_required_task_ids": assignment_required_task_ids, + })) + .map_err(AgentOrgToolReceiptAbort::storage)?; + committed_outbox = Some(outbox); + Ok(Ok(response)) + } + Err(error) if error.starts_with(TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR) => { + let ids = error + .split_once(':') + .map(|(_, ids)| ids.split(',').collect::>()) + .unwrap_or_default(); + let response = serde_json::to_string(&json!({ + "created": false, + "requires_parallel_confirmation": true, + "unlisted_open_task_ids": ids, + "guidance": "Open work changed inside the transaction; retry with dependencies or explicit independent-branch confirmation." + })) + .map_err(AgentOrgToolReceiptAbort::storage)?; + Ok(Ok(response)) + } + Err(error) => match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }, + } }, - ) + )?; + Ok::<_, ToolError>((receipt, committed_outbox)) }) .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("task_graph_create mutation worker failed: {err}")) - })?; - let (created, outbox) = match create_result { - Ok(created) => created, - Err(error) => { - if let Some(task_ids) = error - .strip_prefix(TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR) - .and_then(|suffix| suffix.strip_prefix(':')) - { - let unlisted_open_task_ids = task_ids - .split(',') - .filter(|task_id| !task_id.is_empty()) - .collect::>(); - return serde_json::to_string(&json!({ - "created": false, - "requires_parallel_confirmation": true, - "unlisted_open_task_ids": unlisted_open_task_ids, - "guidance": "Open work changed while this graph was being validated. Add the listed ids to depends_on, or retry with allow_parallel_with_existing_open_tasks=true only when the graph is intentionally independent.", - })) - .map_err(|err| ToolError::ExecutionFailed(err.to_string())); - } - return Err(map_task_write_error(error)); + .map_err(|error| { + ToolError::ExecutionFailed(format!("task_graph_create worker failed: {error}")) + })??; + + if receipt.is_fresh() { + if let Some(outbox) = committed_outbox.as_ref() { + self.ctx.wake_committed_task_outbox(outbox); } - }; - self.ctx.wake_committed_task_outbox(&outbox); - let dispatched_task_ids = outbox.task_assigned_ids; - let assignment_required_task_ids: Vec = created - .iter() - .filter(|task| task.owner.is_none()) - .map(|task| task.id.clone()) - .collect(); - let has_assignment_required = !assignment_required_task_ids.is_empty(); - let task_id_by_key = key_to_id - .into_iter() - .map(|(key, task_id)| (key, Value::String(task_id))) - .collect::>(); - serde_json::to_string(&json!({ - "created": true, - "org_run_id": self.ctx.org_context.run_id, - "tasks": created.iter().map(task_to_json).collect::>(), - "task_id_by_key": task_id_by_key, - "task_assigned_dispatched_ids": dispatched_task_ids, - "assignment_required_task_ids": assignment_required_task_ids, - "guidance": has_assignment_required.then_some("Ownerless tasks are waiting for explicit assignment. No worker will self-claim or be woken."), - })) - .map_err(|err| ToolError::ExecutionFailed(err.to_string())) + crate::coordination::agent_org_run_events::notify_agent_org_run_changed( + &self.ctx.org_context.run_id, + ); + } + receipt.result } fn is_read_only(&self) -> bool { diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs index 66def4aec..3c00408a8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use serde_json::{json, Value}; @@ -11,6 +12,7 @@ use crate::tools::registry::ToolRegistry; use crate::tools::traits::{CallContext, Tool, ToolError}; use test_helpers::test_env; +use super::run_complete::OrgRunCompleteTool; use super::task_create::TaskCreateTool; use super::task_graph_create::TaskGraphCreateTool; use super::task_list_get::{TaskGetTool, TaskListTool}; @@ -65,9 +67,49 @@ fn tools_context(caller_member_id: &str) -> Arc { }) } +fn writer_tools_context(caller_member_id: &str) -> Arc { + let mut context = (*org_context()).clone(); + let snapshot = crate::definitions::orgs::AgentOrgLaunchSnapshot { + schema_version: 1, + org_id: "org-task-tools".into(), + org_name: "Task Tools Org".into(), + coordinator_role: "lead".into(), + coordinator_agent_id: "agent-coordinator".into(), + plan_approval_policy: crate::definitions::orgs::PlanApprovalPolicy::Coordinator, + members: vec![ + crate::definitions::orgs::FlatOrgMember { + member_id: ALICE.into(), + name: "Alice".into(), + role: "builder".into(), + agent_id: "agent-alice".into(), + runtime_config: None, + }, + crate::definitions::orgs::FlatOrgMember { + member_id: BOB.into(), + name: "Bob".into(), + role: "reviewer".into(), + agent_id: "agent-bob".into(), + runtime_config: None, + }, + ], + additional_task_graph_writer_member_ids: vec![ALICE.into()], + member_communication_links: Vec::new(), + }; + context.capability_index = + crate::definitions::orgs::AgentOrgCapabilityIndex::from_snapshot(&snapshot); + Arc::new(TaskToolsContext { + caller_agent_id: context + .require_participant_agent_id(caller_member_id) + .expect("participant"), + caller_member_id: caller_member_id.to_string(), + org_context: Arc::new(context), + wake_hook: Arc::new(NoopInboxWakeHook), + }) +} + fn coordinator_call() -> CallContext { CallContext::for_turn( - "call-coordinator", + format!("call-coordinator-{}", next_call_sequence()), ROOT_SESSION, COORDINATOR_TURN, Vec::new(), @@ -75,7 +117,17 @@ fn coordinator_call() -> CallContext { } fn owner_call(turn_id: &str) -> CallContext { - CallContext::for_turn("call-owner", ALICE_SESSION, turn_id, Vec::new()) + CallContext::for_turn( + format!("call-owner-{}", next_call_sequence()), + ALICE_SESSION, + turn_id, + Vec::new(), + ) +} + +fn next_call_sequence() -> u64 { + static NEXT_CALL: AtomicU64 = AtomicU64::new(1); + NEXT_CALL.fetch_add(1, Ordering::Relaxed) } fn sandbox() -> test_env::SandboxGuard { @@ -130,6 +182,39 @@ fn sandbox() -> test_env::SandboxGuard { rusqlite::params![RUN_ID, ROOT_SESSION, snapshot, now], ) .expect("running Team"); + for (session_id, member_id, agent_id, parent_session_id) in [ + ( + ROOT_SESSION, + COORDINATOR_MEMBER_ID, + "agent-coordinator", + None, + ), + (ALICE_SESSION, ALICE, "agent-alice", Some(ROOT_SESSION)), + ] { + crate::session::persistence::upsert_session( + &crate::session::persistence::UnifiedSessionRecord { + session_id: session_id.to_string(), + name: member_id.to_string(), + status: "running".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + session_type: "sde".to_string(), + org_member_id: Some(member_id.to_string()), + agent_definition_id: Some(agent_id.to_string()), + parent_session_id: parent_session_id.map(str::to_string), + ..Default::default() + }, + ) + .expect("participant Session"); + conn.execute( + "INSERT INTO agent_org_runtime_member_materializations ( + org_run_id,member_id,agent_id,generation,session_id, + authority_class,status,created_at,updated_at + ) VALUES (?1,?2,?3,1,?4,'formal','succeeded',?5,?5)", + rusqlite::params![RUN_ID, member_id, agent_id, session_id, &now], + ) + .expect("participant materialization"); + } insert_coordinator_context(&conn); sandbox } @@ -194,7 +279,6 @@ async fn create_owned(id: &str, owner: &str) -> Value { let result = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "id": id, "subject": format!("Task {id}"), "owner_member_id": owner, "dispatch_policy": "immediate", @@ -264,7 +348,7 @@ fn task_tool_schemas_expose_pending_create_and_tagged_update() { assert!(update_schema.to_string().contains("patch_pending")); assert!(!update_schema.to_string().contains("\"start\"")); assert!(update_schema["properties"].get("active_form").is_some()); - assert!(update_schema["properties"].get("output").is_none()); + assert!(update_schema["properties"].get("output").is_some()); let owner_update = TaskUpdateTool::new(tools_context(ALICE)); let owner_update_schema = owner_update.parameters(); @@ -274,13 +358,13 @@ fn task_tool_schemas_expose_pending_create_and_tagged_update() { assert!(!owner_update_schema.to_string().contains("patch_pending")); assert!(owner_update_schema["properties"] .get("active_form") - .is_none()); + .is_some()); assert!(owner_update_schema["properties"].get("output").is_some()); let owner_wire = crate::providers::responses_common::convert_tools(Some(&[owner_update.to_schema()])) .expect("Responses tool conversion"); let owner_wire_params = &owner_wire[0]["parameters"]; - assert!(owner_wire_params["properties"].get("active_form").is_none()); + assert!(owner_wire_params["properties"].get("active_form").is_some()); assert!(owner_wire_params["properties"].get("output").is_some()); assert!(owner_wire_params["required"] .as_array() @@ -293,12 +377,110 @@ fn task_tool_schemas_expose_pending_create_and_tagged_update() { .expect("task_graph_create schema"); } +#[tokio::test] +async fn completion_request_replays_without_rewriting_progress() { + let _sandbox = sandbox(); + let tool = OrgRunCompleteTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let call = CallContext::for_turn( + "completion-call", + ROOT_SESSION, + COORDINATOR_TURN, + Vec::new(), + ); + let request = json!({"summary":"The requested work is complete"}); + let first = tool + .execute_text(request.clone(), &call) + .await + .expect("completion request records"); + let progress_before = crate::coordination::agent_org_runs::AgentOrgRunStore::progress(RUN_ID) + .unwrap() + .unwrap(); + let replay = tool + .execute_text(request, &call) + .await + .expect("same completion request replays"); + let progress_after = crate::coordination::agent_org_runs::AgentOrgRunStore::progress(RUN_ID) + .unwrap() + .unwrap(); + assert_eq!(replay, first); + assert_eq!(progress_after, progress_before); +} + +#[tokio::test] +async fn configured_writer_uses_one_schema_for_graph_and_owned_lifecycle_authority() { + let _sandbox = sandbox(); + let conn = database::db::get_connection().expect("test sqlite"); + let mut snapshot: Value = serde_json::from_str( + &conn + .query_row( + "SELECT org_snapshot_json FROM agent_org_runtime_runs WHERE id=?1", + [RUN_ID], + |row| row.get::<_, String>(0), + ) + .unwrap(), + ) + .unwrap(); + snapshot["additionalTaskGraphWriterMemberIds"] = json!([ALICE]); + conn.execute( + "UPDATE agent_org_runtime_runs SET org_snapshot_json=?2 WHERE id=?1", + rusqlite::params![RUN_ID, serde_json::to_string(&snapshot).unwrap()], + ) + .unwrap(); + + let authority = create_owned("writer-authority", ALICE).await; + let authority_id = authority["task"]["id"].as_str().unwrap().to_string(); + let target = create_owned("writer-target", BOB).await; + let target_id = target["task"]["id"].as_str().unwrap().to_string(); + let writer_turn = "turn-configured-writer"; + insert_owner_context(&conn, writer_turn, &authority_id); + + let writer = TaskUpdateTool::new(writer_tools_context(ALICE)); + let schema = writer.parameters().to_string(); + assert!(schema.contains("patch_pending")); + assert!(schema.contains("\"start\"")); + + writer + .execute_text( + json!({ + "operation": "patch_pending", + "id": target_id, + "description": "Edited by the configured Writer" + }), + &owner_call(writer_turn), + ) + .await + .expect("configured Writer can patch shared graph work"); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, &target_id) + .unwrap() + .unwrap() + .description, + "Edited by the configured Writer" + ); + + writer + .execute_text( + json!({"operation":"start","id":authority_id}), + &owner_call(writer_turn), + ) + .await + .expect("configured Writer keeps Owner authority for its exact execution Task"); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, &authority_id) + .unwrap() + .unwrap() + .status, + TaskStatus::InProgress + ); +} + #[tokio::test] async fn task_create_uses_persisted_coordinator_context_and_always_creates_pending() { let _sandbox = sandbox(); let value = create_owned("created", ALICE).await; assert_eq!(value["task"]["status"], "pending"); - let stored = AgentOrgTaskStore::get(RUN_ID, "created").unwrap().unwrap(); + let task_id = value["task"]["id"].as_str().unwrap(); + let stored = AgentOrgTaskStore::get(RUN_ID, task_id).unwrap().unwrap(); assert_eq!(stored.status, TaskStatus::Pending); assert_eq!(stored.created_by_participant_id, COORDINATOR_MEMBER_ID); assert_eq!(stored.source_turn_intent_id, COORDINATOR_TURN); @@ -306,7 +488,6 @@ async fn task_create_uses_persisted_coordinator_context_and_always_creates_pendi let status_error = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "id": "forged-state", "subject": "Forged", "owner_member_id": ALICE, "status": "completed", @@ -323,7 +504,6 @@ async fn task_create_uses_persisted_coordinator_context_and_always_creates_pendi let owner_error = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "id": "coordinator-owned", "subject": "Forbidden", "owner_member_id": "coordinator", "dispatch_policy": "immediate", @@ -337,13 +517,269 @@ async fn task_create_uses_persisted_coordinator_context_and_always_creates_pendi assert!(owner_error.to_string().contains("formal Task Owner")); } +#[tokio::test] +async fn idle_first_task_activates_once_and_replay_stays_read_only_after_archive() { + let _sandbox = sandbox(); + let conn = database::db::get_connection().expect("test sqlite"); + conn.execute( + "UPDATE agent_org_runtime_runs + SET status='idle',idled_at=?2,last_activity_outcome='completed' + WHERE id=?1", + rusqlite::params![RUN_ID, chrono::Utc::now().to_rfc3339()], + ) + .expect("idle Team"); + + let tool = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let call = CallContext::for_turn( + "idle-first-task-call", + ROOT_SESSION, + COORDINATOR_TURN, + Vec::new(), + ); + let request = json!({ + "subject": "First work after Idle", + "owner_member_id": ALICE, + "dispatch_policy": "immediate", + "execution_mode": "build" + }); + let first = tool + .execute_text(request.clone(), &call) + .await + .expect("Idle first work activates and creates"); + let first_value: Value = serde_json::from_str(&first).unwrap(); + let first_task_id = first_value["task"]["id"].as_str().unwrap().to_string(); + let activated = crate::coordination::agent_org_runs::AgentOrgRunStore::load(RUN_ID) + .unwrap() + .unwrap(); + assert_eq!( + activated.status, + crate::coordination::agent_org_runs::AgentOrgRunStatus::Running + ); + assert_eq!(activated.activation_generation, 2); + let marker_generation: i64 = conn + .query_row( + "SELECT activation_generation FROM agent_org_runtime_turn_contexts + WHERE session_id=?1 AND turn_intent_id=?2", + rusqlite::params![ROOT_SESSION, COORDINATOR_TURN], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(marker_generation, 2); + + let replay = tool + .execute_text(request.clone(), &call) + .await + .expect("same call replays original result"); + assert_eq!(replay, first); + assert_eq!(AgentOrgTaskStore::list(RUN_ID).unwrap().len(), 1); + + let expansion_call = CallContext::for_turn( + "working-expansion-call", + ROOT_SESSION, + COORDINATOR_TURN, + Vec::new(), + ); + tool.execute_text( + json!({ + "subject": "Independent expansion", + "owner_member_id": ALICE, + "dispatch_policy": "immediate", + "execution_mode": "build", + "allow_parallel_with_unlisted_open_tasks": true + }), + &expansion_call, + ) + .await + .expect("Working expansion succeeds"); + assert_eq!( + crate::coordination::agent_org_runs::AgentOrgRunStore::load(RUN_ID) + .unwrap() + .unwrap() + .activation_generation, + 2, + "Working expansion must not bump activation generation" + ); + + conn.execute( + "UPDATE agent_org_runtime_runs + SET status='archived',activation_generation=activation_generation+1, + archived_at=?2,archive_receipt_id='idle-replay-archive' + WHERE id=?1", + rusqlite::params![RUN_ID, chrono::Utc::now().to_rfc3339()], + ) + .unwrap(); + let before = ( + AgentOrgTaskStore::list(RUN_ID).unwrap().len(), + conn.query_row( + "SELECT COUNT(*) FROM agent_org_runtime_inbox WHERE org_run_id=?1", + [RUN_ID], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + conn.query_row( + "SELECT COUNT(*) FROM agent_org_runtime_tool_call_receipts WHERE org_run_id=?1", + [RUN_ID], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + ); + let archived_replay = tool + .execute_text(request.clone(), &call) + .await + .expect("committed call remains replayable after archive"); + assert_eq!(archived_replay, first); + let after = ( + AgentOrgTaskStore::list(RUN_ID).unwrap().len(), + conn.query_row( + "SELECT COUNT(*) FROM agent_org_runtime_inbox WHERE org_run_id=?1", + [RUN_ID], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + conn.query_row( + "SELECT COUNT(*) FROM agent_org_runtime_tool_call_receipts WHERE org_run_id=?1", + [RUN_ID], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + ); + assert_eq!(after, before); + assert!(AgentOrgTaskStore::get(RUN_ID, &first_task_id) + .unwrap() + .is_some()); + + let conflict = tool + .execute_text( + json!({ + "subject": "Changed request under same provider call id", + "owner_member_id": ALICE, + "dispatch_policy": "immediate", + "execution_mode": "build" + }), + &call, + ) + .await + .expect_err("same key with different request conflicts"); + assert!(conflict + .to_string() + .contains("agent_org_tool_call_receipt_conflict")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_idle_task_creates_share_one_activation_generation() { + let _sandbox = sandbox(); + let conn = database::db::get_connection().expect("test sqlite"); + conn.execute( + "UPDATE agent_org_runtime_runs + SET status='idle',idled_at=?2,last_activity_outcome='completed' + WHERE id=?1", + rusqlite::params![RUN_ID, chrono::Utc::now().to_rfc3339()], + ) + .expect("idle Team"); + + let context = tools_context(COORDINATOR_MEMBER_ID); + let first_tool = TaskCreateTool::new(Arc::clone(&context)); + let second_tool = TaskCreateTool::new(context); + let first_call = CallContext::for_turn( + "idle-concurrent-call-a", + ROOT_SESSION, + COORDINATOR_TURN, + Vec::new(), + ); + let second_call = CallContext::for_turn( + "idle-concurrent-call-b", + ROOT_SESSION, + COORDINATOR_TURN, + Vec::new(), + ); + let first_request = json!({ + "subject": "Concurrent branch A", + "owner_member_id": ALICE, + "dispatch_policy": "immediate", + "execution_mode": "build", + "allow_parallel_with_unlisted_open_tasks": true + }); + let second_request = json!({ + "subject": "Concurrent branch B", + "owner_member_id": BOB, + "dispatch_policy": "immediate", + "execution_mode": "build", + "allow_parallel_with_unlisted_open_tasks": true + }); + + let (first, second) = tokio::join!( + first_tool.execute_text(first_request, &first_call), + second_tool.execute_text(second_request, &second_call) + ); + first.expect("first concurrent create"); + second.expect("second concurrent create"); + + let run = crate::coordination::agent_org_runs::AgentOrgRunStore::load(RUN_ID) + .unwrap() + .unwrap(); + assert_eq!(run.activation_generation, 2); + assert_eq!(AgentOrgTaskStore::list(RUN_ID).unwrap().len(), 2); + let marker_generation: i64 = conn + .query_row( + "SELECT activation_generation FROM agent_org_runtime_turn_contexts + WHERE session_id=?1 AND turn_intent_id=?2", + rusqlite::params![ROOT_SESSION, COORDINATOR_TURN], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(marker_generation, 2); +} + +#[tokio::test] +async fn paused_first_task_is_rejected_without_task_or_receipt() { + let _sandbox = sandbox(); + let conn = database::db::get_connection().expect("test sqlite"); + conn.execute( + "UPDATE agent_org_runtime_runs + SET status='paused',activation_generation=2,updated_at=?2 + WHERE id=?1", + rusqlite::params![RUN_ID, chrono::Utc::now().to_rfc3339()], + ) + .expect("pause Team fixture"); + let tool = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let call = CallContext::for_turn( + "paused-first-task-call", + ROOT_SESSION, + COORDINATOR_TURN, + Vec::new(), + ); + + let error = tool + .execute_text( + json!({ + "subject": "Must wait for Resume", + "owner_member_id": ALICE, + "dispatch_policy": "immediate", + "execution_mode": "build" + }), + &call, + ) + .await + .expect_err("Paused Team rejects new work"); + assert!(error.to_string().contains("team_paused_resume_required")); + assert!(AgentOrgTaskStore::list(RUN_ID).unwrap().is_empty()); + let receipt_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_org_runtime_tool_call_receipts + WHERE org_run_id=?1 AND call_id=?2", + rusqlite::params![RUN_ID, &call.call_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(receipt_count, 0); +} + #[tokio::test] async fn task_create_rejects_contextless_or_member_graph_writer_calls() { let _sandbox = sandbox(); let missing = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "id": "missing-context", "subject": "Missing context", "owner_member_id": ALICE, "dispatch_policy": "immediate", @@ -354,15 +790,12 @@ async fn task_create_rejects_contextless_or_member_graph_writer_calls() { ) .await .expect_err("Store actor requires persisted context"); - assert!(missing.to_string().contains("context_required")); - assert!(AgentOrgTaskStore::get(RUN_ID, "missing-context") - .unwrap() - .is_none()); + assert!(missing.to_string().contains("required")); + assert!(AgentOrgTaskStore::list(RUN_ID).unwrap().is_empty()); let member = TaskCreateTool::new(tools_context(ALICE)) .execute_text( json!({ - "id": "member-graph", "subject": "Member graph", "owner_member_id": ALICE, "dispatch_policy": "immediate", @@ -375,9 +808,7 @@ async fn task_create_rejects_contextless_or_member_graph_writer_calls() { let denied: Value = serde_json::from_str(&member).expect("authorization JSON"); assert_eq!(denied["authorization_denied"], true); assert!(denied["guidance"].as_str().unwrap().contains("Coordinator")); - assert!(AgentOrgTaskStore::get(RUN_ID, "member-graph") - .unwrap() - .is_none()); + assert!(AgentOrgTaskStore::list(RUN_ID).unwrap().is_empty()); } #[tokio::test] @@ -433,15 +864,16 @@ async fn task_graph_create_is_atomic_and_rejects_cycle_or_coordinator_owner() { #[tokio::test] async fn owner_tagged_operations_follow_task_execution_context() { let _sandbox = sandbox(); - create_owned("owned", ALICE).await; + let created = create_owned("owned", ALICE).await; + let task_id = created["task"]["id"].as_str().unwrap().to_string(); let conn = database::db::get_connection().unwrap(); - insert_owner_context(&conn, "turn-owned", "owned"); + insert_owner_context(&conn, "turn-owned", &task_id); let tool = TaskUpdateTool::new(tools_context(ALICE)); let started: Value = serde_json::from_str( &tool .execute_text( - json!({"operation":"start","id":"owned"}), + json!({"operation":"start","id":task_id}), &owner_call("turn-owned"), ) .await @@ -451,7 +883,7 @@ async fn owner_tagged_operations_follow_task_execution_context() { assert_eq!(started["task"]["status"], "in_progress"); tool.execute_text( - json!({"operation":"append_progress","id":"owned","body":"halfway"}), + json!({"operation":"append_progress","id":task_id,"body":"halfway"}), &owner_call("turn-owned"), ) .await @@ -462,7 +894,7 @@ async fn owner_tagged_operations_follow_task_execution_context() { .execute_text( json!({ "operation":"complete", - "id":"owned", + "id":task_id, "output":{ "summary":"done", "content":"full result", @@ -476,13 +908,13 @@ async fn owner_tagged_operations_follow_task_execution_context() { ) .unwrap(); assert_eq!(completed["task"]["status"], "completed"); - let stored = AgentOrgTaskStore::get(RUN_ID, "owned").unwrap().unwrap(); + let stored = AgentOrgTaskStore::get(RUN_ID, &task_id).unwrap().unwrap(); assert_eq!(stored.output.as_ref().unwrap().produced_by_member_id, ALICE); let mixed: Value = serde_json::from_str( &tool .execute_text( - json!({"operation":"start","id":"owned","subject":"forged graph field"}), + json!({"operation":"start","id":task_id,"subject":"forged graph field"}), &owner_call("turn-owned"), ) .await @@ -491,23 +923,19 @@ async fn owner_tagged_operations_follow_task_execution_context() { .expect("correction JSON"); assert_eq!(mixed["needs_correction"], true); assert_eq!(mixed["unexpected_fields"], json!(["subject"])); - assert_eq!( - mixed["expected_call"], - json!({"operation":"start","id":""}) - ); + assert_eq!(mixed["allowed_fields"], json!(["operation", "id"])); } #[tokio::test] async fn owner_task_update_schema_and_parser_handle_real_strict_provider_payloads() { let _sandbox = sandbox(); - create_owned("portable", ALICE).await; + let created = create_owned("portable", ALICE).await; + let task_id = created["task"]["id"].as_str().unwrap().to_string(); let conn = database::db::get_connection().unwrap(); - insert_owner_context(&conn, "turn-portable", "portable"); + insert_owner_context(&conn, "turn-portable", &task_id); let mut registry = ToolRegistry::new(); registry.register(Box::new(TaskUpdateTool::new(tools_context(ALICE)))); - let call = owner_call("turn-portable"); - let correction = registry .execute( "task_update", @@ -520,7 +948,7 @@ async fn owner_task_update_schema_and_parser_handle_real_strict_provider_payload "description": null, "eligible_member_ids": null, "execution_mode": null, - "id": "portable", + "id": task_id, "metadata": null, "operation": "start", "output": null, @@ -530,7 +958,7 @@ async fn owner_task_update_schema_and_parser_handle_real_strict_provider_payload "required_role": null, "subject": null }), - &call, + &owner_call("turn-portable"), ) .await .expect("recoverable provider misuse is a structured tool result"); @@ -538,7 +966,7 @@ async fn owner_task_update_schema_and_parser_handle_real_strict_provider_payload assert_eq!(correction["needs_correction"], true); assert_eq!(correction["unexpected_fields"], json!(["active_form"])); assert_eq!( - AgentOrgTaskStore::get(RUN_ID, "portable") + AgentOrgTaskStore::get(RUN_ID, &task_id) .unwrap() .unwrap() .status, @@ -548,22 +976,26 @@ async fn owner_task_update_schema_and_parser_handle_real_strict_provider_payload let started = registry .execute( "task_update", - strict_provider_task_update_payload("start", "portable"), - &call, + strict_provider_task_update_payload("start", &task_id), + &owner_call("turn-portable"), ) .await .expect("semantic-empty strict-provider placeholders do not block start"); let started: Value = serde_json::from_str(&started.text).expect("started task JSON"); assert_eq!(started["task"]["status"], "in_progress"); - let mut complete_payload = strict_provider_task_update_payload("complete", "portable"); + let mut complete_payload = strict_provider_task_update_payload("complete", &task_id); complete_payload["output"] = json!({ "summary": "done", "content": "full result", "artifact_ids": ["artifact-portable"] }); let completed = registry - .execute("task_update", complete_payload, &call) + .execute( + "task_update", + complete_payload, + &owner_call("turn-portable"), + ) .await .expect("strict-provider complete payload is normalized by the real registry path"); let completed: Value = serde_json::from_str(&completed.text).expect("completed task JSON"); @@ -592,7 +1024,7 @@ async fn owner_task_update_schema_and_parser_handle_real_strict_provider_payload let reconciled = crate::coordination::reconcile_agent_org_turns_after_restart(&conn) .expect("restart reconciliation"); assert_eq!(reconciled, 0); - let restarted = AgentOrgTaskStore::get(RUN_ID, "portable") + let restarted = AgentOrgTaskStore::get(RUN_ID, &task_id) .expect("restart Task read") .expect("completed Task persists"); assert_eq!(restarted.status, TaskStatus::Completed); @@ -620,7 +1052,6 @@ async fn create_fail_and_cancel_accept_only_semantic_empty_cross_operation_place .execute( "task_create", json!({ - "id": "create-portable", "subject": "Portable create", "description": "", "active_form": null, @@ -640,38 +1071,43 @@ async fn create_fail_and_cancel_accept_only_semantic_empty_cross_operation_place let created: Value = serde_json::from_str(&created.text).expect("created Task JSON"); assert_eq!(created["task"]["status"], "pending"); - create_owned("fail-portable", ALICE).await; + let fail_created = create_owned("fail-portable", ALICE).await; + let fail_id = fail_created["task"]["id"].as_str().unwrap().to_string(); let conn = database::db::get_connection().unwrap(); - insert_owner_context(&conn, "turn-fail-portable", "fail-portable"); + insert_owner_context(&conn, "turn-fail-portable", &fail_id); let mut owner_registry = ToolRegistry::new(); owner_registry.register(Box::new(TaskUpdateTool::new(tools_context(ALICE)))); - let owner_call = owner_call("turn-fail-portable"); owner_registry .execute( "task_update", - strict_provider_task_update_payload("start", "fail-portable"), - &owner_call, + strict_provider_task_update_payload("start", &fail_id), + &owner_call("turn-fail-portable"), ) .await .expect("start before fail"); - let mut fail_payload = strict_provider_task_update_payload("fail", "fail-portable"); + let mut fail_payload = strict_provider_task_update_payload("fail", &fail_id); fail_payload["reason"] = json!({ "code": "verification.failed", "message": "deterministic failure" }); let failed = owner_registry - .execute("task_update", fail_payload, &owner_call) + .execute( + "task_update", + fail_payload, + &owner_call("turn-fail-portable"), + ) .await .expect("fail accepts empty placeholders from other operations"); let failed: Value = serde_json::from_str(&failed.text).expect("failed Task JSON"); assert_eq!(failed["task"]["status"], "failed"); - create_owned("cancel-portable", ALICE).await; + let cancel_created = create_owned("cancel-portable", ALICE).await; + let cancel_id = cancel_created["task"]["id"].as_str().unwrap().to_string(); let mut coordinator_registry = ToolRegistry::new(); coordinator_registry.register(Box::new(TaskUpdateTool::new(tools_context( COORDINATOR_MEMBER_ID, )))); - let mut cancel_payload = strict_provider_task_update_payload("cancel", "cancel-portable"); + let mut cancel_payload = strict_provider_task_update_payload("cancel", &cancel_id); cancel_payload["reason"] = json!({ "code": "scope.cancelled", "message": "no longer required" @@ -687,16 +1123,18 @@ async fn create_fail_and_cancel_accept_only_semantic_empty_cross_operation_place #[tokio::test] async fn task_update_placeholder_normalization_stays_fail_closed() { let _sandbox = sandbox(); - create_owned("strict-negative", ALICE).await; - create_owned("strict-cancel", ALICE).await; + let strict_created = create_owned("strict-negative", ALICE).await; + let strict_id = strict_created["task"]["id"].as_str().unwrap().to_string(); + let cancel_created = create_owned("strict-cancel", ALICE).await; + let cancel_id = cancel_created["task"]["id"].as_str().unwrap().to_string(); let conn = database::db::get_connection().unwrap(); - insert_owner_context(&conn, "turn-strict-negative", "strict-negative"); + insert_owner_context(&conn, "turn-strict-negative", &strict_id); let mut owner_registry = ToolRegistry::new(); owner_registry.register(Box::new(TaskUpdateTool::new(tools_context(ALICE)))); let owner_call = owner_call("turn-strict-negative"); - let mut unknown = strict_provider_task_update_payload("start", "strict-negative"); + let mut unknown = strict_provider_task_update_payload("start", &strict_id); unknown["unknown_provider_field"] = json!(""); let unknown = owner_registry .execute("task_update", unknown, &owner_call) @@ -711,7 +1149,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { let wrong_shape = owner_registry .execute( "task_update", - json!({"operation":"start", "id":"strict-negative", "body":{}}), + json!({"operation":"start", "id":strict_id, "body":{}}), &owner_call, ) .await @@ -725,7 +1163,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { "task_update", json!({ "operation":"start", - "id":"strict-negative", + "id":strict_id, "body":"real progress", "output":{"summary":"forged output"}, "reason":{"code":"forged", "message":"forged reason"} @@ -741,7 +1179,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { json!(["body", "output", "reason"]) ); assert_eq!( - AgentOrgTaskStore::get(RUN_ID, "strict-negative") + AgentOrgTaskStore::get(RUN_ID, &strict_id) .unwrap() .unwrap() .status, @@ -751,7 +1189,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { AgentOrgTaskStore::list_history(RUN_ID) .unwrap() .iter() - .filter(|event| event.task_id == "strict-negative") + .filter(|event| event.task_id == strict_id) .count(), 1, "rejected payloads must not write Task history" @@ -760,7 +1198,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { owner_registry .execute( "task_update", - strict_provider_task_update_payload("start", "strict-negative"), + strict_provider_task_update_payload("start", &strict_id), &owner_call, ) .await @@ -768,7 +1206,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { let missing_output = owner_registry .execute( "task_update", - json!({"operation":"complete", "id":"strict-negative"}), + json!({"operation":"complete", "id":strict_id}), &owner_call, ) .await @@ -784,7 +1222,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { let empty_output = owner_registry .execute( "task_update", - strict_provider_task_update_payload("complete", "strict-negative"), + strict_provider_task_update_payload("complete", &strict_id), &owner_call, ) .await @@ -793,14 +1231,14 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { let empty_reason = owner_registry .execute( "task_update", - strict_provider_task_update_payload("fail", "strict-negative"), + strict_provider_task_update_payload("fail", &strict_id), &owner_call, ) .await .expect_err("empty current-operation reason remains invalid"); assert!(empty_reason.contains("requires non-empty code and message")); assert_eq!( - AgentOrgTaskStore::get(RUN_ID, "strict-negative") + AgentOrgTaskStore::get(RUN_ID, &strict_id) .unwrap() .unwrap() .status, @@ -810,7 +1248,7 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { AgentOrgTaskStore::list_history(RUN_ID) .unwrap() .iter() - .filter(|event| event.task_id == "strict-negative") + .filter(|event| event.task_id == strict_id) .count(), 2, "invalid terminal calls must not write Task history" @@ -823,14 +1261,14 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { let cancel_error = coordinator_registry .execute( "task_update", - strict_provider_task_update_payload("cancel", "strict-cancel"), + strict_provider_task_update_payload("cancel", &cancel_id), &coordinator_call(), ) .await .expect_err("empty current-operation cancel reason remains invalid"); assert!(cancel_error.contains("requires non-empty code and message")); assert_eq!( - AgentOrgTaskStore::get(RUN_ID, "strict-cancel") + AgentOrgTaskStore::get(RUN_ID, &cancel_id) .unwrap() .unwrap() .status, @@ -841,14 +1279,15 @@ async fn task_update_placeholder_normalization_stays_fail_closed() { #[tokio::test] async fn coordinator_cannot_submit_output_and_wrong_turn_cannot_start() { let _sandbox = sandbox(); - create_owned("protected", ALICE).await; + let created = create_owned("protected", ALICE).await; + let task_id = created["task"]["id"].as_str().unwrap().to_string(); let coordinator = TaskUpdateTool::new(tools_context(COORDINATOR_MEMBER_ID)); let correction: Value = serde_json::from_str( &coordinator .execute_text( json!({ "operation":"complete", - "id":"protected", + "id":task_id, "output":{"summary":"forged"} }), &coordinator_call(), @@ -861,7 +1300,7 @@ async fn coordinator_cannot_submit_output_and_wrong_turn_cannot_start() { assert!(correction["reason"] .as_str() .unwrap() - .contains("outside this caller's persisted Task authority")); + .contains("outside this caller's frozen Task authority")); assert!(!correction["allowed_operations"] .as_array() .unwrap() @@ -871,14 +1310,14 @@ async fn coordinator_cannot_submit_output_and_wrong_turn_cannot_start() { let owner = TaskUpdateTool::new(tools_context(ALICE)); let error = owner .execute_text( - json!({"operation":"start","id":"protected"}), + json!({"operation":"start","id":task_id}), &owner_call("missing-turn"), ) .await .expect_err("missing persisted Turn context"); assert!(error.to_string().contains("missing companion context")); assert_eq!( - AgentOrgTaskStore::get(RUN_ID, "protected") + AgentOrgTaskStore::get(RUN_ID, &task_id) .unwrap() .unwrap() .status, @@ -889,13 +1328,14 @@ async fn coordinator_cannot_submit_output_and_wrong_turn_cannot_start() { #[tokio::test] async fn cancel_replace_and_late_callback_use_the_store_gate() { let _sandbox = sandbox(); - create_owned("old", ALICE).await; + let created = create_owned("old", ALICE).await; + let task_id = created["task"]["id"].as_str().unwrap().to_string(); let conn = database::db::get_connection().unwrap(); - insert_owner_context(&conn, "turn-old", "old"); + insert_owner_context(&conn, "turn-old", &task_id); let owner = TaskUpdateTool::new(tools_context(ALICE)); owner .execute_text( - json!({"operation":"start","id":"old"}), + json!({"operation":"start","id":task_id}), &owner_call("turn-old"), ) .await @@ -907,10 +1347,9 @@ async fn cancel_replace_and_late_callback_use_the_store_gate() { .execute_text( json!({ "operation":"cancel_and_replace", - "id":"old", + "id":task_id, "reason":{"code":"scope.changed","message":"new goal"}, "replacement":{ - "id":"replacement", "subject":"Replacement", "owner_member_id":BOB, "execution_mode":"build", @@ -930,7 +1369,7 @@ async fn cancel_replace_and_late_callback_use_the_store_gate() { .execute_text( json!({ "operation":"complete", - "id":"old", + "id":task_id, "output":{"summary":"late"} }), &owner_call("turn-old"), @@ -944,13 +1383,14 @@ async fn cancel_replace_and_late_callback_use_the_store_gate() { async fn task_list_and_get_cover_five_states_without_loading_detail_in_pages() { let _sandbox = sandbox(); create_owned("pending", BOB).await; - create_owned("completed", ALICE).await; + let completed = create_owned("completed", ALICE).await; + let completed_id = completed["task"]["id"].as_str().unwrap().to_string(); let conn = database::db::get_connection().unwrap(); - insert_owner_context(&conn, "turn-complete", "completed"); + insert_owner_context(&conn, "turn-complete", &completed_id); let owner = TaskUpdateTool::new(tools_context(ALICE)); owner .execute_text( - json!({"operation":"start","id":"completed"}), + json!({"operation":"start","id":completed_id}), &owner_call("turn-complete"), ) .await @@ -959,7 +1399,7 @@ async fn task_list_and_get_cover_five_states_without_loading_detail_in_pages() { .execute_text( json!({ "operation":"complete", - "id":"completed", + "id":completed_id, "output":{"summary":"summary","content":"detail"} }), &owner_call("turn-complete"), @@ -984,7 +1424,7 @@ async fn task_list_and_get_cover_five_states_without_loading_detail_in_pages() { let detail: Value = serde_json::from_str( &TaskGetTool::new(tools_context(COORDINATOR_MEMBER_ID)) - .execute_text(json!({"id":"completed"}), &coordinator_call()) + .execute_text(json!({"id":completed_id}), &coordinator_call()) .await .expect("Task detail"), ) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs index 5dac64634..a1e485958 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs @@ -3,19 +3,22 @@ use std::sync::Arc; use async_trait::async_trait; use schemars::JsonSchema; use serde::Deserialize; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use crate::coordination::agent_org_tasks::{ AgentOrgTaskStore, CreatePendingTaskParams, PendingTaskGraphPatch, TaskAnnotationKind, TaskExecutionMode, TaskGraphWriterAdmin, TaskOutputInput, TaskOwnerExecution, TaskTerminalReason, }; +use crate::coordination::agent_org_tool_receipts::{ + AgentOrgToolReceiptKey, AgentOrgToolReceiptStore, +}; use crate::tools::names as tool_names; use crate::tools::traits::{parse_params, CallContext, Tool, ToolError}; use super::{ - map_task_write_error, task_to_json, validate_freeform_task_metadata, TaskOutboxCommit, - TaskToolsContext, + classify_task_receipt_error, merge_task_metadata, task_to_json, + validate_freeform_task_metadata, TaskOutboxCommit, TaskToolsContext, }; #[derive(Debug, Deserialize, JsonSchema)] @@ -100,8 +103,6 @@ pub struct TaskReasonParams { #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ReplacementTaskParams { - #[serde(default)] - pub id: Option, pub subject: String, #[serde(default)] pub description: Option, @@ -120,13 +121,13 @@ pub struct ReplacementTaskParams { pub required_role: Option, } -const COORDINATOR_TASK_UPDATE_OPERATIONS: &[&str] = &[ +const GRAPH_OPERATIONS: &[&str] = &[ "patch_pending", "cancel", "cancel_and_replace", "append_audit_note", ]; -const OWNER_TASK_UPDATE_OPERATIONS: &[&str] = &[ +const OWNER_OPERATIONS: &[&str] = &[ "start", "complete", "fail", @@ -155,6 +156,8 @@ const TASK_UPDATE_FIELDS: &[&str] = &[ const TASK_OUTPUT_FIELDS: &[&str] = &["summary", "content", "artifact_ids"]; const TASK_REASON_FIELDS: &[&str] = &["code", "message"]; const REPLACEMENT_TASK_FIELDS: &[&str] = &[ + // Historical provider-expanded placeholders may still contain an empty + // id even though durable replacement ids are no longer model-facing. "id", "subject", "description", @@ -169,7 +172,7 @@ const REPLACEMENT_TASK_FIELDS: &[&str] = &[ #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TaskUpdateAuthority { - Coordinator, + Graph, Owner, } @@ -179,9 +182,9 @@ struct TaskUpdateOperationContract { } fn task_update_operation_contract(operation: &str) -> Option { - let contract = match operation { + Some(match operation { "patch_pending" => TaskUpdateOperationContract { - authority: TaskUpdateAuthority::Coordinator, + authority: TaskUpdateAuthority::Graph, allowed_fields: &[ "operation", "id", @@ -211,11 +214,11 @@ fn task_update_operation_contract(operation: &str) -> Option TaskUpdateOperationContract { - authority: TaskUpdateAuthority::Coordinator, + authority: TaskUpdateAuthority::Graph, allowed_fields: &["operation", "id", "reason"], }, "cancel_and_replace" => TaskUpdateOperationContract { - authority: TaskUpdateAuthority::Coordinator, + authority: TaskUpdateAuthority::Graph, allowed_fields: &["operation", "id", "reason", "replacement"], }, "append_progress" | "append_evidence" => TaskUpdateOperationContract { @@ -223,12 +226,11 @@ fn task_update_operation_contract(operation: &str) -> Option TaskUpdateOperationContract { - authority: TaskUpdateAuthority::Coordinator, + authority: TaskUpdateAuthority::Graph, allowed_fields: &["operation", "id", "body"], }, _ => return None, - }; - Some(contract) + }) } fn is_semantically_empty_json_placeholder(value: &Value) -> bool { @@ -274,76 +276,16 @@ fn is_removable_cross_operation_placeholder(field: &str, value: &Value) -> bool } } -fn task_update_operation_example(operation: &str) -> Value { - match operation { - "patch_pending" => json!({ - "operation": "patch_pending", - "id": "", - "subject": "" - }), - "start" => json!({"operation": "start", "id": ""}), - "complete" => json!({ - "operation": "complete", - "id": "", - "output": {"summary": ""} - }), - "fail" => json!({ - "operation": "fail", - "id": "", - "reason": {"code": "", "message": ""} - }), - "cancel" => json!({ - "operation": "cancel", - "id": "", - "reason": {"code": "", "message": ""} - }), - "cancel_and_replace" => json!({ - "operation": "cancel_and_replace", - "id": "", - "reason": {"code": "", "message": ""}, - "replacement": { - "subject": "", - "execution_mode": "build" - } - }), - "append_progress" => json!({ - "operation": "append_progress", - "id": "", - "body": "" - }), - "append_evidence" => json!({ - "operation": "append_evidence", - "id": "", - "body": "" - }), - "append_audit_note" => json!({ - "operation": "append_audit_note", - "id": "", - "body": "" - }), - _ => Value::Null, - } -} - fn task_update_correction( - is_coordinator: bool, + allowed_operations: &[&str], operation: Option<&str>, unexpected_fields: Vec, reason: &str, ) -> Value { - let allowed_operations = if is_coordinator { - COORDINATOR_TASK_UPDATE_OPERATIONS - } else { - OWNER_TASK_UPDATE_OPERATIONS - }; - let contract = operation.and_then(task_update_operation_contract); - let allowed_fields = contract - .as_ref() + let allowed_fields = operation + .and_then(task_update_operation_contract) .map(|contract| contract.allowed_fields) .unwrap_or(&[]); - let expected_call = operation - .map(task_update_operation_example) - .unwrap_or(Value::Null); json!({ "needs_correction": true, "tool": tool_names::TASK_UPDATE, @@ -352,103 +294,117 @@ fn task_update_correction( "unexpected_fields": unexpected_fields, "allowed_operations": allowed_operations, "allowed_fields": allowed_fields, - "expected_call": expected_call, - "guidance": "Retry task_update once using only the fields in expected_call/allowed_fields. Do not copy fields from another operation." - }) -} - -fn task_output_schema() -> Value { - json!({ - "type": "object", - "additionalProperties": false, - "required": ["summary"], - "properties": { - "summary": { "type": "string" }, - "content": { "type": "string" }, - "artifact_ids": { "type": "array", "items": { "type": "string" } } - } - }) -} - -fn task_reason_schema() -> Value { - json!({ - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { "type": "string" }, - "message": { "type": "string" } - } + "guidance": "Retry once using only fields allowed for the selected operation. Empty fields from other operations are ignored for provider compatibility; meaningful or unknown fields fail closed." }) } -fn replacement_task_schema() -> Value { +fn task_update_parameters(allow_graph: bool, allow_owner: bool) -> Value { + let operations = GRAPH_OPERATIONS + .iter() + .copied() + .filter(|_| allow_graph) + .chain(OWNER_OPERATIONS.iter().copied().filter(|_| allow_owner)) + .collect::>(); json!({ "type": "object", "additionalProperties": false, - "required": ["subject", "execution_mode"], + "required": ["operation", "id"], "properties": { + "operation": { "type": "string", "enum": operations }, "id": { "type": "string" }, "subject": { "type": "string" }, "description": { "type": "string" }, "active_form": { "type": "string" }, + "clear_active_form": { "type": "boolean" }, "owner_member_id": { "type": "string" }, + "clear_owner": { "type": "boolean" }, "execution_mode": { "type": "string", "enum": ["plan", "build"] }, "blocked_by": { "type": "array", "items": { "type": "string" } }, "metadata": { "type": "object", "additionalProperties": true }, "eligible_member_ids": { "type": "array", "items": { "type": "string" } }, - "required_role": { "type": "string" } + "required_role": { "type": "string" }, + "body": { "type": "string" }, + "output": { + "type": "object", + "additionalProperties": false, + "required": ["summary"], + "properties": { + "summary": { "type": "string" }, + "content": { "type": "string" }, + "artifact_ids": { "type": "array", "items": { "type": "string" } } + } + }, + "reason": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + }, + "replacement": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "execution_mode"], + "properties": { + "subject": { "type": "string" }, + "description": { "type": "string" }, + "active_form": { "type": "string" }, + "owner_member_id": { "type": "string" }, + "execution_mode": { "type": "string", "enum": ["plan", "build"] }, + "blocked_by": { "type": "array", "items": { "type": "string" } }, + "metadata": { "type": "object", "additionalProperties": true }, + "eligible_member_ids": { "type": "array", "items": { "type": "string" } }, + "required_role": { "type": "string" } + } + } } }) } -fn task_update_parameters(is_coordinator: bool) -> Value { - if is_coordinator { - json!({ - "type": "object", - "additionalProperties": false, - "required": ["operation", "id"], - "properties": { - "operation": { - "type": "string", - "enum": COORDINATOR_TASK_UPDATE_OPERATIONS, - "description": "Coordinator only. patch_pending uses graph fields; cancel uses reason; cancel_and_replace uses reason+replacement; append_audit_note uses body. Omit fields from every other operation." - }, - "id": { "type": "string" }, - "subject": { "type": "string", "description": "patch_pending only" }, - "description": { "type": "string", "description": "patch_pending only" }, - "active_form": { "type": "string", "description": "patch_pending only" }, - "clear_active_form": { "type": "boolean", "description": "patch_pending only" }, - "owner_member_id": { "type": "string", "description": "patch_pending only" }, - "clear_owner": { "type": "boolean", "description": "patch_pending only" }, - "execution_mode": { "type": "string", "enum": ["plan", "build"], "description": "patch_pending only" }, - "blocked_by": { "type": "array", "items": { "type": "string" }, "description": "patch_pending only" }, - "metadata": { "type": "object", "additionalProperties": true, "description": "patch_pending only" }, - "eligible_member_ids": { "type": "array", "items": { "type": "string" }, "description": "patch_pending only" }, - "required_role": { "type": "string", "description": "patch_pending only" }, - "body": { "type": "string", "description": "append_audit_note only" }, - "reason": task_reason_schema(), - "replacement": replacement_task_schema() - } - }) - } else { - json!({ - "type": "object", - "additionalProperties": false, - "required": ["operation", "id"], - "properties": { - "operation": { - "type": "string", - "enum": OWNER_TASK_UPDATE_OPERATIONS, - "description": "Owner only. start accepts exactly operation+id; complete adds output; fail adds reason; append_progress/append_evidence add body. Omit fields from every other operation." - }, - "id": { "type": "string" }, - "body": { "type": "string", "description": "append_progress or append_evidence only" }, - "output": task_output_schema(), - "reason": task_reason_schema() - } - }) - } +enum PreparedTaskUpdate { + Patch { + actor: TaskGraphWriterAdmin, + id: String, + patch: PendingTaskGraphPatch, + }, + Start { + actor: TaskOwnerExecution, + id: String, + }, + Complete { + actor: TaskOwnerExecution, + id: String, + output: TaskOutputInput, + }, + Fail { + actor: TaskOwnerExecution, + id: String, + reason: TaskTerminalReason, + }, + Cancel { + actor: TaskGraphWriterAdmin, + id: String, + reason: TaskTerminalReason, + }, + CancelAndReplace { + actor: TaskGraphWriterAdmin, + id: String, + reason: TaskTerminalReason, + replacement: CreatePendingTaskParams, + }, + OwnerAnnotation { + actor: TaskOwnerExecution, + id: String, + kind: TaskAnnotationKind, + body: String, + }, + AuditAnnotation { + actor: TaskGraphWriterAdmin, + id: String, + body: String, + }, } pub struct TaskUpdateTool { @@ -468,14 +424,14 @@ impl Tool for TaskUpdateTool { } fn description(&self) -> &str { - "Apply one explicit Task operation. Coordinator operations manage pending graph fields, cancellation, replacement, and terminal audit notes. Owner operations start, complete, fail, or append progress/evidence to that Owner's persisted in-progress Task. Mixed graph and Owner fields are rejected by the tagged operation schema." + "Apply one exactly-once Task operation. Graph writers manage sparse pending fields, cancellation, replacement, and terminal audit notes. A Task Owner may start, complete, fail, or annotate only the Task bound to the exact persisted TaskExecution turn. A configured Writer has both sets of operations but still cannot execute another Task's Owner lifecycle." } fn llm_description(&self) -> Option { Some(format!( - "{}\n\nYour Task authority: {}. New work is always created pending; only an Owner TaskExecution turn can start, complete, or fail it.", + "{}\n\nYour Task authority: {}", self.description(), - self.ctx.task_authority_summary(), + self.ctx.task_authority_summary() )) } @@ -484,15 +440,7 @@ impl Tool for TaskUpdateTool { } fn parameters(&self) -> Value { - // `TaskUpdateParams` stays a serde-tagged enum so the runtime parser - // rejects fields that belong to a different actor/operation. Schemars - // represents that enum as a top-level `oneOf`, however, and several - // function-calling providers silently discard such schemas. Keep the - // portable flat object, but expose only operations and fields this - // session's persisted org role may actually use. This prevents strict - // providers from filling Coordinator-only fields into an Owner - // `start` call while the typed parser remains the authority boundary. - task_update_parameters(self.ctx.is_coordinator()) + task_update_parameters(self.ctx.is_task_graph_writer(), !self.ctx.is_coordinator()) } async fn execute_text( @@ -500,249 +448,211 @@ impl Tool for TaskUpdateTool { mut params_value: Value, call_ctx: &CallContext, ) -> Result { - let params = match self.parse_model_params(&mut params_value) { - Ok(params) => params, + let (params, canonical_params, operation) = match self.parse_model_params(&mut params_value) + { + Ok(parsed) => parsed, Err(correction) => { return serde_json::to_string(&correction) .map_err(|error| ToolError::ExecutionFailed(error.to_string())) } }; - match params { - TaskUpdateParams::PatchPending { - id, - subject, - description, - active_form, - clear_active_form, - owner_member_id, - clear_owner, - execution_mode, - blocked_by, - metadata, - eligible_member_ids, - required_role, - } => { - if clear_active_form && active_form.is_some() { - return Err(ToolError::InvalidParams( - "patch_pending cannot set and clear active_form together".to_string(), - )); - } - if clear_owner && owner_member_id.is_some() { - return Err(ToolError::InvalidParams( - "patch_pending cannot set and clear owner together".to_string(), - )); - } - validate_freeform_task_metadata(metadata.as_ref()) - .map_err(ToolError::InvalidParams)?; - let actor = self.graph_actor(call_ctx)?; - let run_id = self.ctx.org_context.run_id.clone(); - let prior_id = id.clone(); - let prior = - tokio::task::spawn_blocking(move || AgentOrgTaskStore::get(&run_id, &prior_id)) - .await - .map_err(join_error)? - .map_err(ToolError::ExecutionFailed)? - .ok_or_else(|| ToolError::InvalidParams(format!("task_not_found: {id}")))?; - let owner = owner_member_id - .as_deref() - .map(|owner| self.ctx.resolve_owner_member_id(owner)) - .transpose() - .map_err(ToolError::InvalidParams)?; - reject_coordinator_owner(owner.as_deref())?; - let eligible_member_ids = eligible_member_ids - .map(|ids| self.ctx.resolve_eligible_member_ids(ids)) - .transpose() - .map_err(ToolError::InvalidParams)?; - let metadata = merge_graph_metadata( - prior.metadata.clone(), - metadata, - eligible_member_ids, - required_role, - )?; - let patch = PendingTaskGraphPatch { - subject, - description, - active_form: clear_active_form.then_some(None).or(active_form.map(Some)), - owner: clear_owner.then_some(None).or(owner.map(Some)), - execution_mode: execution_mode - .as_deref() - .map(TaskExecutionMode::from_wire) - .transpose() - .map_err(ToolError::InvalidParams)?, - blocked_by, - metadata: Some(metadata), - }; - let update_context = Arc::clone(&self.ctx); - let run_id = self.ctx.org_context.run_id.clone(); - let expected_updated_at = prior.updated_at; - let (outcome, outbox) = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::patch_pending_with_transactional_effects( - actor, - &run_id, - &id, - &expected_updated_at, - patch, - |tx, outcome, tasks| { - update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) - }, - ) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - self.finish_mutation(outcome, outbox) - } - TaskUpdateParams::Start { id } => { - let actor = self.owner_actor(call_ctx)?; - let update_context = Arc::clone(&self.ctx); - let run_id = self.ctx.org_context.run_id.clone(); - let (outcome, outbox) = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::owner_start_with_transactional_effects( - actor, - &run_id, - &id, - |tx, outcome, tasks| { - update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) - }, - ) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - self.finish_mutation(outcome, outbox) - } - TaskUpdateParams::Complete { id, output } => { - let actor = self.owner_actor(call_ctx)?; - let output = normalize_output(output).map_err(ToolError::InvalidParams)?; - let update_context = Arc::clone(&self.ctx); - let run_id = self.ctx.org_context.run_id.clone(); - let (outcome, outbox) = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::owner_complete_with_transactional_effects( - actor, - &run_id, - &id, - output, - |tx, outcome, tasks| { - update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) - }, - ) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - self.finish_mutation(outcome, outbox) - } - TaskUpdateParams::Fail { id, reason } => { - let actor = self.owner_actor(call_ctx)?; - let reason = normalize_reason(reason).map_err(ToolError::InvalidParams)?; - let update_context = Arc::clone(&self.ctx); - let run_id = self.ctx.org_context.run_id.clone(); - let (outcome, outbox) = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::owner_fail_with_transactional_effects( - actor, - &run_id, - &id, - reason, - |tx, outcome, tasks| { - update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) - }, - ) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - self.finish_mutation(outcome, outbox) - } - TaskUpdateParams::Cancel { id, reason } => { - let actor = self.graph_actor(call_ctx)?; - let reason = normalize_reason(reason).map_err(ToolError::InvalidParams)?; - let prior = self.read_task(&id).await?; - let update_context = Arc::clone(&self.ctx); - let run_id = self.ctx.org_context.run_id.clone(); - let expected_updated_at = prior.updated_at; - let (outcome, outbox) = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::cancel_with_transactional_effects( - actor, - &run_id, - &id, - &expected_updated_at, - reason, - |tx, outcome, tasks| { - update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) - }, - ) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - self.finish_mutation(outcome, outbox) - } - TaskUpdateParams::CancelAndReplace { - id, - reason, - replacement, - } => { - let actor = self.graph_actor(call_ctx)?; - let reason = normalize_reason(reason).map_err(ToolError::InvalidParams)?; - let prior = self.read_task(&id).await?; - let replacement = self.replacement_params(replacement)?; - let update_context = Arc::clone(&self.ctx); - let run_id = self.ctx.org_context.run_id.clone(); - let expected_updated_at = prior.updated_at; - let (outcome, replacement, outbox) = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( - actor, - &run_id, - &id, - &expected_updated_at, - reason, - replacement, - |tx, outcome, replacement, tasks| { - let mut outbox = update_context - .persist_task_update_outbox_in_tx(tx, outcome, tasks)?; - let created = update_context.persist_created_tasks_outbox_in_tx( + let prepared = self.prepare_update(params, call_ctx)?; + let receipt_key = AgentOrgToolReceiptKey::from_call_context( + self.ctx.org_context.run_id.clone(), + call_ctx, + )?; + let run_id = self.ctx.org_context.run_id.clone(); + let context = Arc::clone(&self.ctx); + + let (receipt, committed_outbox, did_mutate) = tokio::task::spawn_blocking(move || { + let mut committed_outbox: Option = None; + let mut did_mutate = false; + let receipt = AgentOrgToolReceiptStore::execute( + receipt_key, + tool_names::TASK_UPDATE, + &operation, + &canonical_params, + |tx| { + let result: Result = match prepared { + PreparedTaskUpdate::Patch { actor, id, patch } => { + AgentOrgTaskStore::patch_pending_in_tx( + tx, + actor, + &run_id, + &id, + patch, + |tx, outcome, tasks| { + context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + .and_then(|(outcome, outbox)| { + let response = mutation_response(&outcome, &outbox)?; + committed_outbox = Some(outbox); + did_mutate = true; + Ok(response) + }) + } + PreparedTaskUpdate::Start { actor, id } => { + AgentOrgTaskStore::owner_start_in_tx( + tx, + actor, + &run_id, + &id, + |tx, outcome, tasks| { + context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + .and_then(|(outcome, outbox)| { + let response = mutation_response(&outcome, &outbox)?; + committed_outbox = Some(outbox); + did_mutate = true; + Ok(response) + }) + } + PreparedTaskUpdate::Complete { actor, id, output } => { + AgentOrgTaskStore::owner_complete_in_tx( + tx, + actor, + &run_id, + &id, + output, + |tx, outcome, tasks| { + context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + .and_then(|(outcome, outbox)| { + let response = mutation_response(&outcome, &outbox)?; + committed_outbox = Some(outbox); + did_mutate = true; + Ok(response) + }) + } + PreparedTaskUpdate::Fail { actor, id, reason } => { + AgentOrgTaskStore::owner_fail_in_tx( tx, - std::slice::from_ref(replacement), - tasks, - )?; - merge_outbox(&mut outbox, created); - Ok(outbox) + actor, + &run_id, + &id, + reason, + |tx, outcome, tasks| { + context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + .and_then(|(outcome, outbox)| { + let response = mutation_response(&outcome, &outbox)?; + committed_outbox = Some(outbox); + did_mutate = true; + Ok(response) + }) + } + PreparedTaskUpdate::Cancel { actor, id, reason } => { + AgentOrgTaskStore::cancel_in_tx( + tx, + actor, + &run_id, + &id, + reason, + |tx, outcome, tasks| { + context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + .and_then(|(outcome, outbox)| { + let response = mutation_response(&outcome, &outbox)?; + committed_outbox = Some(outbox); + did_mutate = true; + Ok(response) + }) + } + PreparedTaskUpdate::CancelAndReplace { + actor, + id, + reason, + mut replacement, + } => { + replacement.id = crate::coordination::agent_org_tasks::new_task_id(); + AgentOrgTaskStore::cancel_and_replace_in_tx( + tx, + actor, + &run_id, + &id, + reason, + replacement, + |tx, outcome, replacement, tasks| { + let mut outbox = context + .persist_task_update_outbox_in_tx(tx, outcome, tasks)?; + let created = context.persist_created_tasks_outbox_in_tx( + tx, + std::slice::from_ref(replacement), + tasks, + )?; + merge_outbox(&mut outbox, created); + Ok(outbox) + }, + ) + .and_then( + |(outcome, replacement, outbox)| { + let response = serde_json::to_string(&json!({ + "task": task_to_json(&outcome.current), + "replacement": task_to_json(&replacement), + "status_changed": true, + "replacement_created": true, + })) + .map_err(|error| error.to_string())?; + committed_outbox = Some(outbox); + did_mutate = true; + Ok(response) + }, + ) + } + PreparedTaskUpdate::OwnerAnnotation { + actor, + id, + kind, + body, + } => AgentOrgTaskStore::append_owner_annotation_in_tx( + tx, actor, &run_id, &id, kind, body, + ) + .and_then(|annotation| { + did_mutate = true; + serde_json::to_string(&json!({ "annotation": annotation })) + .map_err(|error| error.to_string()) + }), + PreparedTaskUpdate::AuditAnnotation { actor, id, body } => { + AgentOrgTaskStore::append_audit_annotation_in_tx( + tx, actor, &run_id, &id, body, + ) + .and_then(|annotation| { + did_mutate = true; + serde_json::to_string(&json!({ "annotation": annotation })) + .map_err(|error| error.to_string()) + }) + } + }; + match result { + Ok(response) => Ok(Ok(response)), + Err(error) => match classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), }, - ) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - self.ctx.wake_committed_task_outbox(&outbox); - serde_json::to_string(&json!({ - "task": task_to_json(&outcome.current), - "replacement": task_to_json(&replacement), - "status_changed": true, - "replacement_created": true, - })) - .map_err(|error| ToolError::ExecutionFailed(error.to_string())) - } - TaskUpdateParams::AppendProgress { id, body } => { - self.append_owner_annotation(call_ctx, id, TaskAnnotationKind::Progress, body) - .await - } - TaskUpdateParams::AppendEvidence { id, body } => { - self.append_owner_annotation(call_ctx, id, TaskAnnotationKind::Evidence, body) - .await - } - TaskUpdateParams::AppendAuditNote { id, body } => { - let actor = self.graph_actor(call_ctx)?; - let run_id = self.ctx.org_context.run_id.clone(); - let annotation = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::append_audit_annotation(actor, &run_id, &id, body) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - serde_json::to_string(&json!({ "annotation": annotation })) - .map_err(|error| ToolError::ExecutionFailed(error.to_string())) + } + }, + )?; + Ok::<_, ToolError>((receipt, committed_outbox, did_mutate)) + }) + .await + .map_err(|error| { + ToolError::ExecutionFailed(format!("task_update worker failed: {error}")) + })??; + + if receipt.is_fresh() && did_mutate { + if let Some(outbox) = committed_outbox.as_ref() { + self.ctx.wake_committed_task_outbox(outbox); } + crate::coordination::agent_org_run_events::notify_agent_org_run_changed( + &self.ctx.org_context.run_id, + ); } + receipt.result } fn is_read_only(&self) -> bool { @@ -751,10 +661,28 @@ impl Tool for TaskUpdateTool { } impl TaskUpdateTool { - fn parse_model_params(&self, params_value: &mut Value) -> Result { + fn allowed_operations(&self) -> Vec<&'static str> { + GRAPH_OPERATIONS + .iter() + .copied() + .filter(|_| self.ctx.is_task_graph_writer()) + .chain( + OWNER_OPERATIONS + .iter() + .copied() + .filter(|_| !self.ctx.is_coordinator()), + ) + .collect() + } + + fn parse_model_params( + &self, + params_value: &mut Value, + ) -> Result<(TaskUpdateParams, Value, String), Value> { + let allowed_operations = self.allowed_operations(); let Some(params) = params_value.as_object_mut() else { return Err(task_update_correction( - self.ctx.is_coordinator(), + &allowed_operations, None, Vec::new(), "task_update parameters must be a JSON object", @@ -766,7 +694,7 @@ impl TaskUpdateTool { .map(str::to_string); let Some(operation_name) = operation.as_deref() else { return Err(task_update_correction( - self.ctx.is_coordinator(), + &allowed_operations, None, Vec::new(), "operation is required and must be a string", @@ -774,41 +702,43 @@ impl TaskUpdateTool { }; let Some(contract) = task_update_operation_contract(operation_name) else { return Err(task_update_correction( - self.ctx.is_coordinator(), + &allowed_operations, Some(operation_name), Vec::new(), "unknown task_update operation", )); }; - let expected_authority = if self.ctx.is_coordinator() { - TaskUpdateAuthority::Coordinator - } else { - TaskUpdateAuthority::Owner - }; - if contract.authority != expected_authority { + if !allowed_operations.contains(&operation_name) { return Err(task_update_correction( - self.ctx.is_coordinator(), + &allowed_operations, Some(operation_name), Vec::new(), - "operation is outside this caller's persisted Task authority", + "operation is outside this caller's frozen Task authority", + )); + } + if contract.authority == TaskUpdateAuthority::Graph && !self.ctx.is_task_graph_writer() { + return Err(task_update_correction( + &allowed_operations, + Some(operation_name), + Vec::new(), + "operation requires graph-writer authority", )); } - // Some providers populate every property in a portable flat schema. - // Normalize only known fields from another operation when their value - // is semantically empty. Fields used by the selected operation remain - // untouched for the typed parser, and unknown or meaningful fields - // stay fail-closed. + // Normalize known cross-operation placeholders before both typed + // parsing and receipt hashing. This preserves provider compatibility + // without allowing an unknown or meaningful field through the wire + // boundary. let keys = params.keys().cloned().collect::>(); let mut unexpected_fields = Vec::new(); for key in keys { if contract.allowed_fields.contains(&key.as_str()) { continue; } - let removable_placeholder = params + if params .get(&key) - .is_some_and(|value| is_removable_cross_operation_placeholder(&key, value)); - if removable_placeholder { + .is_some_and(|value| is_removable_cross_operation_placeholder(&key, value)) + { params.remove(&key); } else { unexpected_fields.push(key); @@ -817,27 +747,164 @@ impl TaskUpdateTool { unexpected_fields.sort(); if !unexpected_fields.is_empty() { return Err(task_update_correction( - self.ctx.is_coordinator(), + &allowed_operations, Some(operation_name), unexpected_fields, "fields from another task_update operation are not accepted", )); } - - parse_params(params_value.take()).map_err(|error| { + let canonical_params = params_value.clone(); + let parsed = parse_params(canonical_params.clone()).map_err(|error| { task_update_correction( - self.ctx.is_coordinator(), + &allowed_operations, Some(operation_name), Vec::new(), &error.to_string(), ) - }) + })?; + Ok((parsed, canonical_params, operation_name.to_string())) + } + + fn prepare_update( + &self, + params: TaskUpdateParams, + call_ctx: &CallContext, + ) -> Result { + match params { + TaskUpdateParams::PatchPending { + id, + subject, + description, + active_form, + clear_active_form, + owner_member_id, + clear_owner, + execution_mode, + blocked_by, + metadata, + eligible_member_ids, + required_role, + } => { + if clear_active_form && active_form.is_some() { + return Err(ToolError::InvalidParams( + "patch_pending cannot set and clear active_form together".to_string(), + )); + } + if clear_owner && owner_member_id.is_some() { + return Err(ToolError::InvalidParams( + "patch_pending cannot set and clear owner together".to_string(), + )); + } + validate_freeform_task_metadata(metadata.as_ref()) + .map_err(ToolError::InvalidParams)?; + let owner = owner_member_id + .as_deref() + .map(|owner| self.ctx.resolve_owner_member_id(owner)) + .transpose() + .map_err(ToolError::InvalidParams)?; + if let Some(owner) = owner.as_ref() { + let denied = self + .ctx + .unauthorized_task_target_member_ids(std::slice::from_ref(owner)); + if !denied.is_empty() { + return Err(ToolError::PermissionDenied(format!( + "Task owner is outside frozen Writer authority: {}", + denied.join(", ") + ))); + } + } + let eligible_member_ids = eligible_member_ids + .map(|ids| self.ctx.resolve_eligible_member_ids(ids)) + .transpose() + .map_err(ToolError::InvalidParams)?; + if let Some(ids) = eligible_member_ids.as_ref() { + let denied = self.ctx.unauthorized_task_target_member_ids(ids); + if !denied.is_empty() { + return Err(ToolError::PermissionDenied(format!( + "Task eligibility is outside frozen Writer authority: {}", + denied.join(", ") + ))); + } + } + Ok(PreparedTaskUpdate::Patch { + actor: self.graph_actor(call_ctx)?, + id, + patch: PendingTaskGraphPatch { + subject, + description, + active_form: clear_active_form.then_some(None).or(active_form.map(Some)), + owner: clear_owner.then_some(None).or(owner.map(Some)), + execution_mode: execution_mode + .as_deref() + .map(TaskExecutionMode::from_wire) + .transpose() + .map_err(ToolError::InvalidParams)?, + blocked_by, + metadata_merge_patch: metadata, + eligible_member_ids, + required_role, + }, + }) + } + TaskUpdateParams::Start { id } => Ok(PreparedTaskUpdate::Start { + actor: self.owner_actor(call_ctx)?, + id, + }), + TaskUpdateParams::Complete { id, output } => Ok(PreparedTaskUpdate::Complete { + actor: self.owner_actor(call_ctx)?, + id, + output: normalize_output(output).map_err(ToolError::InvalidParams)?, + }), + TaskUpdateParams::Fail { id, reason } => Ok(PreparedTaskUpdate::Fail { + actor: self.owner_actor(call_ctx)?, + id, + reason: normalize_reason(reason).map_err(ToolError::InvalidParams)?, + }), + TaskUpdateParams::Cancel { id, reason } => Ok(PreparedTaskUpdate::Cancel { + actor: self.graph_actor(call_ctx)?, + id, + reason: normalize_reason(reason).map_err(ToolError::InvalidParams)?, + }), + TaskUpdateParams::CancelAndReplace { + id, + reason, + replacement, + } => Ok(PreparedTaskUpdate::CancelAndReplace { + actor: self.graph_actor(call_ctx)?, + id, + reason: normalize_reason(reason).map_err(ToolError::InvalidParams)?, + replacement: self.replacement_params(replacement)?, + }), + TaskUpdateParams::AppendProgress { id, body } => { + Ok(PreparedTaskUpdate::OwnerAnnotation { + actor: self.owner_actor(call_ctx)?, + id, + kind: TaskAnnotationKind::Progress, + body, + }) + } + TaskUpdateParams::AppendEvidence { id, body } => { + Ok(PreparedTaskUpdate::OwnerAnnotation { + actor: self.owner_actor(call_ctx)?, + id, + kind: TaskAnnotationKind::Evidence, + body, + }) + } + TaskUpdateParams::AppendAuditNote { id, body } => { + Ok(PreparedTaskUpdate::AuditAnnotation { + actor: self.graph_actor(call_ctx)?, + id, + body, + }) + } + } } fn graph_actor(&self, call_ctx: &CallContext) -> Result { - if !self.ctx.is_coordinator() { - return Err(ToolError::InvalidParams( - "This task_update operation requires the Coordinator graph writer".to_string(), + if !self.ctx.is_task_graph_writer() { + return Err(ToolError::PermissionDenied( + "This task_update operation requires frozen graph-writer authority".to_string(), )); } TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) @@ -846,7 +913,7 @@ impl TaskUpdateTool { fn owner_actor(&self, call_ctx: &CallContext) -> Result { if self.ctx.is_coordinator() { - return Err(ToolError::InvalidParams( + return Err(ToolError::PermissionDenied( "Coordinator cannot execute an Owner lifecycle operation".to_string(), )); } @@ -854,19 +921,6 @@ impl TaskUpdateTool { .map_err(ToolError::InvalidParams) } - async fn read_task( - &self, - id: &str, - ) -> Result { - let run_id = self.ctx.org_context.run_id.clone(); - let task_id = id.to_string(); - tokio::task::spawn_blocking(move || AgentOrgTaskStore::get(&run_id, &task_id)) - .await - .map_err(join_error)? - .map_err(ToolError::ExecutionFailed)? - .ok_or_else(|| ToolError::InvalidParams(format!("task_not_found: {id}"))) - } - fn replacement_params( &self, replacement: ReplacementTaskParams, @@ -879,7 +933,17 @@ impl TaskUpdateTool { .map(|owner| self.ctx.resolve_owner_member_id(owner)) .transpose() .map_err(ToolError::InvalidParams)?; - reject_coordinator_owner(owner.as_deref())?; + if let Some(owner) = owner.as_ref() { + let denied = self + .ctx + .unauthorized_task_target_member_ids(std::slice::from_ref(owner)); + if !denied.is_empty() { + return Err(ToolError::PermissionDenied(format!( + "Replacement owner is outside frozen Writer authority: {}", + denied.join(", ") + ))); + } + } let eligible_member_ids = replacement .eligible_member_ids .map(|ids| self.ctx.resolve_eligible_member_ids(ids)) @@ -890,17 +954,9 @@ impl TaskUpdateTool { "ownerless replacement requires eligible_member_ids".to_string(), )); } - let metadata = merge_graph_metadata( - None, - replacement.metadata, - eligible_member_ids, - replacement.required_role, - )?; Ok(CreatePendingTaskParams { - id: replacement - .id - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(crate::coordination::agent_org_tasks::new_task_id), + // Minted only after receipt lookup in the transaction closure. + id: String::new(), org_run_id: self.ctx.org_context.run_id.clone(), subject: replacement.subject, description: replacement.description.unwrap_or_default(), @@ -909,49 +965,15 @@ impl TaskUpdateTool { execution_mode: TaskExecutionMode::from_wire(&replacement.execution_mode) .map_err(ToolError::InvalidParams)?, blocked_by: replacement.blocked_by, - metadata, + metadata: merge_task_metadata( + replacement.metadata, + eligible_member_ids, + replacement.required_role, + ), originating_message_id: None, replaces_task_id: None, }) } - - async fn append_owner_annotation( - &self, - call_ctx: &CallContext, - id: String, - kind: TaskAnnotationKind, - body: String, - ) -> Result { - let actor = self.owner_actor(call_ctx)?; - let run_id = self.ctx.org_context.run_id.clone(); - let annotation = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::append_owner_annotation(actor, &run_id, &id, kind, body) - }) - .await - .map_err(join_error)? - .map_err(map_task_write_error)?; - serde_json::to_string(&json!({ "annotation": annotation })) - .map_err(|error| ToolError::ExecutionFailed(error.to_string())) - } - - fn finish_mutation( - &self, - outcome: crate::coordination::agent_org_tasks::TaskMutationOutcome, - outbox: TaskOutboxCommit, - ) -> Result { - self.ctx.wake_committed_task_outbox(&outbox); - serde_json::to_string(&json!({ - "task": task_to_json(&outcome.current), - "owner_changed": outcome.owner_changed, - "status_changed": outcome.status_changed, - "task_assigned_dispatched": outbox.task_assigned_ids.contains(&outcome.current.id), - "unblocked_task_assigned_ids": outbox.unblocked_task_assigned_ids, - "assignment_required_task_ids": outbox.assignment_required_task_ids, - "task_completed_notified": outbox.task_completed_notified, - "remaining_open_task_count": outbox.remaining_open_task_count, - })) - .map_err(|error| ToolError::ExecutionFailed(error.to_string())) - } } fn normalize_output(output: TaskOutputParams) -> Result { @@ -989,55 +1011,21 @@ fn normalize_reason(reason: TaskReasonParams) -> Result, - patch: Option, - eligible_member_ids: Option>, - required_role: Option, -) -> Result, ToolError> { - let mut object = match existing { - Some(Value::Object(object)) => object, - Some(_) => { - return Err(ToolError::ExecutionFailed( - "persisted task metadata is not an object".to_string(), - )) - } - None => Map::new(), - }; - if let Some(patch) = patch { - let patch = patch.as_object().ok_or_else(|| { - ToolError::InvalidParams("metadata patch must be an object".to_string()) - })?; - for (key, value) in patch { - if value.is_null() { - object.remove(key); - } else { - object.insert(key.clone(), value.clone()); - } - } - } - if let Some(ids) = eligible_member_ids { - object.insert("eligible_member_ids".to_string(), json!(ids)); - } - if let Some(role) = required_role { - let role = role.trim(); - if role.is_empty() { - object.remove("required_role"); - } else { - object.insert("required_role".to_string(), Value::String(role.to_string())); - } - } - Ok((!object.is_empty()).then_some(Value::Object(object))) -} - -fn reject_coordinator_owner(owner: Option<&str>) -> Result<(), ToolError> { - if owner == Some(crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID) { - Err(ToolError::InvalidParams( - "Coordinator cannot be a formal Task Owner".to_string(), - )) - } else { - Ok(()) - } +fn mutation_response( + outcome: &crate::coordination::agent_org_tasks::TaskMutationOutcome, + outbox: &TaskOutboxCommit, +) -> Result { + serde_json::to_string(&json!({ + "task": task_to_json(&outcome.current), + "owner_changed": outcome.owner_changed, + "status_changed": outcome.status_changed, + "task_assigned_dispatched": outbox.task_assigned_ids.contains(&outcome.current.id), + "unblocked_task_assigned_ids": outbox.unblocked_task_assigned_ids, + "assignment_required_task_ids": outbox.assignment_required_task_ids, + "task_completed_notified": outbox.task_completed_notified, + "remaining_open_task_count": outbox.remaining_open_task_count, + })) + .map_err(|error| error.to_string()) } fn merge_outbox(target: &mut TaskOutboxCommit, incoming: TaskOutboxCommit) { @@ -1058,7 +1046,3 @@ fn merge_outbox(target: &mut TaskOutboxCommit, incoming: TaskOutboxCommit) { target.wake_member_ids.sort(); target.wake_member_ids.dedup(); } - -fn join_error(error: tokio::task::JoinError) -> ToolError { - ToolError::ExecutionFailed(format!("task_update worker failed: {error}")) -} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs index b4ff59dd2..431f922ad 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs @@ -36,6 +36,7 @@ use crate::coordination::agent_org_tasks::{ TASK_DEPENDENCY_LIMIT_ERROR, TASK_METADATA_ELIGIBLE_MEMBER_IDS, TASK_METADATA_REQUIRED_ROLE, TASK_MUTATION_CONFLICT_ERROR, TASK_RUN_TASK_LIMIT_ERROR, }; +use crate::coordination::agent_org_tool_receipts::AgentOrgToolReceiptAbort; use crate::tools::impls::orchestration::org_send_message::InboxWakeHook; use crate::tools::traits::ToolError; @@ -144,9 +145,19 @@ impl TaskToolsContext { self.caller_member_id == COORDINATOR_MEMBER_ID } + pub(crate) fn is_task_graph_writer(&self) -> bool { + self.is_coordinator() + || self + .org_context + .capability_index + .is_additional_writer(&self.caller_member_id) + } + pub(crate) fn task_authority_summary(&self) -> &'static str { if self.is_coordinator() { "coordinator: may create, assign, reassign, edit, and repair tasks for every participant, but may not impersonate another owner by setting that member's in_progress/completed lifecycle or writing that member's output" + } else if self.is_task_graph_writer() { + "writer task owner: during the exact persisted TaskExecution turn, may manage graph fields for every participant and may execute only the lifecycle of the Task bound to this turn" } else { "worker: may only start, annotate, complete, or fail the exact Task bound to its persisted TaskExecution turn" } @@ -522,6 +533,12 @@ pub(crate) fn map_task_write_error(err: String) -> ToolError { || err.starts_with(TASK_DELETE_IS_DELIVERY_REPLACEMENT_ERROR) || err.starts_with(TASK_DEPENDENCY_LIMIT_ERROR) || err.starts_with(TASK_RUN_TASK_LIMIT_ERROR) + || err.starts_with("task_not_found") + || err.starts_with("task_graph_edit_requires_pending") + || err.starts_with("task_owner_") + || err.starts_with("task_dependencies_not_completed") + || err.starts_with("Owner annotations require") + || err.starts_with("audit_note is available") { ToolError::InvalidParams(err) } else { @@ -529,6 +546,41 @@ pub(crate) fn map_task_write_error(err: String) -> ToolError { } } +pub(crate) fn classify_task_receipt_error( + error: String, +) -> Result { + if [ + "agent_org_run_not_mutable", + "team_archived", + "agent_org_run_not_found", + "agent_org_idle_activation_", + "team_paused_resume_required", + "task_actor_", + "task_graph_writer_", + "task_owner_context_", + ] + .iter() + .any(|prefix| error.starts_with(prefix)) + { + return Err(AgentOrgToolReceiptAbort::rejected(map_task_write_error( + error, + ))); + } + if [ + "database is locked", + "database disk image is malformed", + "disk I/O error", + "no such table", + "FOREIGN KEY constraint failed", + ] + .iter() + .any(|fragment| error.contains(fragment)) + { + return Err(AgentOrgToolReceiptAbort::storage(error)); + } + Ok(map_task_write_error(error)) +} + pub(crate) fn task_to_json(task: &Task) -> Value { let required_role = task .metadata diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs b/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs index bf30d4eb9..4926c1f7d 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs @@ -37,6 +37,9 @@ use crate::coordination::agent_org_runs::{AgentOrgRunContext, COORDINATOR_MEMBER use crate::coordination::agent_org_tasks::{ task_execution_mode, AgentOrgTaskStore, Task, TaskExecutionMode, TaskStatus, }; +use crate::coordination::agent_org_tool_receipts::{ + AgentOrgToolReceiptKey, AgentOrgToolReceiptStore, +}; use crate::definitions::orgs::PlanApprovalPolicy; use crate::interaction::plan_approval::PlanApprovalManager; use crate::session::plan_mode::{ @@ -239,6 +242,7 @@ impl Tool for CreatePlanTool { params: Value, ctx: &crate::tools::traits::CallContext, ) -> Result { + let canonical_params = params.clone(); // Per-call tool_call_id flows through `CallContext` (constructed // by `tool_execution` dispatch sites). Empty when a direct // in-process caller forgot to populate ctx. @@ -359,33 +363,6 @@ impl Tool for CreatePlanTool { ))); } let agent_id = record.agent_definition_id.as_deref().unwrap_or("default"); - let org_plan_source_task = match ( - self.context.agent_org_context.as_ref(), - self.context.agent_org_current_member_id.as_deref(), - ) { - (Some(org_context), Some(member_id)) if member_id != COORDINATOR_MEMBER_ID => { - let org_context = org_context.clone(); - let member_id = member_id.to_string(); - Some( - tokio::task::spawn_blocking(move || { - resolve_source_plan_task( - &org_context, - &member_id, - requested_source_task_id.as_deref(), - ) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "create_plan: source-task worker failed: {err}" - )) - })? - .map_err(ToolError::InvalidParams)?, - ) - } - _ => None, - }; - // Decide whether to update the current pending approval slot or rotate it. let slot = if let Some(pending) = pending_plan.as_ref() { let slot = PlanSlot { @@ -494,82 +471,23 @@ impl Tool for CreatePlanTool { "create_plan: runtime member_id '{sender_member_id}' is not in Agent Org roster" )) })?; - let request_id = RequestId::new(); - let source_task = org_plan_source_task.as_ref().ok_or_else(|| { - ToolError::ExecutionFailed( - "create_plan: Agent Org planning task binding disappeared".to_string(), - ) - })?; let root_session_id = org_ctx.root_session_id.clone().ok_or_else(|| { ToolError::ExecutionFailed( "create_plan: Agent Org run has no root session".to_string(), ) })?; - let approval_params = CreateAgentOrgPlanApprovalParams { - request_id: request_id.as_str().to_string(), - org_run_id: org_ctx.run_id.clone(), - source_task_id: source_task.id.clone(), - source_member_id: sender_member_id.to_string(), - source_session_id: session_id.clone(), - source_turn_intent_id: ctx.turn_intent_id.clone(), - root_session_id, - policy: org_ctx.plan_approval_policy, - plan_title: slot.title.clone(), - plan_path: slot.resolved_path.to_string_lossy().into_owned(), - plan_content: content.clone(), - }; - let policy = org_ctx.plan_approval_policy; let coordinator_agent_id = org_ctx.coordinator_agent_id.clone(); let sender_member_id = sender_member_id.to_string(); - let wake_member_ids = tokio::task::spawn_blocking(move || { - match policy { - PlanApprovalPolicy::Coordinator => { - AgentOrgPlanApprovalStore::create_pending_with_request( - approval_params, - AgentOrgPlanInboxDelivery { - recipient_agent_id: coordinator_agent_id, - sender_agent_id, - sender_member_id: Some(sender_member_id), - }, - )?; - Ok(vec![COORDINATOR_MEMBER_ID.to_string()]) - } - PlanApprovalPolicy::User => { - AgentOrgPlanApprovalStore::create_pending(approval_params)?; - // The pending row is projected into the Group chat - // Run View. No model wake is needed while the user - // is the designated approver. - Ok(Vec::new()) - } - PlanApprovalPolicy::Automatic => { - let approved = AgentOrgPlanApprovalStore::create_and_approve_automatic( - approval_params, - )?; - Ok(approved.wake_member_ids) - } - } - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "create_plan: approval worker failed: {err}" - )) - })? - .map_err(|err: String| { - ToolError::ExecutionFailed(format!( - "create_plan: failed to persist Agent Org plan approval: {err}" - )) - })?; - if let Some(app_handle) = self.context.app_handle.clone() { - let wake_hook = AppHandleInboxWakeHook::new(app_handle); - for member_id in wake_member_ids { - wake_hook.wake_member(&member_id, &org_ctx.run_id); - } - } - + let run_id = org_ctx.run_id.clone(); + let receipt_key = AgentOrgToolReceiptKey::from_call_context(run_id.clone(), ctx)?; + let source_session_id = session_id.clone(); + let source_turn_intent_id = ctx.turn_intent_id.clone(); + let plan_title = slot.title.clone(); + let plan_path = slot.resolved_path.to_string_lossy().into_owned(); + let plan_content = content.clone(); let result = CreatePlanResult { - path: slot.resolved_path.to_string_lossy().into_owned(), + path: plan_path.clone(), slug: slot.slug.clone(), hash: slot.hash.clone(), bytes_written: content.len(), @@ -581,7 +499,109 @@ impl Tool for CreatePlanTool { "create_plan: failed to serialize success payload: {err}" )) })?; - return Ok(format!("{PLAN_SUBMITTED_END_TURN_PREFIX}{body}")); + let result_text = format!("{PLAN_SUBMITTED_END_TURN_PREFIX}{body}"); + let (receipt, wake_member_ids) = tokio::task::spawn_blocking(move || { + let _artifact_guard = crate::coordination::agent_org_plan_approvals::artifact::plan_artifact_install_lock().lock(); + let mut wake_member_ids = Vec::new(); + let receipt = AgentOrgToolReceiptStore::execute( + receipt_key, + tool_names::CREATE_PLAN, + "agent_org_submit", + &canonical_params, + |tx| { + let source_task = match resolve_source_plan_task_with_connection( + tx, + &run_id, + &sender_member_id, + requested_source_task_id.as_deref(), + ) { + Ok(task) => task, + Err(error) => { + return Ok(Err(ToolError::InvalidParams(error))); + } + }; + let approval_params = CreateAgentOrgPlanApprovalParams { + request_id: RequestId::new().as_str().to_string(), + org_run_id: run_id.clone(), + source_task_id: source_task.id, + source_member_id: sender_member_id.clone(), + source_session_id: source_session_id.clone(), + source_turn_intent_id: source_turn_intent_id.clone(), + root_session_id: root_session_id.clone(), + policy, + plan_title: plan_title.clone(), + plan_path: plan_path.clone(), + plan_content: plan_content.clone(), + }; + let delivery = (policy == PlanApprovalPolicy::Coordinator).then(|| { + AgentOrgPlanInboxDelivery { + recipient_agent_id: coordinator_agent_id.clone(), + sender_agent_id: sender_agent_id.clone(), + sender_member_id: Some(sender_member_id.clone()), + } + }); + match AgentOrgPlanApprovalStore::submit_agent_org_plan_in_tx( + tx, + approval_params, + delivery, + ) { + Ok(wakes) => wake_member_ids = wakes, + Err(error) => { + return match crate::tools::impls::orchestration::agent_org::tasks::classify_task_receipt_error(error) { + Ok(error) => Ok(Err(error)), + Err(abort) => Err(abort), + }; + } + } + Ok(Ok(result_text.clone())) + }, + )?; + if receipt.is_fresh() && receipt.result.is_ok() { + match database::db::get_connection() + .map_err(|error| error.to_string()) + .and_then(|conn| { + crate::coordination::agent_org_plan_approvals::artifact::stage_plan_artifact_with_connection( + &conn, + &source_session_id, + &plan_path, + &plan_content, + ) + }) + .and_then(|staged| { + crate::coordination::agent_org_plan_approvals::artifact::install_staged_plan_artifact(Some(&staged)) + }) + { + Ok(()) => {} + Err(error) => tracing::warn!( + org_run_id = %run_id, + plan_path, + error = %error, + "Agent Org plan receipt committed but its derived artifact needs repair" + ), + } + } + Ok::<_, ToolError>((receipt, wake_member_ids)) + }) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!( + "create_plan: approval worker failed: {err}" + )) + })??; + if receipt.is_fresh() { + crate::coordination::agent_org_run_events::notify_agent_org_run_changed( + &org_ctx.run_id, + ); + } + if receipt.is_fresh() { + if let Some(app_handle) = self.context.app_handle.clone() { + let wake_hook = AppHandleInboxWakeHook::new(app_handle); + for member_id in wake_member_ids { + wake_hook.wake_member(&member_id, &org_ctx.run_id); + } + } + } + return receipt.result; } } @@ -632,12 +652,13 @@ impl Tool for CreatePlanTool { } } -fn resolve_source_plan_task( - org_context: &AgentOrgRunContext, +fn resolve_source_plan_task_with_connection( + conn: &rusqlite::Connection, + org_run_id: &str, member_id: &str, requested_task_id: Option<&str>, ) -> Result { - let tasks = AgentOrgTaskStore::list(&org_context.run_id)?; + let tasks = AgentOrgTaskStore::list_with_connection(conn, org_run_id)?; let mut candidates = tasks .into_iter() .filter(|task| { @@ -791,4 +812,193 @@ mod tests { fn sentinel_prefix_is_stable() { assert_eq!(PLAN_SUBMITTED_END_TURN_PREFIX, "PLAN_SUBMITTED_END_TURN:"); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn agent_org_plan_submission_replays_without_duplicate_approval_or_artifact() { + let sandbox = test_helpers::test_env::sandbox(); + let conn = database::db::get_connection().expect("test sqlite"); + crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); + crate::session::persistence::init(&conn).expect("session schema"); + conn.execute_batch( + "CREATE TABLE code_sessions ( + session_id TEXT PRIMARY KEY, + cli_agent_type TEXT NOT NULL, + status TEXT NOT NULL, + parent_session_id TEXT, + org_member_id TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) + );", + ) + .expect("base Turn schema"); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); + let now = chrono::Utc::now().to_rfc3339(); + let snapshot = crate::definitions::orgs::AgentOrgLaunchSnapshot { + schema_version: 1, + org_id: "plan-org".into(), + org_name: "Plan Org".into(), + coordinator_role: "lead".into(), + coordinator_agent_id: "plan-coordinator-agent".into(), + plan_approval_policy: PlanApprovalPolicy::Coordinator, + members: vec![crate::definitions::orgs::FlatOrgMember { + member_id: "planner".into(), + name: "Planner".into(), + role: "planner".into(), + agent_id: "planner-agent".into(), + runtime_config: None, + }], + additional_task_graph_writer_member_ids: Vec::new(), + member_communication_links: Vec::new(), + }; + conn.execute( + "INSERT INTO agent_org_runtime_runs ( + id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, + entry_mode,status,activation_generation,created_at,updated_at + ) VALUES ('plan-run','plan-org','plan-coordinator-agent','plan-root',?1, + 'standalone_session','running',1,?2,?2)", + rusqlite::params![serde_json::to_string(&snapshot).unwrap(), &now], + ) + .unwrap(); + let workspace = sandbox.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + for (session_id, member_id, agent_id, parent_session_id) in [ + ( + "plan-root", + COORDINATOR_MEMBER_ID, + "plan-coordinator-agent", + None, + ), + ( + "planner-session", + "planner", + "planner-agent", + Some("plan-root"), + ), + ] { + crate::session::persistence::upsert_session( + &crate::session::persistence::UnifiedSessionRecord { + session_id: session_id.into(), + name: member_id.into(), + status: "running".into(), + created_at: now.clone(), + updated_at: now.clone(), + session_type: "sde".into(), + workspace_path: Some(workspace.to_string_lossy().into_owned()), + org_member_id: Some(member_id.into()), + agent_definition_id: Some(agent_id.into()), + parent_session_id: parent_session_id.map(str::to_string), + ..Default::default() + }, + ) + .unwrap(); + } + let task_id = crate::coordination::agent_org_tasks::new_task_id(); + crate::coordination::agent_org_tasks::AgentOrgTaskStore::create( + crate::coordination::agent_org_tasks::CreateTaskParams { + id: task_id.clone(), + org_run_id: "plan-run".into(), + subject: "Write the implementation plan".into(), + description: String::new(), + active_form: None, + owner: Some("planner".into()), + status: TaskStatus::InProgress, + blocks: Vec::new(), + blocked_by: Vec::new(), + metadata: Some(serde_json::json!({ + crate::coordination::agent_org_tasks::TASK_METADATA_EXECUTION_MODE: "plan", + crate::coordination::agent_org_tasks::TASK_METADATA_ELIGIBLE_MEMBER_IDS: ["planner"] + })), + }, + ) + .unwrap(); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES ('planner-session','planner-turn','plan-run','agent_org','running',?1,?1)", + [&now], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts ( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + task_id,owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,activation_generation,created_at + ) VALUES ('planner-session','planner-turn','plan-run','planner','task_execution', + ?1,'planner','planner',1,'task',?1,1,?2)", + rusqlite::params![&task_id, &now], + ) + .unwrap(); + let org_context = AgentOrgRunContext { + run_id: "plan-run".into(), + org_id: "plan-org".into(), + org_name: "Plan Org".into(), + org_role: "lead".into(), + coordinator_agent_id: "plan-coordinator-agent".into(), + coordinator_name: "Coordinator".into(), + coordinator_role: "lead".into(), + members: vec![crate::coordination::agent_org_runs::AgentOrgContextMember { + member_id: "planner".into(), + name: "Planner".into(), + role: "planner".into(), + agent_id: "planner-agent".into(), + }], + plan_approval_policy: PlanApprovalPolicy::Coordinator, + capability_index: crate::definitions::orgs::AgentOrgCapabilityIndex::from_snapshot( + &snapshot, + ), + root_session_id: Some("plan-root".into()), + }; + let tool = CreatePlanTool::new(Arc::new(CreatePlanToolContext::new( + PlanSlotCache::new(), + None, + Some(org_context), + Some("planner".into()), + None, + ))); + let call = crate::tools::traits::CallContext::for_turn( + "create-plan-call", + "planner-session", + "planner-turn", + Vec::new(), + ); + let request = serde_json::json!({ + "title": "Small implementation plan", + "content": "# Plan\n\n1. Implement.\n2. Verify.", + "source_task_id": task_id + }); + let first = tool + .execute_text(request.clone(), &call) + .await + .expect("Agent Org plan submission"); + let replay = tool + .execute_text(request, &call) + .await + .expect("same create_plan call replays"); + assert_eq!(replay, first); + let approvals = AgentOrgPlanApprovalStore::list_pending_by_run("plan-run").unwrap(); + assert_eq!(approvals.len(), 1); + assert_eq!( + std::fs::read_to_string(&approvals[0].plan_path).unwrap(), + "# Plan\n\n1. Implement.\n2. Verify." + ); + let receipt_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_org_runtime_tool_call_receipts + WHERE org_run_id='plan-run' AND tool_name='create_plan'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(receipt_count, 1); + } } diff --git a/src-tauri/crates/agent-core/src/lifecycle.rs b/src-tauri/crates/agent-core/src/lifecycle.rs index 7fa82121e..ce476e71a 100644 --- a/src-tauri/crates/agent-core/src/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/lifecycle.rs @@ -390,6 +390,27 @@ pub fn finalize_agent_org_member_turn( } if response.is_ok() { + if snapshot.member_id != crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID + { + match crate::coordination::agent_inbox::AgentInboxStore::resolve_obsolete_formal_rows_after_successful_member_turn( + &snapshot.context.run_id, + &snapshot.member_id, + ) { + Ok(resolved) if resolved > 0 => tracing::info!( + run_id = %snapshot.context.run_id, + member_id = %snapshot.member_id, + resolved, + "[lifecycle] resolved obsolete formal Inbox rows after Member Turn ended without owned work" + ), + Ok(_) => {} + Err(err) => tracing::warn!( + run_id = %snapshot.context.run_id, + member_id = %snapshot.member_id, + error = %err, + "[lifecycle] failed to resolve obsolete formal Inbox rows; keeping them unread" + ), + } + } if let Err(err) = crate::coordination::agent_org_watchdog::clear_rewake_budget( &snapshot.context.run_id, &snapshot.member_id, @@ -1223,6 +1244,128 @@ mod tests { assert_eq!(task_recovery_attempts, 2); } + #[test] + fn successful_cancelled_turn_resolves_undrainable_formal_rows_once() { + let _serial = test_serial_guard(); + let _sandbox = test_helpers::test_env::sandbox(); + let run_id = seed_run("builtin:sde"); + seed_in_progress_task(&run_id, "cancelled-task"); + let conn = database::db::get_connection().expect("test sqlite connection"); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + r#"UPDATE agent_org_runtime_tasks + SET status='cancelled', + cancel_reason_json='{"code":"writer_cancelled","message":"replaced"}', + updated_at=?3 + WHERE org_run_id=?1 AND id=?2"#, + rusqlite::params![&run_id, "cancelled-task", &now], + ) + .expect("cancel exact Task while its old Turn winds down"); + + let insert = |message| { + crate::coordination::agent_inbox::AgentInboxStore::insert( + crate::coordination::agent_inbox::InsertInboxParams { + recipient_agent_id: "builtin:sde".to_string(), + recipient_member_id: Some("member-worker".to_string()), + sender_agent_id: "builtin:coord".to_string(), + sender_member_id: Some("coordinator".to_string()), + org_run_id: Some(run_id.clone()), + message, + }, + ) + .expect("insert formal row queued behind old Turn"); + }; + insert(AgentMessage::TaskAssigned { + task_id: "cancelled-task".to_string(), + subject: "cancelled task".to_string(), + description: "old assignment".to_string(), + assigned_by: "Coordinator".to_string(), + execution_mode: crate::coordination::agent_org_tasks::TaskExecutionMode::Build, + dependency_outputs: Vec::new(), + }); + insert(AgentMessage::Plain { + summary: "stale reminder".to_string(), + text: "finish the cancelled task".to_string(), + }); + insert(AgentMessage::ShutdownRequest { + request_id: crate::coordination::agent_inbox::RequestId::new(), + reason: Some("no work remains".to_string()), + }); + + let ok = Ok("the old Provider Turn ended naturally".to_string()); + finalize_agent_org_member_turn(None, "member-session", None, &ok); + finalize_agent_org_member_turn(None, "member-session", None, &ok); + + let (unread_rows, resolutions): (i64, i64) = conn + .query_row( + "SELECT + (SELECT COUNT(*) FROM agent_org_runtime_inbox + WHERE org_run_id=?1 AND recipient_member_id='member-worker' + AND read_at IS NULL), + (SELECT COUNT(*) + FROM agent_org_runtime_inbox_delivery_resolutions + WHERE org_run_id=?1 + AND reason='member_turn_finished_without_owned_formal_work')", + [&run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("load preserved audit rows and lifecycle resolutions"); + assert_eq!( + unread_rows, 3, + "original Inbox rows remain unread for audit" + ); + assert_eq!( + resolutions, 3, + "repeat finalization must not duplicate resolutions" + ); + assert!( + !crate::coordination::agent_inbox::AgentInboxStore::has_unread_for_member( + "member-worker", + &run_id, + ) + .expect("probe unresolved rows"), + "resolved stale formal input must not trigger an impossible wake" + ); + } + + #[test] + fn successful_turn_keeps_formal_rows_pending_when_member_still_owns_work() { + let _serial = test_serial_guard(); + let _sandbox = test_helpers::test_env::sandbox(); + let run_id = seed_run("builtin:sde"); + seed_in_progress_task(&run_id, "still-open-task"); + crate::coordination::agent_inbox::AgentInboxStore::insert( + crate::coordination::agent_inbox::InsertInboxParams { + recipient_agent_id: "builtin:sde".to_string(), + recipient_member_id: Some("member-worker".to_string()), + sender_agent_id: "builtin:coord".to_string(), + sender_member_id: Some("coordinator".to_string()), + org_run_id: Some(run_id.clone()), + message: AgentMessage::Plain { + summary: "continue".to_string(), + text: "the owned task is still open".to_string(), + }, + }, + ) + .expect("insert actionable formal row"); + + finalize_agent_org_member_turn( + None, + "member-session", + None, + &Ok("turn boundary".to_string()), + ); + + assert!( + crate::coordination::agent_inbox::AgentInboxStore::has_unread_for_member( + "member-worker", + &run_id, + ) + .expect("probe actionable row"), + "lifecycle cleanup must not discard input while owned work remains" + ); + } + #[tokio::test(flavor = "multi_thread")] async fn failed_member_finalize_releases_task_for_coordinator_assignment() { let _serial = test_serial_guard(); 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 3a85d27b0..3ef224e13 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 @@ -94,39 +94,38 @@ pub(super) fn promote_agent_org_wake_session_to_running( .map_err(|error| error.to_string()) } -/// Promote a direct Rust Agent Org turn only while its Team is still Running. -/// Submit preflight is only a snapshot: a queued turn must re-check the -/// durable lifecycle fence immediately before execution so Starting, Idle, -/// Paused, Failed, or Archived can never start a Provider turn. +/// Promote a direct Rust Agent Org turn while its Team is Running, or while +/// the canonical Root is answering a user message from Idle. The latter keeps +/// the Team itself Idle until a formal writer commits new work; Q&A-only turns +/// therefore remain side-effect free at the Team lifecycle boundary. +/// +/// Submit preflight is only a snapshot, so this claim re-checks both Run state +/// and Root identity atomically immediately before Provider execution. 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_runtime_runs WHERE id=?1", - [run_id], - |row| row.get::<_, String>(0), - ) - .optional() - .map_err(|error| error.to_string())?; - if run_status.as_deref() - != Some(crate::coordination::agent_org_runs::AgentOrgRunStatus::Running.as_str()) - { - 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_runtime_runs run + WHERE run.id=?4 + AND ( + run.status=?5 + OR (run.status=?6 AND run.root_session_id=?3) + ) + )", rusqlite::params![ crate::session::SessionStatus::Running.as_str(), chrono::Utc::now().to_rfc3339(), session_id, + run_id, + crate::coordination::agent_org_runs::AgentOrgRunStatus::Running.as_str(), + crate::coordination::agent_org_runs::AgentOrgRunStatus::Idle.as_str(), ], ) .map_err(|error| error.to_string()) 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 437f81c77..63237f7e2 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 @@ -30,6 +30,7 @@ use super::org_wake::{ pub(super) fn ensure_agent_org_turn_is_runnable( run_id: &str, status: crate::coordination::agent_org_runs::AgentOrgRunStatus, + allow_idle_root: bool, ) -> Result<(), String> { use crate::coordination::agent_org_runs::AgentOrgRunStatus; @@ -41,8 +42,9 @@ pub(super) fn ensure_agent_org_turn_is_runnable( AgentOrgRunStatus::Paused => Err(format!( "team_paused: Agent Org run {run_id} cannot start a turn in this lifecycle slice" )), + AgentOrgRunStatus::Idle if allow_idle_root => Ok(()), AgentOrgRunStatus::Idle => Err(format!( - "team_idle: Agent Org run {run_id} has no formal activation for a new turn" + "team_idle: Agent Org run {run_id} accepts new turns only in its canonical Root session" )), AgentOrgRunStatus::Failed => Err(format!( "team_unavailable: Agent Org run {run_id} failed during materialization" @@ -88,13 +90,14 @@ async fn preflight_agent_org_turn_before_runtime( crate::coordination::agent_org_runs::require_agent_org_redesign()?; let status_run_id = run_id.clone(); - let status = tokio::task::spawn_blocking(move || { - crate::coordination::agent_org_runs::AgentOrgRunStore::get_run_status(&status_run_id) + let run = tokio::task::spawn_blocking(move || { + crate::coordination::agent_org_runs::AgentOrgRunStore::load(&status_run_id) }) .await .map_err(|error| format!("Agent Org status worker failed: {error}"))?? .ok_or_else(|| format!("team_unavailable: Agent Org run {run_id} does not exist"))?; - ensure_agent_org_turn_is_runnable(&run_id, status)?; + let allow_idle_root = run.root_session_id.as_deref() == Some(session_id); + ensure_agent_org_turn_is_runnable(&run_id, run.status, allow_idle_root)?; Ok(Some(run_id)) } 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 9904d7a99..e2c99e570 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 @@ -317,7 +317,7 @@ fn queued_agent_org_wake_rechecks_run_member_and_intervention_at_turn_start() { } #[test] -fn direct_agent_org_turn_only_promotes_while_run_is_running() { +fn direct_agent_org_turn_allows_running_or_canonical_root_idle() { let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); let conn = database::db::get_connection().expect("test db"); for status in [ @@ -361,6 +361,30 @@ fn direct_agent_org_turn_only_promotes_while_run_is_running() { assert_eq!(session_status, "idle"); } + conn.execute( + "UPDATE agent_org_runtime_runs + SET status=?1,archived_at=NULL,archive_receipt_id=NULL WHERE id=?2", + rusqlite::params![AgentOrgRunStatus::Idle.as_str(), &fixture.run_id], + ) + .expect("set idle run"); + assert_eq!( + promote_agent_org_direct_session_to_running(&conn, &fixture.run_id, "root-session") + .expect("Idle canonical Root can start a Provider turn"), + 1 + ); + let run_status = conn + .query_row( + "SELECT status FROM agent_org_runtime_runs WHERE id=?1", + [&fixture.run_id], + |row| row.get::<_, String>(0), + ) + .expect("load idle run status"); + assert_eq!( + run_status, + AgentOrgRunStatus::Idle.as_str(), + "Root Q&A admission must not activate formal Team work" + ); + conn.execute( "UPDATE agent_org_runtime_runs SET status=?1,archived_at=NULL,archive_receipt_id=NULL WHERE id=?2", @@ -375,8 +399,9 @@ fn direct_agent_org_turn_only_promotes_while_run_is_running() { } #[test] -fn provider_preflight_exhaustively_rejects_every_non_running_team_status() { - assert!(ensure_agent_org_turn_is_runnable("run", AgentOrgRunStatus::Running).is_ok()); +fn provider_preflight_allows_only_running_or_canonical_root_idle_turns() { + assert!(ensure_agent_org_turn_is_runnable("run", AgentOrgRunStatus::Running, false).is_ok()); + assert!(ensure_agent_org_turn_is_runnable("run", AgentOrgRunStatus::Idle, true).is_ok()); for (status, code) in [ (AgentOrgRunStatus::Starting, "team_not_ready"), @@ -385,7 +410,7 @@ fn provider_preflight_exhaustively_rejects_every_non_running_team_status() { (AgentOrgRunStatus::Failed, "team_unavailable"), (AgentOrgRunStatus::Archived, "team_archived"), ] { - let error = ensure_agent_org_turn_is_runnable("run", status) + let error = ensure_agent_org_turn_is_runnable("run", status, false) .expect_err("non-running Team cannot initialize a turn"); assert!( error.starts_with(code),