1use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::sync::Arc;
19
20use crate::error::Result;
21use crate::typed_id::SessionId;
22
23#[cfg(feature = "openapi")]
24use utoipa::ToSchema;
25
26pub type TaskProgress = crate::background::BackgroundProgress;
28
29pub const TASK_KIND_SUBAGENT: &str = "subagent";
32pub const TASK_KIND_AGENT_HANDOFF: &str = "agent_handoff";
37pub const TASK_KIND_EXTERNAL_AGENT: &str = "external_agent";
38pub const TASK_KIND_BACKGROUND_TOOL: &str = "background_tool";
39pub const TASK_KIND_MONITOR: &str = "monitor";
42
43pub fn generate_task_id() -> String {
45 format!("task_{}", uuid::Uuid::now_v7().simple())
46}
47
48pub fn generate_task_message_id() -> String {
50 format!("tmsg_{}", uuid::Uuid::now_v7().simple())
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
59#[cfg_attr(feature = "openapi", derive(ToSchema))]
60#[serde(rename_all = "snake_case")]
61pub enum SessionTaskState {
62 Queued,
63 Running,
64 AwaitingInput,
65 Succeeded,
66 Failed,
67 Canceled,
68}
69
70impl SessionTaskState {
71 pub fn is_terminal(&self) -> bool {
72 matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
73 }
74
75 pub fn parse(s: &str) -> Option<Self> {
80 match s {
81 "queued" => Some(Self::Queued),
82 "running" => Some(Self::Running),
83 "awaiting_input" => Some(Self::AwaitingInput),
84 "succeeded" => Some(Self::Succeeded),
85 "failed" => Some(Self::Failed),
86 "canceled" => Some(Self::Canceled),
87 _ => None,
88 }
89 }
90}
91
92impl std::fmt::Display for SessionTaskState {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 let s = match self {
95 Self::Queued => "queued",
96 Self::Running => "running",
97 Self::AwaitingInput => "awaiting_input",
98 Self::Succeeded => "succeeded",
99 Self::Failed => "failed",
100 Self::Canceled => "canceled",
101 };
102 write!(f, "{s}")
103 }
104}
105
106impl From<&str> for SessionTaskState {
107 fn from(s: &str) -> Self {
108 match s {
109 "running" => Self::Running,
110 "awaiting_input" => Self::AwaitingInput,
111 "succeeded" => Self::Succeeded,
112 "failed" => Self::Failed,
113 "canceled" => Self::Canceled,
114 _ => Self::Queued,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
121#[cfg_attr(feature = "openapi", derive(ToSchema))]
122#[serde(rename_all = "snake_case")]
123pub enum TaskWakePolicy {
124 #[default]
126 Silent,
127 OnTerminal,
129 OnActivity,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[cfg_attr(feature = "openapi", derive(ToSchema))]
136pub struct TaskInputRequest {
137 pub id: String,
139 pub prompt: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
144 pub expected: Option<Value>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[cfg_attr(feature = "openapi", derive(ToSchema))]
150pub struct TaskError {
151 pub kind: String,
152 pub message: String,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[cfg_attr(feature = "openapi", derive(ToSchema))]
158pub struct TaskArtifact {
159 pub name: String,
160 #[serde(rename = "type")]
162 pub artifact_type: String,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub path: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub url: Option<String>,
169}
170
171#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
173#[cfg_attr(feature = "openapi", derive(ToSchema))]
174pub struct TaskLinks {
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
178 pub child_session_id: Option<SessionId>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub remote_task_id: Option<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub resource_ids: Vec<String>,
185}
186
187impl TaskLinks {
188 pub fn is_empty(&self) -> bool {
189 self.child_session_id.is_none()
190 && self.remote_task_id.is_none()
191 && self.resource_ids.is_empty()
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197#[cfg_attr(feature = "openapi", derive(ToSchema))]
198pub struct SessionTask {
199 pub id: String,
201 #[cfg_attr(feature = "openapi", schema(value_type = String))]
203 pub session_id: SessionId,
204 pub kind: String,
206 pub display_name: String,
208 #[serde(default)]
210 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
211 pub spec: Value,
212 pub state: SessionTaskState,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub state_detail: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub progress: Option<TaskProgress>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub input_request: Option<TaskInputRequest>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub cancel_requested_at: Option<DateTime<Utc>>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub summary: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub result_path: Option<String>,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub artifacts: Vec<TaskArtifact>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub error: Option<TaskError>,
234 #[serde(default = "default_attempt")]
236 pub attempt: i32,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub worker_id: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub heartbeat_at: Option<DateTime<Utc>>,
241 #[serde(default, skip_serializing_if = "TaskLinks::is_empty")]
242 pub links: TaskLinks,
243 #[serde(default)]
244 pub wake_policy: TaskWakePolicy,
245 pub created_at: DateTime<Utc>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub started_at: Option<DateTime<Utc>>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub finished_at: Option<DateTime<Utc>>,
250 pub updated_at: DateTime<Utc>,
251}
252
253fn default_attempt() -> i32 {
254 1
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct CreateSessionTask {
260 pub session_id: SessionId,
261 #[serde(default)]
263 pub id: Option<String>,
264 pub kind: String,
265 pub display_name: String,
266 #[serde(default)]
267 pub spec: Value,
268 #[serde(default = "default_queued")]
270 pub state: SessionTaskState,
271 #[serde(default)]
272 pub links: TaskLinks,
273 #[serde(default)]
274 pub wake_policy: TaskWakePolicy,
275}
276
277fn default_queued() -> SessionTaskState {
278 SessionTaskState::Queued
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
283pub struct SessionTaskUpdate {
284 pub state: Option<SessionTaskState>,
285 pub state_detail: Option<String>,
286 pub progress: Option<TaskProgress>,
287 pub input_request: Option<TaskInputRequest>,
289 pub summary: Option<String>,
290 pub result_path: Option<String>,
291 pub artifacts: Option<Vec<TaskArtifact>>,
293 pub error: Option<TaskError>,
294 pub links: Option<TaskLinks>,
296 pub worker_id: Option<String>,
297 pub heartbeat_at: Option<DateTime<Utc>>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub expected_attempt: Option<i32>,
307 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
312 pub increment_attempt: bool,
313}
314
315#[derive(Debug, Clone, Default)]
317pub struct SessionTaskFilter {
318 pub kind: Option<String>,
319 pub state: Option<SessionTaskState>,
320}
321
322pub fn apply_task_update(task: &mut SessionTask, update: SessionTaskUpdate, now: DateTime<Utc>) {
333 if let Some(expected) = update.expected_attempt
337 && expected != task.attempt
338 {
339 return;
340 }
341
342 let was_terminal = task.state.is_terminal();
343
344 if was_terminal
351 && let Some(state) = update.state
352 && state != task.state
353 {
354 return;
355 }
356
357 if update.increment_attempt {
360 task.attempt += 1;
361 }
362
363 let mut next_state = update.state;
364 if update.input_request.is_some() && !was_terminal {
365 next_state = Some(SessionTaskState::AwaitingInput);
366 }
367
368 if let Some(input_request) = update.input_request
369 && !was_terminal
370 {
371 task.input_request = Some(input_request);
372 }
373
374 if let Some(state) = next_state
375 && !was_terminal
376 && task.state != state
377 {
378 if task.state == SessionTaskState::Queued && state != SessionTaskState::Queued {
379 task.started_at.get_or_insert(now);
380 }
381 if state.is_terminal() {
382 task.finished_at.get_or_insert(now);
383 }
384 if state != SessionTaskState::AwaitingInput {
385 task.input_request = None;
386 }
387 task.state = state;
388 }
389
390 if let Some(detail) = update.state_detail {
391 task.state_detail = Some(detail);
392 }
393 if let Some(progress) = update.progress {
394 task.progress = Some(progress);
395 }
396 if let Some(summary) = update.summary {
397 task.summary = Some(summary);
398 }
399 if let Some(result_path) = update.result_path {
400 task.result_path = Some(result_path);
401 }
402 if let Some(artifacts) = update.artifacts {
403 task.artifacts = artifacts;
404 }
405 if let Some(error) = update.error {
406 task.error = Some(error);
407 }
408 if let Some(links) = update.links {
409 if links.child_session_id.is_some() {
410 task.links.child_session_id = links.child_session_id;
411 }
412 if links.remote_task_id.is_some() {
413 task.links.remote_task_id = links.remote_task_id;
414 }
415 for id in links.resource_ids {
416 if !task.links.resource_ids.contains(&id) {
417 task.links.resource_ids.push(id);
418 }
419 }
420 }
421 if let Some(worker_id) = update.worker_id {
422 task.worker_id = Some(worker_id);
423 }
424 if let Some(heartbeat_at) = update.heartbeat_at {
425 task.heartbeat_at = Some(heartbeat_at);
426 }
427
428 task.updated_at = now;
429}
430
431pub fn new_session_task(input: CreateSessionTask, now: DateTime<Utc>) -> SessionTask {
433 let state = input.state;
434 SessionTask {
435 id: input.id.unwrap_or_else(generate_task_id),
436 session_id: input.session_id,
437 kind: input.kind,
438 display_name: input.display_name,
439 spec: input.spec,
440 state,
441 state_detail: None,
442 progress: None,
443 input_request: None,
444 cancel_requested_at: None,
445 summary: None,
446 result_path: None,
447 artifacts: Vec::new(),
448 error: None,
449 attempt: 1,
450 worker_id: None,
451 heartbeat_at: None,
452 links: input.links,
453 wake_policy: input.wake_policy,
454 created_at: now,
455 started_at: if state == SessionTaskState::Queued {
456 None
457 } else {
458 Some(now)
459 },
460 finished_at: if state.is_terminal() { Some(now) } else { None },
461 updated_at: now,
462 }
463}
464
465#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
471#[cfg_attr(feature = "openapi", derive(ToSchema))]
472#[serde(rename_all = "snake_case")]
473pub enum TaskMessageDirection {
474 Inbound,
475 Outbound,
476}
477
478impl std::fmt::Display for TaskMessageDirection {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 match self {
481 Self::Inbound => write!(f, "inbound"),
482 Self::Outbound => write!(f, "outbound"),
483 }
484 }
485}
486
487impl From<&str> for TaskMessageDirection {
488 fn from(s: &str) -> Self {
489 match s {
490 "outbound" => Self::Outbound,
491 _ => Self::Inbound,
492 }
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498#[cfg_attr(feature = "openapi", derive(ToSchema))]
499#[serde(tag = "type", rename_all = "snake_case")]
500pub enum TaskMessagePart {
501 Text {
502 text: String,
503 },
504 Data {
505 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
506 data: Value,
507 },
508}
509
510impl TaskMessagePart {
511 pub fn text(text: impl Into<String>) -> Self {
512 Self::Text { text: text.into() }
513 }
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize)]
518#[cfg_attr(feature = "openapi", derive(ToSchema))]
519pub struct TaskMessage {
520 pub id: String,
522 pub task_id: String,
523 pub direction: TaskMessageDirection,
524 pub content: Vec<TaskMessagePart>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub in_reply_to: Option<String>,
528 pub created_at: DateTime<Utc>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct NewTaskMessage {
534 pub direction: TaskMessageDirection,
535 pub content: Vec<TaskMessagePart>,
536 #[serde(default)]
537 pub in_reply_to: Option<String>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub expected_attempt: Option<i32>,
544}
545
546impl NewTaskMessage {
547 pub fn inbound_text(text: impl Into<String>) -> Self {
548 Self {
549 direction: TaskMessageDirection::Inbound,
550 content: vec![TaskMessagePart::text(text)],
551 in_reply_to: None,
552 expected_attempt: None,
553 }
554 }
555
556 pub fn outbound_text(text: impl Into<String>) -> Self {
557 Self {
558 direction: TaskMessageDirection::Outbound,
559 content: vec![TaskMessagePart::text(text)],
560 in_reply_to: None,
561 expected_attempt: None,
562 }
563 }
564
565 pub fn with_expected_attempt(mut self, attempt: i32) -> Self {
567 self.expected_attempt = Some(attempt);
568 self
569 }
570}
571
572pub fn task_message_text(content: &[TaskMessagePart]) -> String {
574 content
575 .iter()
576 .filter_map(|part| match part {
577 TaskMessagePart::Text { text } => Some(text.as_str()),
578 TaskMessagePart::Data { .. } => None,
579 })
580 .collect::<Vec<_>>()
581 .join("\n")
582}
583
584#[async_trait]
592pub trait SessionTaskRegistry: Send + Sync {
593 async fn create(&self, input: CreateSessionTask) -> Result<SessionTask>;
596
597 async fn update(
599 &self,
600 session_id: SessionId,
601 task_id: &str,
602 update: SessionTaskUpdate,
603 ) -> Result<Option<SessionTask>>;
604
605 async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>>;
606
607 async fn list(
608 &self,
609 session_id: SessionId,
610 filter: Option<&SessionTaskFilter>,
611 ) -> Result<Vec<SessionTask>>;
612
613 async fn request_cancel(
616 &self,
617 session_id: SessionId,
618 task_id: &str,
619 ) -> Result<Option<SessionTask>>;
620
621 async fn record_message(
625 &self,
626 session_id: SessionId,
627 task_id: &str,
628 message: NewTaskMessage,
629 ) -> Result<TaskMessage>;
630
631 async fn list_messages(
638 &self,
639 session_id: SessionId,
640 task_id: &str,
641 limit: Option<u32>,
642 after_id: Option<&str>,
643 ) -> Result<Vec<TaskMessage>>;
644}
645
646#[async_trait]
656pub trait TaskExecutor: Send + Sync {
657 fn kind(&self) -> &str;
658
659 fn can_reattach(&self) -> bool {
667 false
668 }
669
670 fn can_reattach_task(&self, task: &SessionTask) -> bool {
677 let _ = task;
678 self.can_reattach()
679 }
680
681 async fn start(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()> {
687 let _ = (task, context);
688 Err(crate::error::AgentLoopError::tool(format!(
689 "task kind '{}' does not support start via the registry",
690 self.kind()
691 )))
692 }
693
694 async fn deliver(
696 &self,
697 task: &SessionTask,
698 message: &TaskMessage,
699 context: &crate::traits::ToolContext,
700 ) -> Result<()> {
701 let _ = (task, message, context);
702 Err(crate::error::AgentLoopError::tool(format!(
703 "task kind '{}' does not accept inbound messages",
704 self.kind()
705 )))
706 }
707
708 async fn cancel(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()>;
710
711 async fn reconcile(
714 &self,
715 task: &SessionTask,
716 context: &crate::traits::ToolContext,
717 ) -> Result<()> {
718 let _ = (task, context);
719 Ok(())
720 }
721}
722
723pub struct TaskExecutorPlugin {
726 pub executor: fn() -> Arc<dyn TaskExecutor>,
727}
728
729inventory::collect!(TaskExecutorPlugin);
730
731pub fn find_task_executor(kind: &str) -> Option<Arc<dyn TaskExecutor>> {
733 inventory::iter::<TaskExecutorPlugin>
734 .into_iter()
735 .map(|plugin| (plugin.executor)())
736 .find(|executor| executor.kind() == kind)
737}
738
739#[async_trait]
747pub trait TaskSink: Send + Sync {
748 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()>;
749
750 async fn progress(&self, progress: TaskProgress) -> Result<()>;
751
752 async fn output(&self, stream: &str, delta: &str) -> Result<()>;
754
755 async fn post(&self, message: NewTaskMessage) -> Result<()>;
757
758 async fn request_input(&self, request: TaskInputRequest) -> Result<()>;
760
761 async fn artifact(&self, artifact: TaskArtifact) -> Result<()>;
762}
763
764pub struct RegistryTaskSink {
771 registry: Arc<dyn SessionTaskRegistry>,
772 session_id: SessionId,
773 task_id: String,
774 attempt: i32,
776}
777
778impl RegistryTaskSink {
779 pub fn new(
780 registry: Arc<dyn SessionTaskRegistry>,
781 session_id: SessionId,
782 task_id: String,
783 ) -> Self {
784 Self {
785 registry,
786 session_id,
787 task_id,
788 attempt: 1,
789 }
790 }
791
792 pub fn with_attempt(mut self, attempt: i32) -> Self {
795 self.attempt = attempt;
796 self
797 }
798}
799
800#[async_trait]
801impl TaskSink for RegistryTaskSink {
802 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()> {
803 self.registry
804 .update(
805 self.session_id,
806 &self.task_id,
807 SessionTaskUpdate {
808 state: Some(state),
809 state_detail: detail,
810 expected_attempt: Some(self.attempt),
811 ..Default::default()
812 },
813 )
814 .await?;
815 Ok(())
816 }
817
818 async fn progress(&self, progress: TaskProgress) -> Result<()> {
819 self.registry
820 .update(
821 self.session_id,
822 &self.task_id,
823 SessionTaskUpdate {
824 progress: Some(progress),
825 expected_attempt: Some(self.attempt),
826 ..Default::default()
827 },
828 )
829 .await?;
830 Ok(())
831 }
832
833 async fn output(&self, _stream: &str, _delta: &str) -> Result<()> {
834 Ok(())
835 }
836
837 async fn post(&self, message: NewTaskMessage) -> Result<()> {
838 self.registry
841 .record_message(
842 self.session_id,
843 &self.task_id,
844 message.with_expected_attempt(self.attempt),
845 )
846 .await?;
847 Ok(())
848 }
849
850 async fn request_input(&self, request: TaskInputRequest) -> Result<()> {
851 self.registry
852 .update(
853 self.session_id,
854 &self.task_id,
855 SessionTaskUpdate {
856 input_request: Some(request),
857 expected_attempt: Some(self.attempt),
858 ..Default::default()
859 },
860 )
861 .await?;
862 Ok(())
863 }
864
865 async fn artifact(&self, artifact: TaskArtifact) -> Result<()> {
866 let Some(task) = self.registry.get(self.session_id, &self.task_id).await? else {
867 return Ok(());
868 };
869 if task.attempt != self.attempt {
871 return Ok(());
872 }
873 let mut artifacts = task.artifacts;
874 artifacts.push(artifact);
875 self.registry
876 .update(
877 self.session_id,
878 &self.task_id,
879 SessionTaskUpdate {
880 artifacts: Some(artifacts),
881 expected_attempt: Some(self.attempt),
882 ..Default::default()
883 },
884 )
885 .await?;
886 Ok(())
887 }
888}
889
890pub fn task_vfs_dir(task_id: &str) -> String {
892 format!("/.tasks/{task_id}")
893}
894
895pub fn task_result_path(task_id: &str) -> String {
897 format!("/.tasks/{task_id}/result.json")
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903
904 fn task() -> SessionTask {
905 new_session_task(
906 CreateSessionTask {
907 session_id: SessionId::new(),
908 id: None,
909 kind: TASK_KIND_BACKGROUND_TOOL.to_string(),
910 display_name: "Test".to_string(),
911 spec: serde_json::json!({}),
912 state: SessionTaskState::Queued,
913 links: TaskLinks::default(),
914 wake_policy: TaskWakePolicy::Silent,
915 },
916 Utc::now(),
917 )
918 }
919
920 #[test]
921 fn create_generates_prefixed_id() {
922 let t = task();
923 assert!(t.id.starts_with("task_"));
924 assert_eq!(t.state, SessionTaskState::Queued);
925 assert!(t.started_at.is_none());
926 }
927
928 #[test]
929 fn first_transition_out_of_queued_stamps_started_at() {
930 let mut t = task();
931 let now = Utc::now();
932 apply_task_update(
933 &mut t,
934 SessionTaskUpdate {
935 state: Some(SessionTaskState::Running),
936 ..Default::default()
937 },
938 now,
939 );
940 assert_eq!(t.state, SessionTaskState::Running);
941 assert_eq!(t.started_at, Some(now));
942 assert!(t.finished_at.is_none());
943 }
944
945 #[test]
946 fn terminal_transition_stamps_finished_at_and_is_final() {
947 let mut t = task();
948 let now = Utc::now();
949 apply_task_update(
950 &mut t,
951 SessionTaskUpdate {
952 state: Some(SessionTaskState::Succeeded),
953 summary: Some("done".to_string()),
954 ..Default::default()
955 },
956 now,
957 );
958 assert_eq!(t.state, SessionTaskState::Succeeded);
959 assert_eq!(t.finished_at, Some(now));
960
961 apply_task_update(
966 &mut t,
967 SessionTaskUpdate {
968 state: Some(SessionTaskState::Failed),
969 error: Some(TaskError {
970 kind: "orphaned".to_string(),
971 message: "stale".to_string(),
972 }),
973 ..Default::default()
974 },
975 Utc::now(),
976 );
977 assert_eq!(t.state, SessionTaskState::Succeeded);
978 assert!(t.error.is_none());
979
980 apply_task_update(
982 &mut t,
983 SessionTaskUpdate {
984 state: Some(SessionTaskState::Succeeded),
985 result_path: Some("/.tasks/x/result.json".to_string()),
986 ..Default::default()
987 },
988 Utc::now(),
989 );
990 assert_eq!(t.result_path.as_deref(), Some("/.tasks/x/result.json"));
991
992 apply_task_update(
994 &mut t,
995 SessionTaskUpdate {
996 summary: Some("enriched".to_string()),
997 ..Default::default()
998 },
999 Utc::now(),
1000 );
1001 assert_eq!(t.summary.as_deref(), Some("enriched"));
1002 }
1003
1004 #[test]
1005 fn input_request_forces_awaiting_input_and_clears_on_resume() {
1006 let mut t = task();
1007 apply_task_update(
1008 &mut t,
1009 SessionTaskUpdate {
1010 input_request: Some(TaskInputRequest {
1011 id: "req_1".to_string(),
1012 prompt: "Approve?".to_string(),
1013 expected: None,
1014 }),
1015 ..Default::default()
1016 },
1017 Utc::now(),
1018 );
1019 assert_eq!(t.state, SessionTaskState::AwaitingInput);
1020 assert!(t.input_request.is_some());
1021
1022 apply_task_update(
1023 &mut t,
1024 SessionTaskUpdate {
1025 state: Some(SessionTaskState::Running),
1026 ..Default::default()
1027 },
1028 Utc::now(),
1029 );
1030 assert_eq!(t.state, SessionTaskState::Running);
1031 assert!(t.input_request.is_none());
1032 }
1033
1034 #[test]
1035 fn links_merge_without_duplicates() {
1036 let mut t = task();
1037 let child = SessionId::new();
1038 apply_task_update(
1039 &mut t,
1040 SessionTaskUpdate {
1041 links: Some(TaskLinks {
1042 child_session_id: Some(child),
1043 remote_task_id: None,
1044 resource_ids: vec!["res_1".to_string()],
1045 }),
1046 ..Default::default()
1047 },
1048 Utc::now(),
1049 );
1050 apply_task_update(
1051 &mut t,
1052 SessionTaskUpdate {
1053 links: Some(TaskLinks {
1054 child_session_id: None,
1055 remote_task_id: Some("rt_1".to_string()),
1056 resource_ids: vec!["res_1".to_string(), "res_2".to_string()],
1057 }),
1058 ..Default::default()
1059 },
1060 Utc::now(),
1061 );
1062 assert_eq!(t.links.child_session_id, Some(child));
1063 assert_eq!(t.links.remote_task_id.as_deref(), Some("rt_1"));
1064 assert_eq!(t.links.resource_ids, vec!["res_1", "res_2"]);
1065 }
1066
1067 #[test]
1068 fn message_text_rendering() {
1069 let msg = NewTaskMessage::outbound_text("hello");
1070 assert_eq!(task_message_text(&msg.content), "hello");
1071 }
1072
1073 #[test]
1078 fn update_with_matching_attempt_applies() {
1079 let mut t = task();
1080 assert_eq!(t.attempt, 1);
1082 let now = Utc::now();
1083
1084 apply_task_update(
1086 &mut t,
1087 SessionTaskUpdate {
1088 state: Some(SessionTaskState::Running),
1089 state_detail: Some("step 1".to_string()),
1090 heartbeat_at: Some(now),
1091 expected_attempt: Some(1),
1092 ..Default::default()
1093 },
1094 now,
1095 );
1096 assert_eq!(t.state, SessionTaskState::Running);
1097 assert_eq!(t.state_detail.as_deref(), Some("step 1"));
1098 assert_eq!(t.heartbeat_at, Some(now));
1099 }
1100
1101 #[test]
1102 fn update_with_stale_attempt_is_fully_ignored() {
1103 let mut t = task();
1104 t.attempt = 2;
1106
1107 let now = Utc::now();
1108 let before_updated = t.updated_at;
1109
1110 apply_task_update(
1113 &mut t,
1114 SessionTaskUpdate {
1115 state: Some(SessionTaskState::Running),
1116 state_detail: Some("superseded".to_string()),
1117 heartbeat_at: Some(now),
1118 expected_attempt: Some(1),
1119 ..Default::default()
1120 },
1121 now,
1122 );
1123 assert_eq!(t.state, SessionTaskState::Queued, "state must be unchanged");
1124 assert!(t.state_detail.is_none(), "state_detail must be unchanged");
1125 assert!(t.heartbeat_at.is_none(), "heartbeat must be unchanged");
1126 assert_eq!(t.updated_at, before_updated, "updated_at must be unchanged");
1127 }
1128
1129 #[test]
1130 fn update_with_none_expected_attempt_applies_regardless() {
1131 let mut t = task();
1132 t.attempt = 99;
1134 let now = Utc::now();
1135
1136 apply_task_update(
1137 &mut t,
1138 SessionTaskUpdate {
1139 summary: Some("cancel from API".to_string()),
1140 expected_attempt: None,
1141 ..Default::default()
1142 },
1143 now,
1144 );
1145 assert_eq!(t.summary.as_deref(), Some("cancel from API"));
1147 }
1148
1149 #[test]
1150 fn reaper_update_increments_attempt_and_fences_old_executor() {
1151 let mut t = task();
1152 t.state = SessionTaskState::Running;
1153 assert_eq!(t.attempt, 1);
1154 let now = Utc::now();
1155
1156 apply_task_update(
1158 &mut t,
1159 SessionTaskUpdate {
1160 state: Some(SessionTaskState::Failed),
1161 error: Some(TaskError {
1162 kind: "orphaned".to_string(),
1163 message: "worker heartbeat stopped".to_string(),
1164 }),
1165 increment_attempt: true,
1166 ..Default::default()
1167 },
1168 now,
1169 );
1170 assert_eq!(t.state, SessionTaskState::Failed);
1171 assert_eq!(t.attempt, 2, "orphan reap must supersede the attempt");
1172
1173 let later = now + chrono::Duration::seconds(5);
1176 apply_task_update(
1177 &mut t,
1178 SessionTaskUpdate {
1179 heartbeat_at: Some(later),
1180 expected_attempt: Some(1),
1181 ..Default::default()
1182 },
1183 later,
1184 );
1185 assert_ne!(
1186 t.heartbeat_at,
1187 Some(later),
1188 "stale heartbeat must be rejected"
1189 );
1190 }
1191
1192 #[test]
1193 fn increment_attempt_is_inert_when_update_is_dropped() {
1194 let mut t = task();
1195 t.state = SessionTaskState::Succeeded;
1196 assert_eq!(t.attempt, 1);
1197 let now = Utc::now();
1198
1199 apply_task_update(
1202 &mut t,
1203 SessionTaskUpdate {
1204 state: Some(SessionTaskState::Failed),
1205 error: Some(TaskError {
1206 kind: "orphaned".to_string(),
1207 message: "worker heartbeat stopped".to_string(),
1208 }),
1209 increment_attempt: true,
1210 ..Default::default()
1211 },
1212 now,
1213 );
1214 assert_eq!(t.state, SessionTaskState::Succeeded);
1215 assert_eq!(t.attempt, 1, "dropped update must not bump the attempt");
1216 }
1217}