use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use starweaver_core::{RunId, SessionId, SubagentAttemptId, TaskId, TraceContext};
use crate::{AcquireRunAdmission, InputPart, RunAdmissionReceipt, RunRecord, RunStatus};
pub const BACKGROUND_SUBAGENT_RECORD_VERSION: u32 = 1;
pub const DEFAULT_BACKGROUND_RESULT_RETENTION_SECS: i64 = 86_400;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BackgroundSubagentArtifact {
pub artifact_ref: String,
pub namespace_id: String,
pub attempt_id: SubagentAttemptId,
pub content: String,
pub digest: String,
pub size_bytes: u64,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl starweaver_core::VersionedRecord for BackgroundSubagentArtifact {
const SCHEMA: &'static str = "starweaver.session.background_subagent_artifact";
const VERSION: u32 = 1;
}
impl BackgroundSubagentArtifact {
#[must_use]
pub fn content_digest(content: &str) -> String {
background_subagent_result_digest(Some(content), None)
}
#[must_use]
pub fn is_valid(&self) -> bool {
!self.artifact_ref.is_empty()
&& !self.namespace_id.is_empty()
&& self.digest == Self::content_digest(&self.content)
&& self.size_bytes == u64::try_from(self.content.len()).unwrap_or(u64::MAX)
&& self.expires_at > self.created_at
}
#[must_use]
pub fn is_available_at(&self, now: DateTime<Utc>) -> bool {
self.is_valid() && self.expires_at > now
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BackgroundSubagentArtifactLimits {
pub max_single_bytes: u64,
pub max_retained_bytes: u64,
}
impl BackgroundSubagentArtifactLimits {
#[must_use]
pub const fn is_valid(self) -> bool {
self.max_single_bytes > 0
&& self.max_retained_bytes > 0
&& self.max_single_bytes <= self.max_retained_bytes
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BackgroundSubagentTerminalCommit {
pub record: BackgroundSubagentRecord,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact: Option<BackgroundSubagentArtifact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_limits: Option<BackgroundSubagentArtifactLimits>,
}
#[derive(Serialize)]
struct CanonicalBackgroundSubagentTerminalCommit<'a> {
schema_version: u32,
attempt_id: &'a SubagentAttemptId,
agent_id: &'a str,
linked_task_id: &'a Option<TaskId>,
subagent_name: &'a str,
namespace_id: &'a str,
parent_session_id: &'a SessionId,
parent_run_id: &'a RunId,
child_run_id: &'a Option<RunId>,
profile: &'a str,
owner_host_instance_id: &'a str,
owner_fencing_generation: u64,
execution_status: DurableBackgroundSubagentExecutionStatus,
result_ref: &'a Option<DurableBackgroundSubagentResultRef>,
failure_category: &'a Option<String>,
cancellation_reason: &'a Option<String>,
initial_retention_status: DurableBackgroundSubagentRetentionStatus,
initial_retention_expires_at: &'a Option<DateTime<Utc>>,
trace_context: &'a Option<TraceContext>,
accepted_at: &'a DateTime<Utc>,
terminal_at: &'a Option<DateTime<Utc>>,
updated_at: &'a DateTime<Utc>,
artifact: &'a Option<BackgroundSubagentArtifact>,
}
impl BackgroundSubagentTerminalCommit {
pub fn canonical_fingerprint(&self) -> Result<String, serde_json::Error> {
let record = &self.record;
let canonical = CanonicalBackgroundSubagentTerminalCommit {
schema_version: record.schema_version,
attempt_id: &record.attempt_id,
agent_id: &record.agent_id,
linked_task_id: &record.linked_task_id,
subagent_name: &record.subagent_name,
namespace_id: &record.namespace_id,
parent_session_id: &record.parent_session_id,
parent_run_id: &record.parent_run_id,
child_run_id: &record.child_run_id,
profile: &record.profile,
owner_host_instance_id: &record.owner_lease.host_instance_id,
owner_fencing_generation: record.owner_lease.fencing_generation,
execution_status: record.execution_status,
result_ref: &record.result_ref,
failure_category: &record.failure_category,
cancellation_reason: &record.cancellation_reason,
initial_retention_status: record.retention_status,
initial_retention_expires_at: &record.retention_expires_at,
trace_context: &record.trace_context,
accepted_at: &record.accepted_at,
terminal_at: &record.terminal_at,
updated_at: &record.updated_at,
artifact: &self.artifact,
};
let payload = serde_json::to_vec(&canonical)?;
let mut hasher = Sha256::new();
hasher.update(b"starweaver.session.background_subagent.terminal_commit.v1\0");
hasher.update(payload);
Ok(format!("sha256:{:x}", hasher.finalize()))
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DurableBackgroundSubagentExecutionStatus {
Accepted,
Starting,
Running,
Waiting,
Completed,
Failed,
Cancelled,
}
impl DurableBackgroundSubagentExecutionStatus {
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::Starting => "starting",
Self::Running => "running",
Self::Waiting => "waiting",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DurableBackgroundSubagentDeliveryStatus {
#[default]
Undelivered,
Claimed,
Delivered,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DurableBackgroundSubagentRetentionStatus {
#[default]
Inline,
Artifact,
Expired,
}
impl DurableBackgroundSubagentRetentionStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Inline => "inline",
Self::Artifact => "artifact",
Self::Expired => "expired",
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct DurableBackgroundSubagentResultRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(default)]
pub size_bytes: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DurableBackgroundSubagentDeliveryClaim {
pub claim_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continuation_run_id: Option<RunId>,
pub deadline: DateTime<Utc>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DurableBackgroundSubagentDeliveryRelease {
#[default]
Retryable,
ConsumerTerminated {
run_id: RunId,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DurableBackgroundSubagentOwnerLease {
pub host_instance_id: String,
pub fencing_generation: u64,
pub heartbeat_at: DateTime<Utc>,
pub lease_expires_at: DateTime<Utc>,
}
impl DurableBackgroundSubagentOwnerLease {
#[must_use]
pub fn is_valid(&self) -> bool {
!self.host_instance_id.is_empty()
&& self.fencing_generation > 0
&& self.lease_expires_at > self.heartbeat_at
}
#[must_use]
pub fn expired_at(&self, now: DateTime<Utc>) -> bool {
self.lease_expires_at <= now
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BackgroundSubagentRecord {
pub schema_version: u32,
pub attempt_id: SubagentAttemptId,
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub linked_task_id: Option<TaskId>,
pub subagent_name: String,
pub namespace_id: String,
pub parent_session_id: SessionId,
pub parent_run_id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub child_run_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continuation_run_id: Option<RunId>,
pub profile: String,
pub owner_lease: DurableBackgroundSubagentOwnerLease,
pub execution_status: DurableBackgroundSubagentExecutionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_ref: Option<DurableBackgroundSubagentResultRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancellation_reason: Option<String>,
pub delivery_status: DurableBackgroundSubagentDeliveryStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery_claim: Option<DurableBackgroundSubagentDeliveryClaim>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivered_claim_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automatic_continuation_suppressed_by_run_id: Option<RunId>,
pub retention_status: DurableBackgroundSubagentRetentionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_context: Option<TraceContext>,
pub accepted_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal_at: Option<DateTime<Utc>>,
}
impl starweaver_core::VersionedRecord for BackgroundSubagentRecord {
const SCHEMA: &'static str = "starweaver.session.background_subagent_record";
const VERSION: u32 = BACKGROUND_SUBAGENT_RECORD_VERSION;
}
impl BackgroundSubagentRecord {
#[must_use]
pub fn is_valid_acceptance(&self) -> bool {
self.execution_status == DurableBackgroundSubagentExecutionStatus::Accepted
&& self.owner_lease.is_valid()
&& self.owner_lease.heartbeat_at == self.accepted_at
&& self.child_run_id.is_none()
&& self.result_ref.is_none()
&& self.failure_category.is_none()
&& self.cancellation_reason.is_none()
&& self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Undelivered
&& self.delivery_claim.is_none()
&& self.delivered_claim_id.is_none()
&& self.continuation_run_id.is_none()
&& self.automatic_continuation_suppressed_by_run_id.is_none()
&& self.retention_status == DurableBackgroundSubagentRetentionStatus::Inline
&& self.retention_expires_at.is_none()
&& self.terminal_at.is_none()
&& self.updated_at == self.accepted_at
}
#[must_use]
pub fn is_valid_terminal(&self) -> bool {
self.execution_status.is_terminal()
&& self.owner_lease.is_valid()
&& self.result_ref.is_some()
&& self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Undelivered
&& self.delivery_claim.is_none()
&& self.delivered_claim_id.is_none()
&& self.continuation_run_id.is_none()
&& self.automatic_continuation_suppressed_by_run_id.is_none()
&& match self.retention_status {
DurableBackgroundSubagentRetentionStatus::Expired => {
self.retention_expires_at.is_none()
&& self.result_ref.as_ref().is_some_and(|result| {
result.content.is_none()
&& result.error.is_none()
&& result.artifact_ref.is_none()
})
}
_ => self
.retention_expires_at
.is_some_and(|deadline| deadline > self.updated_at),
}
&& self.terminal_at == Some(self.updated_at)
&& self.updated_at >= self.accepted_at
&& (self.execution_status != DurableBackgroundSubagentExecutionStatus::Completed
|| (self.failure_category.is_none() && self.cancellation_reason.is_none()))
&& (self.cancellation_reason.is_none()
|| self.execution_status == DurableBackgroundSubagentExecutionStatus::Cancelled)
}
#[must_use]
pub fn delivery_claimable_at(&self, now: DateTime<Utc>) -> bool {
self.execution_status.is_terminal()
&& (self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Undelivered
|| (self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Claimed
&& self
.delivery_claim
.as_ref()
.is_some_and(|claim| claim.deadline <= now)))
}
#[must_use]
pub fn continuation_outcome(&self, artifact_content: Option<&str>) -> String {
if self.retention_status == DurableBackgroundSubagentRetentionStatus::Expired {
let result_ref = self.result_ref.as_ref();
return format!(
"Retained background result content expired (digest: {}, logical_size_bytes: {}).",
result_ref
.and_then(|result| result.digest.as_deref())
.unwrap_or("unknown"),
result_ref.map_or(0, |result| result.size_bytes),
);
}
if let Some(content) = artifact_content {
return content.to_string();
}
self.result_ref.as_ref().map_or_else(
|| {
format!(
"Background subagent finished with status {}.",
self.execution_status.as_str()
)
},
|result| {
result
.content
.clone()
.or_else(|| result.error.clone())
.unwrap_or_else(|| {
format!(
"Background subagent finished with status {}.",
self.execution_status.as_str()
)
})
},
)
}
#[must_use]
pub fn continuation_text(&self, artifact_content: Option<&str>) -> String {
format!(
"Background subagent result (subagent: {}, agent_id: {}, attempt_id: {}, child_run_id: {}):\n\n{}",
self.subagent_name,
self.agent_id,
self.attempt_id.as_str(),
self.child_run_id.as_ref().map_or("unknown", RunId::as_str),
self.continuation_outcome(artifact_content),
)
}
#[must_use]
pub fn continuation_input(&self, artifact_content: Option<&str>) -> Vec<InputPart> {
vec![InputPart::text(self.continuation_text(artifact_content))]
}
#[must_use]
pub fn validates_continuation_run(&self, run: &RunRecord) -> bool {
run.session_id == self.parent_session_id
&& run.status == RunStatus::Queued
&& run.parent_run_id.as_ref() == Some(&self.parent_run_id)
&& run.trigger_type.as_deref() == Some("async_subagent_result")
&& run.profile.as_deref() == Some(self.profile.as_str())
&& run
.metadata
.get("starweaver.async_subagent.attempt_id")
.and_then(serde_json::Value::as_str)
== Some(self.attempt_id.as_str())
&& run
.metadata
.get("starweaver.async_subagent.agent_id")
.and_then(serde_json::Value::as_str)
== Some(self.agent_id.as_str())
&& run
.metadata
.get("starweaver.async_subagent.parent_run_id")
.and_then(serde_json::Value::as_str)
== Some(self.parent_run_id.as_str())
&& self.child_run_id.as_ref().map_or_else(
|| {
!run.metadata
.contains_key("starweaver.async_subagent.child_run_id")
},
|child_run_id| {
run.metadata
.get("starweaver.async_subagent.child_run_id")
.and_then(serde_json::Value::as_str)
== Some(child_run_id.as_str())
},
)
&& run.trace_context == self.trace_context.clone().unwrap_or_default()
}
#[must_use]
pub fn validates_continuation_cause_envelope(
&self,
cause: &BackgroundSubagentContinuationCause,
run: &RunRecord,
) -> bool {
let Ok(input_digest) = background_subagent_input_digest(&run.input) else {
return false;
};
let result_ref = self.result_ref.as_ref();
self.validates_continuation_run(run)
&& cause.attempt_id == self.attempt_id
&& cause.agent_id == self.agent_id
&& cause.parent_session_id == self.parent_session_id
&& cause.parent_run_id == self.parent_run_id
&& cause.child_run_id == self.child_run_id
&& cause.result_digest == result_ref.and_then(|result| result.digest.clone())
&& cause.result_size_bytes == result_ref.map_or(0, |result| result.size_bytes)
&& cause.trace_context == self.trace_context.clone().unwrap_or_default()
&& cause.input_digest == input_digest
}
#[must_use]
pub fn validates_continuation_cause(
&self,
cause: &BackgroundSubagentContinuationCause,
run: &RunRecord,
artifact_content: Option<&str>,
) -> bool {
let artifact_shape_matches = match self.retention_status {
DurableBackgroundSubagentRetentionStatus::Artifact => artifact_content.is_some(),
DurableBackgroundSubagentRetentionStatus::Inline
| DurableBackgroundSubagentRetentionStatus::Expired => artifact_content.is_none(),
};
if !artifact_shape_matches || !self.validates_continuation_cause_envelope(cause, run) {
return false;
}
run.input == self.continuation_input(artifact_content)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BackgroundSubagentContinuationCause {
pub attempt_id: SubagentAttemptId,
pub agent_id: String,
pub parent_session_id: SessionId,
pub parent_run_id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub child_run_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_digest: Option<String>,
pub result_size_bytes: u64,
#[serde(default, skip_serializing_if = "TraceContext::is_empty")]
pub trace_context: TraceContext,
pub input_digest: String,
}
impl BackgroundSubagentContinuationCause {
pub fn new(
record: &BackgroundSubagentRecord,
input: &[InputPart],
) -> Result<Self, serde_json::Error> {
Ok(Self {
attempt_id: record.attempt_id.clone(),
agent_id: record.agent_id.clone(),
parent_session_id: record.parent_session_id.clone(),
parent_run_id: record.parent_run_id.clone(),
child_run_id: record.child_run_id.clone(),
result_digest: record
.result_ref
.as_ref()
.and_then(|result| result.digest.clone()),
result_size_bytes: record
.result_ref
.as_ref()
.map_or(0, |result| result.size_bytes),
trace_context: record.trace_context.clone().unwrap_or_default(),
input_digest: background_subagent_input_digest(input)?,
})
}
}
#[must_use]
pub fn background_subagent_result_digest(content: Option<&str>, error: Option<&str>) -> String {
let mut hasher = Sha256::new();
hasher.update(b"starweaver.session.background_subagent.result.v1\0");
match (content, error) {
(Some(content), _) => {
hasher.update(b"content\0");
hasher.update(content.as_bytes());
}
(None, Some(error)) => {
hasher.update(b"error\0");
hasher.update(error.as_bytes());
}
(None, None) => hasher.update(b"empty\0"),
}
format!("sha256:{:x}", hasher.finalize())
}
pub fn background_subagent_input_digest(input: &[InputPart]) -> Result<String, serde_json::Error> {
let payload = serde_json::to_vec(input)?;
let mut hasher = Sha256::new();
hasher.update(b"starweaver.session.background_subagent_continuation.input.v1\0");
hasher.update(payload);
Ok(format!("sha256:{:x}", hasher.finalize()))
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AcquireBackgroundSubagentContinuation {
pub attempt_id: SubagentAttemptId,
pub claim_id: String,
pub claim_deadline: DateTime<Utc>,
pub cause: BackgroundSubagentContinuationCause,
pub admission: AcquireRunAdmission,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BackgroundSubagentContinuationReceipt {
pub cause: BackgroundSubagentContinuationCause,
pub background: BackgroundSubagentRecord,
pub admission: RunAdmissionReceipt,
}