1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use starweaver_core::{RunId, SessionId, SubagentAttemptId, TaskId, TraceContext};
7
8use crate::{AcquireRunAdmission, InputPart, RunAdmissionReceipt, RunRecord, RunStatus};
9
10pub const BACKGROUND_SUBAGENT_RECORD_VERSION: u32 = 1;
12pub const DEFAULT_BACKGROUND_RESULT_RETENTION_SECS: i64 = 86_400;
14
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
17pub struct BackgroundSubagentArtifact {
18 pub artifact_ref: String,
20 pub namespace_id: String,
22 pub attempt_id: SubagentAttemptId,
24 pub content: String,
26 pub digest: String,
28 pub size_bytes: u64,
30 pub created_at: DateTime<Utc>,
32 pub expires_at: DateTime<Utc>,
34}
35
36impl starweaver_core::VersionedRecord for BackgroundSubagentArtifact {
37 const SCHEMA: &'static str = "starweaver.session.background_subagent_artifact";
38 const VERSION: u32 = 1;
39}
40
41impl BackgroundSubagentArtifact {
42 #[must_use]
44 pub fn content_digest(content: &str) -> String {
45 background_subagent_result_digest(Some(content), None)
46 }
47
48 #[must_use]
50 pub fn is_valid(&self) -> bool {
51 !self.artifact_ref.is_empty()
52 && !self.namespace_id.is_empty()
53 && self.digest == Self::content_digest(&self.content)
54 && self.size_bytes == u64::try_from(self.content.len()).unwrap_or(u64::MAX)
55 && self.expires_at > self.created_at
56 }
57
58 #[must_use]
60 pub fn is_available_at(&self, now: DateTime<Utc>) -> bool {
61 self.is_valid() && self.expires_at > now
62 }
63}
64
65#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
67pub struct BackgroundSubagentArtifactLimits {
68 pub max_single_bytes: u64,
70 pub max_retained_bytes: u64,
72}
73
74impl BackgroundSubagentArtifactLimits {
75 #[must_use]
77 pub const fn is_valid(self) -> bool {
78 self.max_single_bytes > 0
79 && self.max_retained_bytes > 0
80 && self.max_single_bytes <= self.max_retained_bytes
81 }
82}
83
84#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86pub struct BackgroundSubagentTerminalCommit {
87 pub record: BackgroundSubagentRecord,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub artifact: Option<BackgroundSubagentArtifact>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub artifact_limits: Option<BackgroundSubagentArtifactLimits>,
95}
96
97#[derive(Serialize)]
98struct CanonicalBackgroundSubagentTerminalCommit<'a> {
99 schema_version: u32,
100 attempt_id: &'a SubagentAttemptId,
101 agent_id: &'a str,
102 linked_task_id: &'a Option<TaskId>,
103 subagent_name: &'a str,
104 namespace_id: &'a str,
105 parent_session_id: &'a SessionId,
106 parent_run_id: &'a RunId,
107 child_run_id: &'a Option<RunId>,
108 profile: &'a str,
109 owner_host_instance_id: &'a str,
110 owner_fencing_generation: u64,
111 execution_status: DurableBackgroundSubagentExecutionStatus,
112 result_ref: &'a Option<DurableBackgroundSubagentResultRef>,
113 failure_category: &'a Option<String>,
114 cancellation_reason: &'a Option<String>,
115 initial_retention_status: DurableBackgroundSubagentRetentionStatus,
116 initial_retention_expires_at: &'a Option<DateTime<Utc>>,
117 trace_context: &'a Option<TraceContext>,
118 accepted_at: &'a DateTime<Utc>,
119 terminal_at: &'a Option<DateTime<Utc>>,
120 updated_at: &'a DateTime<Utc>,
121 artifact: &'a Option<BackgroundSubagentArtifact>,
122}
123
124impl BackgroundSubagentTerminalCommit {
125 pub fn canonical_fingerprint(&self) -> Result<String, serde_json::Error> {
135 let record = &self.record;
136 let canonical = CanonicalBackgroundSubagentTerminalCommit {
137 schema_version: record.schema_version,
138 attempt_id: &record.attempt_id,
139 agent_id: &record.agent_id,
140 linked_task_id: &record.linked_task_id,
141 subagent_name: &record.subagent_name,
142 namespace_id: &record.namespace_id,
143 parent_session_id: &record.parent_session_id,
144 parent_run_id: &record.parent_run_id,
145 child_run_id: &record.child_run_id,
146 profile: &record.profile,
147 owner_host_instance_id: &record.owner_lease.host_instance_id,
148 owner_fencing_generation: record.owner_lease.fencing_generation,
149 execution_status: record.execution_status,
150 result_ref: &record.result_ref,
151 failure_category: &record.failure_category,
152 cancellation_reason: &record.cancellation_reason,
153 initial_retention_status: record.retention_status,
154 initial_retention_expires_at: &record.retention_expires_at,
155 trace_context: &record.trace_context,
156 accepted_at: &record.accepted_at,
157 terminal_at: &record.terminal_at,
158 updated_at: &record.updated_at,
159 artifact: &self.artifact,
160 };
161 let payload = serde_json::to_vec(&canonical)?;
162 let mut hasher = Sha256::new();
163 hasher.update(b"starweaver.session.background_subagent.terminal_commit.v1\0");
164 hasher.update(payload);
165 Ok(format!("sha256:{:x}", hasher.finalize()))
166 }
167}
168
169#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
171#[serde(rename_all = "snake_case")]
172pub enum DurableBackgroundSubagentExecutionStatus {
173 Accepted,
175 Starting,
177 Running,
179 Waiting,
181 Completed,
183 Failed,
185 Cancelled,
187}
188
189impl DurableBackgroundSubagentExecutionStatus {
190 #[must_use]
192 pub const fn is_terminal(self) -> bool {
193 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
194 }
195
196 #[must_use]
198 pub const fn as_str(self) -> &'static str {
199 match self {
200 Self::Accepted => "accepted",
201 Self::Starting => "starting",
202 Self::Running => "running",
203 Self::Waiting => "waiting",
204 Self::Completed => "completed",
205 Self::Failed => "failed",
206 Self::Cancelled => "cancelled",
207 }
208 }
209}
210
211#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
213#[serde(rename_all = "snake_case")]
214pub enum DurableBackgroundSubagentDeliveryStatus {
215 #[default]
217 Undelivered,
218 Claimed,
220 Delivered,
222}
223
224#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
226#[serde(rename_all = "snake_case")]
227pub enum DurableBackgroundSubagentRetentionStatus {
228 #[default]
230 Inline,
231 Artifact,
233 Expired,
235}
236
237impl DurableBackgroundSubagentRetentionStatus {
238 #[must_use]
240 pub const fn as_str(self) -> &'static str {
241 match self {
242 Self::Inline => "inline",
243 Self::Artifact => "artifact",
244 Self::Expired => "expired",
245 }
246 }
247}
248
249#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
251pub struct DurableBackgroundSubagentResultRef {
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub content: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub error: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub artifact_ref: Option<String>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub digest: Option<String>,
264 #[serde(default)]
266 pub size_bytes: u64,
267}
268
269#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
271pub struct DurableBackgroundSubagentDeliveryClaim {
272 pub claim_id: String,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub continuation_run_id: Option<RunId>,
277 pub deadline: DateTime<Utc>,
279}
280
281#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
283#[serde(tag = "kind", rename_all = "snake_case")]
284pub enum DurableBackgroundSubagentDeliveryRelease {
285 #[default]
287 Retryable,
288 ConsumerTerminated {
290 run_id: RunId,
292 },
293}
294
295#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
297pub struct DurableBackgroundSubagentOwnerLease {
298 pub host_instance_id: String,
300 pub fencing_generation: u64,
302 pub heartbeat_at: DateTime<Utc>,
304 pub lease_expires_at: DateTime<Utc>,
306}
307
308impl DurableBackgroundSubagentOwnerLease {
309 #[must_use]
311 pub fn is_valid(&self) -> bool {
312 !self.host_instance_id.is_empty()
313 && self.fencing_generation > 0
314 && self.lease_expires_at > self.heartbeat_at
315 }
316
317 #[must_use]
319 pub fn expired_at(&self, now: DateTime<Utc>) -> bool {
320 self.lease_expires_at <= now
321 }
322}
323
324#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
326pub struct BackgroundSubagentRecord {
327 pub schema_version: u32,
329 pub attempt_id: SubagentAttemptId,
331 pub agent_id: String,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub linked_task_id: Option<TaskId>,
336 pub subagent_name: String,
338 pub namespace_id: String,
340 pub parent_session_id: SessionId,
342 pub parent_run_id: RunId,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub child_run_id: Option<RunId>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub continuation_run_id: Option<RunId>,
350 pub profile: String,
352 pub owner_lease: DurableBackgroundSubagentOwnerLease,
354 pub execution_status: DurableBackgroundSubagentExecutionStatus,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub result_ref: Option<DurableBackgroundSubagentResultRef>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub failure_category: Option<String>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub cancellation_reason: Option<String>,
365 pub delivery_status: DurableBackgroundSubagentDeliveryStatus,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub delivery_claim: Option<DurableBackgroundSubagentDeliveryClaim>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub delivered_claim_id: Option<String>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub automatic_continuation_suppressed_by_run_id: Option<RunId>,
378 pub retention_status: DurableBackgroundSubagentRetentionStatus,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub retention_expires_at: Option<DateTime<Utc>>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub trace_context: Option<TraceContext>,
386 pub accepted_at: DateTime<Utc>,
388 pub updated_at: DateTime<Utc>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub terminal_at: Option<DateTime<Utc>>,
393}
394
395impl starweaver_core::VersionedRecord for BackgroundSubagentRecord {
396 const SCHEMA: &'static str = "starweaver.session.background_subagent_record";
397 const VERSION: u32 = BACKGROUND_SUBAGENT_RECORD_VERSION;
398}
399
400impl BackgroundSubagentRecord {
401 #[must_use]
403 pub fn is_valid_acceptance(&self) -> bool {
404 self.execution_status == DurableBackgroundSubagentExecutionStatus::Accepted
405 && self.owner_lease.is_valid()
406 && self.owner_lease.heartbeat_at == self.accepted_at
407 && self.child_run_id.is_none()
408 && self.result_ref.is_none()
409 && self.failure_category.is_none()
410 && self.cancellation_reason.is_none()
411 && self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Undelivered
412 && self.delivery_claim.is_none()
413 && self.delivered_claim_id.is_none()
414 && self.continuation_run_id.is_none()
415 && self.automatic_continuation_suppressed_by_run_id.is_none()
416 && self.retention_status == DurableBackgroundSubagentRetentionStatus::Inline
417 && self.retention_expires_at.is_none()
418 && self.terminal_at.is_none()
419 && self.updated_at == self.accepted_at
420 }
421
422 #[must_use]
424 pub fn is_valid_terminal(&self) -> bool {
425 self.execution_status.is_terminal()
426 && self.owner_lease.is_valid()
427 && self.result_ref.is_some()
428 && self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Undelivered
429 && self.delivery_claim.is_none()
430 && self.delivered_claim_id.is_none()
431 && self.continuation_run_id.is_none()
432 && self.automatic_continuation_suppressed_by_run_id.is_none()
433 && match self.retention_status {
434 DurableBackgroundSubagentRetentionStatus::Expired => {
435 self.retention_expires_at.is_none()
436 && self.result_ref.as_ref().is_some_and(|result| {
437 result.content.is_none()
438 && result.error.is_none()
439 && result.artifact_ref.is_none()
440 })
441 }
442 _ => self
443 .retention_expires_at
444 .is_some_and(|deadline| deadline > self.updated_at),
445 }
446 && self.terminal_at == Some(self.updated_at)
447 && self.updated_at >= self.accepted_at
448 && (self.execution_status != DurableBackgroundSubagentExecutionStatus::Completed
449 || (self.failure_category.is_none() && self.cancellation_reason.is_none()))
450 && (self.cancellation_reason.is_none()
451 || self.execution_status == DurableBackgroundSubagentExecutionStatus::Cancelled)
452 }
453
454 #[must_use]
456 pub fn delivery_claimable_at(&self, now: DateTime<Utc>) -> bool {
457 self.execution_status.is_terminal()
458 && (self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Undelivered
459 || (self.delivery_status == DurableBackgroundSubagentDeliveryStatus::Claimed
460 && self
461 .delivery_claim
462 .as_ref()
463 .is_some_and(|claim| claim.deadline <= now)))
464 }
465
466 #[must_use]
468 pub fn continuation_outcome(&self, artifact_content: Option<&str>) -> String {
469 if self.retention_status == DurableBackgroundSubagentRetentionStatus::Expired {
470 let result_ref = self.result_ref.as_ref();
471 return format!(
472 "Retained background result content expired (digest: {}, logical_size_bytes: {}).",
473 result_ref
474 .and_then(|result| result.digest.as_deref())
475 .unwrap_or("unknown"),
476 result_ref.map_or(0, |result| result.size_bytes),
477 );
478 }
479 if let Some(content) = artifact_content {
480 return content.to_string();
481 }
482 self.result_ref.as_ref().map_or_else(
483 || {
484 format!(
485 "Background subagent finished with status {}.",
486 self.execution_status.as_str()
487 )
488 },
489 |result| {
490 result
491 .content
492 .clone()
493 .or_else(|| result.error.clone())
494 .unwrap_or_else(|| {
495 format!(
496 "Background subagent finished with status {}.",
497 self.execution_status.as_str()
498 )
499 })
500 },
501 )
502 }
503
504 #[must_use]
506 pub fn continuation_text(&self, artifact_content: Option<&str>) -> String {
507 format!(
508 "Background subagent result (subagent: {}, agent_id: {}, attempt_id: {}, child_run_id: {}):\n\n{}",
509 self.subagent_name,
510 self.agent_id,
511 self.attempt_id.as_str(),
512 self.child_run_id.as_ref().map_or("unknown", RunId::as_str),
513 self.continuation_outcome(artifact_content),
514 )
515 }
516
517 #[must_use]
519 pub fn continuation_input(&self, artifact_content: Option<&str>) -> Vec<InputPart> {
520 vec![InputPart::text(self.continuation_text(artifact_content))]
521 }
522
523 #[must_use]
525 pub fn validates_continuation_run(&self, run: &RunRecord) -> bool {
526 run.session_id == self.parent_session_id
527 && run.status == RunStatus::Queued
528 && run.parent_run_id.as_ref() == Some(&self.parent_run_id)
529 && run.trigger_type.as_deref() == Some("async_subagent_result")
530 && run.profile.as_deref() == Some(self.profile.as_str())
531 && run
532 .metadata
533 .get("starweaver.async_subagent.attempt_id")
534 .and_then(serde_json::Value::as_str)
535 == Some(self.attempt_id.as_str())
536 && run
537 .metadata
538 .get("starweaver.async_subagent.agent_id")
539 .and_then(serde_json::Value::as_str)
540 == Some(self.agent_id.as_str())
541 && run
542 .metadata
543 .get("starweaver.async_subagent.parent_run_id")
544 .and_then(serde_json::Value::as_str)
545 == Some(self.parent_run_id.as_str())
546 && self.child_run_id.as_ref().map_or_else(
547 || {
548 !run.metadata
549 .contains_key("starweaver.async_subagent.child_run_id")
550 },
551 |child_run_id| {
552 run.metadata
553 .get("starweaver.async_subagent.child_run_id")
554 .and_then(serde_json::Value::as_str)
555 == Some(child_run_id.as_str())
556 },
557 )
558 && run.trace_context == self.trace_context.clone().unwrap_or_default()
559 }
560
561 #[must_use]
563 pub fn validates_continuation_cause_envelope(
564 &self,
565 cause: &BackgroundSubagentContinuationCause,
566 run: &RunRecord,
567 ) -> bool {
568 let Ok(input_digest) = background_subagent_input_digest(&run.input) else {
569 return false;
570 };
571 let result_ref = self.result_ref.as_ref();
572 self.validates_continuation_run(run)
573 && cause.attempt_id == self.attempt_id
574 && cause.agent_id == self.agent_id
575 && cause.parent_session_id == self.parent_session_id
576 && cause.parent_run_id == self.parent_run_id
577 && cause.child_run_id == self.child_run_id
578 && cause.result_digest == result_ref.and_then(|result| result.digest.clone())
579 && cause.result_size_bytes == result_ref.map_or(0, |result| result.size_bytes)
580 && cause.trace_context == self.trace_context.clone().unwrap_or_default()
581 && cause.input_digest == input_digest
582 }
583
584 #[must_use]
586 pub fn validates_continuation_cause(
587 &self,
588 cause: &BackgroundSubagentContinuationCause,
589 run: &RunRecord,
590 artifact_content: Option<&str>,
591 ) -> bool {
592 let artifact_shape_matches = match self.retention_status {
593 DurableBackgroundSubagentRetentionStatus::Artifact => artifact_content.is_some(),
594 DurableBackgroundSubagentRetentionStatus::Inline
595 | DurableBackgroundSubagentRetentionStatus::Expired => artifact_content.is_none(),
596 };
597 if !artifact_shape_matches || !self.validates_continuation_cause_envelope(cause, run) {
598 return false;
599 }
600 run.input == self.continuation_input(artifact_content)
601 }
602}
603
604#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
606pub struct BackgroundSubagentContinuationCause {
607 pub attempt_id: SubagentAttemptId,
609 pub agent_id: String,
611 pub parent_session_id: SessionId,
613 pub parent_run_id: RunId,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub child_run_id: Option<RunId>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub result_digest: Option<String>,
621 pub result_size_bytes: u64,
623 #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
625 pub trace_context: TraceContext,
626 pub input_digest: String,
628}
629
630impl BackgroundSubagentContinuationCause {
631 pub fn new(
637 record: &BackgroundSubagentRecord,
638 input: &[InputPart],
639 ) -> Result<Self, serde_json::Error> {
640 Ok(Self {
641 attempt_id: record.attempt_id.clone(),
642 agent_id: record.agent_id.clone(),
643 parent_session_id: record.parent_session_id.clone(),
644 parent_run_id: record.parent_run_id.clone(),
645 child_run_id: record.child_run_id.clone(),
646 result_digest: record
647 .result_ref
648 .as_ref()
649 .and_then(|result| result.digest.clone()),
650 result_size_bytes: record
651 .result_ref
652 .as_ref()
653 .map_or(0, |result| result.size_bytes),
654 trace_context: record.trace_context.clone().unwrap_or_default(),
655 input_digest: background_subagent_input_digest(input)?,
656 })
657 }
658}
659
660#[must_use]
662pub fn background_subagent_result_digest(content: Option<&str>, error: Option<&str>) -> String {
663 let mut hasher = Sha256::new();
664 hasher.update(b"starweaver.session.background_subagent.result.v1\0");
665 match (content, error) {
666 (Some(content), _) => {
667 hasher.update(b"content\0");
668 hasher.update(content.as_bytes());
669 }
670 (None, Some(error)) => {
671 hasher.update(b"error\0");
672 hasher.update(error.as_bytes());
673 }
674 (None, None) => hasher.update(b"empty\0"),
675 }
676 format!("sha256:{:x}", hasher.finalize())
677}
678
679pub fn background_subagent_input_digest(input: &[InputPart]) -> Result<String, serde_json::Error> {
685 let payload = serde_json::to_vec(input)?;
686 let mut hasher = Sha256::new();
687 hasher.update(b"starweaver.session.background_subagent_continuation.input.v1\0");
688 hasher.update(payload);
689 Ok(format!("sha256:{:x}", hasher.finalize()))
690}
691
692#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
694pub struct AcquireBackgroundSubagentContinuation {
695 pub attempt_id: SubagentAttemptId,
697 pub claim_id: String,
699 pub claim_deadline: DateTime<Utc>,
701 pub cause: BackgroundSubagentContinuationCause,
703 pub admission: AcquireRunAdmission,
705}
706
707#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
709pub struct BackgroundSubagentContinuationReceipt {
710 pub cause: BackgroundSubagentContinuationCause,
712 pub background: BackgroundSubagentRecord,
714 pub admission: RunAdmissionReceipt,
716}