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_SESSION: &str = "session";
35pub const TASK_KIND_AGENT_HANDOFF: &str = "agent_handoff";
40pub const TASK_KIND_EXTERNAL_AGENT: &str = "external_agent";
41pub const TASK_KIND_BACKGROUND_TOOL: &str = "background_tool";
42pub const TASK_KIND_MONITOR: &str = "monitor";
45
46pub fn generate_task_id() -> String {
48 format!("task_{}", uuid::Uuid::now_v7().simple())
49}
50
51pub fn generate_task_message_id() -> String {
53 format!("tmsg_{}", uuid::Uuid::now_v7().simple())
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
62#[cfg_attr(feature = "openapi", derive(ToSchema))]
63#[serde(rename_all = "snake_case")]
64pub enum SessionTaskState {
65 Queued,
66 Running,
67 AwaitingInput,
68 Succeeded,
69 Failed,
70 Canceled,
71}
72
73impl SessionTaskState {
74 pub fn is_terminal(&self) -> bool {
75 matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
76 }
77
78 pub fn parse(s: &str) -> Option<Self> {
83 match s {
84 "queued" => Some(Self::Queued),
85 "running" => Some(Self::Running),
86 "awaiting_input" => Some(Self::AwaitingInput),
87 "succeeded" => Some(Self::Succeeded),
88 "failed" => Some(Self::Failed),
89 "canceled" => Some(Self::Canceled),
90 _ => None,
91 }
92 }
93}
94
95impl std::fmt::Display for SessionTaskState {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 let s = match self {
98 Self::Queued => "queued",
99 Self::Running => "running",
100 Self::AwaitingInput => "awaiting_input",
101 Self::Succeeded => "succeeded",
102 Self::Failed => "failed",
103 Self::Canceled => "canceled",
104 };
105 write!(f, "{s}")
106 }
107}
108
109impl From<&str> for SessionTaskState {
110 fn from(s: &str) -> Self {
111 match s {
112 "running" => Self::Running,
113 "awaiting_input" => Self::AwaitingInput,
114 "succeeded" => Self::Succeeded,
115 "failed" => Self::Failed,
116 "canceled" => Self::Canceled,
117 _ => Self::Queued,
118 }
119 }
120}
121
122#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
124#[cfg_attr(feature = "openapi", derive(ToSchema))]
125#[serde(rename_all = "snake_case")]
126pub enum TaskWakePolicy {
127 #[default]
129 Silent,
130 OnTerminal,
132 OnActivity,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[cfg_attr(feature = "openapi", derive(ToSchema))]
139pub struct TaskInputRequest {
140 pub id: String,
142 pub prompt: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
147 pub expected: Option<Value>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[cfg_attr(feature = "openapi", derive(ToSchema))]
153pub struct TaskError {
154 pub kind: String,
155 pub message: String,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160#[cfg_attr(feature = "openapi", derive(ToSchema))]
161pub struct TaskArtifact {
162 pub name: String,
163 #[serde(rename = "type")]
165 pub artifact_type: String,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub path: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub url: Option<String>,
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
176#[cfg_attr(feature = "openapi", derive(ToSchema))]
177pub struct TaskLinks {
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
181 pub child_session_id: Option<SessionId>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub remote_task_id: Option<String>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub resource_ids: Vec<String>,
188}
189
190impl TaskLinks {
191 pub fn is_empty(&self) -> bool {
192 self.child_session_id.is_none()
193 && self.remote_task_id.is_none()
194 && self.resource_ids.is_empty()
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200#[cfg_attr(feature = "openapi", derive(ToSchema))]
201pub struct SessionTask {
202 pub id: String,
204 #[cfg_attr(feature = "openapi", schema(value_type = String))]
206 pub session_id: SessionId,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
213 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
214 pub root_session_id: Option<SessionId>,
215 pub kind: String,
217 pub display_name: String,
219 #[serde(default)]
221 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
222 pub spec: Value,
223 pub state: SessionTaskState,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub state_detail: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub progress: Option<TaskProgress>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub input_request: Option<TaskInputRequest>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub cancel_requested_at: Option<DateTime<Utc>>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub summary: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub result_path: Option<String>,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub artifacts: Vec<TaskArtifact>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub error: Option<TaskError>,
245 #[serde(default = "default_attempt")]
247 pub attempt: i32,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub worker_id: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub heartbeat_at: Option<DateTime<Utc>>,
252 #[serde(default, skip_serializing_if = "TaskLinks::is_empty")]
253 pub links: TaskLinks,
254 #[serde(default)]
255 pub wake_policy: TaskWakePolicy,
256 pub created_at: DateTime<Utc>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub started_at: Option<DateTime<Utc>>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub finished_at: Option<DateTime<Utc>>,
261 pub updated_at: DateTime<Utc>,
262}
263
264fn default_attempt() -> i32 {
265 1
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct CreateSessionTask {
271 pub session_id: SessionId,
272 #[serde(default)]
274 pub id: Option<String>,
275 pub kind: String,
276 pub display_name: String,
277 #[serde(default)]
278 pub spec: Value,
279 #[serde(default = "default_queued")]
281 pub state: SessionTaskState,
282 #[serde(default)]
283 pub links: TaskLinks,
284 #[serde(default)]
285 pub wake_policy: TaskWakePolicy,
286}
287
288fn default_queued() -> SessionTaskState {
289 SessionTaskState::Queued
290}
291
292#[derive(Debug, Clone, Default, Serialize, Deserialize)]
294pub struct SessionTaskUpdate {
295 pub state: Option<SessionTaskState>,
296 pub state_detail: Option<String>,
297 pub progress: Option<TaskProgress>,
298 pub input_request: Option<TaskInputRequest>,
300 pub summary: Option<String>,
301 pub result_path: Option<String>,
302 pub artifacts: Option<Vec<TaskArtifact>>,
304 pub error: Option<TaskError>,
305 pub links: Option<TaskLinks>,
307 pub worker_id: Option<String>,
308 pub heartbeat_at: Option<DateTime<Utc>>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub expected_attempt: Option<i32>,
318 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
323 pub increment_attempt: bool,
324}
325
326#[derive(Debug, Clone, Default)]
328pub struct SessionTaskFilter {
329 pub kind: Option<String>,
330 pub state: Option<SessionTaskState>,
331}
332
333pub fn apply_task_update(task: &mut SessionTask, update: SessionTaskUpdate, now: DateTime<Utc>) {
344 if let Some(expected) = update.expected_attempt
348 && expected != task.attempt
349 {
350 return;
351 }
352
353 let was_terminal = task.state.is_terminal();
354
355 if was_terminal
362 && let Some(state) = update.state
363 && state != task.state
364 {
365 return;
366 }
367
368 if update.increment_attempt {
371 task.attempt += 1;
372 }
373
374 let mut next_state = update.state;
375 if update.input_request.is_some() && !was_terminal {
376 next_state = Some(SessionTaskState::AwaitingInput);
377 }
378
379 if let Some(input_request) = update.input_request
380 && !was_terminal
381 {
382 task.input_request = Some(input_request);
383 }
384
385 if let Some(state) = next_state
386 && !was_terminal
387 && task.state != state
388 {
389 if task.state == SessionTaskState::Queued && state != SessionTaskState::Queued {
390 task.started_at.get_or_insert(now);
391 }
392 if state.is_terminal() {
393 task.finished_at.get_or_insert(now);
394 }
395 if state != SessionTaskState::AwaitingInput {
396 task.input_request = None;
397 }
398 task.state = state;
399 }
400
401 if let Some(detail) = update.state_detail {
402 task.state_detail = Some(detail);
403 }
404 if let Some(progress) = update.progress {
405 task.progress = Some(progress);
406 }
407 if let Some(summary) = update.summary {
408 task.summary = Some(summary);
409 }
410 if let Some(result_path) = update.result_path {
411 task.result_path = Some(result_path);
412 }
413 if let Some(artifacts) = update.artifacts {
414 task.artifacts = artifacts;
415 }
416 if let Some(error) = update.error {
417 task.error = Some(error);
418 }
419 if let Some(links) = update.links {
420 if links.child_session_id.is_some() {
421 task.links.child_session_id = links.child_session_id;
422 }
423 if links.remote_task_id.is_some() {
424 task.links.remote_task_id = links.remote_task_id;
425 }
426 for id in links.resource_ids {
427 if !task.links.resource_ids.contains(&id) {
428 task.links.resource_ids.push(id);
429 }
430 }
431 }
432 if let Some(worker_id) = update.worker_id {
433 task.worker_id = Some(worker_id);
434 }
435 if let Some(heartbeat_at) = update.heartbeat_at {
436 task.heartbeat_at = Some(heartbeat_at);
437 }
438
439 task.updated_at = now;
440}
441
442pub fn new_session_task(input: CreateSessionTask, now: DateTime<Utc>) -> SessionTask {
444 let state = input.state;
445 SessionTask {
446 id: input.id.unwrap_or_else(generate_task_id),
447 session_id: input.session_id,
448 root_session_id: None,
451 kind: input.kind,
452 display_name: input.display_name,
453 spec: input.spec,
454 state,
455 state_detail: None,
456 progress: None,
457 input_request: None,
458 cancel_requested_at: None,
459 summary: None,
460 result_path: None,
461 artifacts: Vec::new(),
462 error: None,
463 attempt: 1,
464 worker_id: None,
465 heartbeat_at: None,
466 links: input.links,
467 wake_policy: input.wake_policy,
468 created_at: now,
469 started_at: if state == SessionTaskState::Queued {
470 None
471 } else {
472 Some(now)
473 },
474 finished_at: if state.is_terminal() { Some(now) } else { None },
475 updated_at: now,
476 }
477}
478
479#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
485#[cfg_attr(feature = "openapi", derive(ToSchema))]
486#[serde(rename_all = "snake_case")]
487pub enum TaskMessageDirection {
488 Inbound,
489 Outbound,
490}
491
492impl std::fmt::Display for TaskMessageDirection {
493 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494 match self {
495 Self::Inbound => write!(f, "inbound"),
496 Self::Outbound => write!(f, "outbound"),
497 }
498 }
499}
500
501impl From<&str> for TaskMessageDirection {
502 fn from(s: &str) -> Self {
503 match s {
504 "outbound" => Self::Outbound,
505 _ => Self::Inbound,
506 }
507 }
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
512#[cfg_attr(feature = "openapi", derive(ToSchema))]
513#[serde(tag = "type", rename_all = "snake_case")]
514pub enum TaskMessagePart {
515 Text {
516 text: String,
517 },
518 Data {
519 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
520 data: Value,
521 },
522}
523
524impl TaskMessagePart {
525 pub fn text(text: impl Into<String>) -> Self {
526 Self::Text { text: text.into() }
527 }
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532#[cfg_attr(feature = "openapi", derive(ToSchema))]
533pub struct TaskMessage {
534 pub id: String,
536 pub task_id: String,
537 pub direction: TaskMessageDirection,
538 pub content: Vec<TaskMessagePart>,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub in_reply_to: Option<String>,
542 pub created_at: DateTime<Utc>,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
547pub struct NewTaskMessage {
548 pub direction: TaskMessageDirection,
549 pub content: Vec<TaskMessagePart>,
550 #[serde(default)]
551 pub in_reply_to: Option<String>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub expected_attempt: Option<i32>,
558}
559
560impl NewTaskMessage {
561 pub fn inbound_text(text: impl Into<String>) -> Self {
562 Self {
563 direction: TaskMessageDirection::Inbound,
564 content: vec![TaskMessagePart::text(text)],
565 in_reply_to: None,
566 expected_attempt: None,
567 }
568 }
569
570 pub fn outbound_text(text: impl Into<String>) -> Self {
571 Self {
572 direction: TaskMessageDirection::Outbound,
573 content: vec![TaskMessagePart::text(text)],
574 in_reply_to: None,
575 expected_attempt: None,
576 }
577 }
578
579 pub fn with_expected_attempt(mut self, attempt: i32) -> Self {
581 self.expected_attempt = Some(attempt);
582 self
583 }
584}
585
586pub fn task_message_text(content: &[TaskMessagePart]) -> String {
588 content
589 .iter()
590 .filter_map(|part| match part {
591 TaskMessagePart::Text { text } => Some(text.as_str()),
592 TaskMessagePart::Data { .. } => None,
593 })
594 .collect::<Vec<_>>()
595 .join("\n")
596}
597
598#[async_trait]
606pub trait SessionTaskRegistry: Send + Sync {
607 async fn create(&self, input: CreateSessionTask) -> Result<SessionTask>;
610
611 async fn update(
613 &self,
614 session_id: SessionId,
615 task_id: &str,
616 update: SessionTaskUpdate,
617 ) -> Result<Option<SessionTask>>;
618
619 async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>>;
620
621 async fn list(
622 &self,
623 session_id: SessionId,
624 filter: Option<&SessionTaskFilter>,
625 ) -> Result<Vec<SessionTask>>;
626
627 async fn request_cancel(
630 &self,
631 session_id: SessionId,
632 task_id: &str,
633 ) -> Result<Option<SessionTask>>;
634
635 async fn record_message(
639 &self,
640 session_id: SessionId,
641 task_id: &str,
642 message: NewTaskMessage,
643 ) -> Result<TaskMessage>;
644
645 async fn list_messages(
652 &self,
653 session_id: SessionId,
654 task_id: &str,
655 limit: Option<u32>,
656 after_id: Option<&str>,
657 ) -> Result<Vec<TaskMessage>>;
658}
659
660#[async_trait]
670pub trait TaskExecutor: Send + Sync {
671 fn kind(&self) -> &str;
672
673 fn can_reattach(&self) -> bool {
681 false
682 }
683
684 fn can_reattach_task(&self, task: &SessionTask) -> bool {
691 let _ = task;
692 self.can_reattach()
693 }
694
695 async fn start(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()> {
701 let _ = (task, context);
702 Err(crate::error::AgentLoopError::tool(format!(
703 "task kind '{}' does not support start via the registry",
704 self.kind()
705 )))
706 }
707
708 async fn deliver(
710 &self,
711 task: &SessionTask,
712 message: &TaskMessage,
713 context: &crate::traits::ToolContext,
714 ) -> Result<()> {
715 let _ = (task, message, context);
716 Err(crate::error::AgentLoopError::tool(format!(
717 "task kind '{}' does not accept inbound messages",
718 self.kind()
719 )))
720 }
721
722 async fn cancel(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()>;
724
725 async fn reconcile(
728 &self,
729 task: &SessionTask,
730 context: &crate::traits::ToolContext,
731 ) -> Result<()> {
732 let _ = (task, context);
733 Ok(())
734 }
735}
736
737pub struct TaskExecutorPlugin {
740 pub executor: fn() -> Arc<dyn TaskExecutor>,
741}
742
743inventory::collect!(TaskExecutorPlugin);
744
745pub fn find_task_executor(kind: &str) -> Option<Arc<dyn TaskExecutor>> {
747 inventory::iter::<TaskExecutorPlugin>
748 .into_iter()
749 .map(|plugin| (plugin.executor)())
750 .find(|executor| executor.kind() == kind)
751}
752
753#[async_trait]
761pub trait TaskSink: Send + Sync {
762 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()>;
763
764 async fn progress(&self, progress: TaskProgress) -> Result<()>;
765
766 async fn output(&self, stream: &str, delta: &str) -> Result<()>;
768
769 async fn post(&self, message: NewTaskMessage) -> Result<()>;
771
772 async fn request_input(&self, request: TaskInputRequest) -> Result<()>;
774
775 async fn artifact(&self, artifact: TaskArtifact) -> Result<()>;
776}
777
778pub struct RegistryTaskSink {
785 registry: Arc<dyn SessionTaskRegistry>,
786 session_id: SessionId,
787 task_id: String,
788 attempt: i32,
790}
791
792impl RegistryTaskSink {
793 pub fn new(
794 registry: Arc<dyn SessionTaskRegistry>,
795 session_id: SessionId,
796 task_id: String,
797 ) -> Self {
798 Self {
799 registry,
800 session_id,
801 task_id,
802 attempt: 1,
803 }
804 }
805
806 pub fn with_attempt(mut self, attempt: i32) -> Self {
809 self.attempt = attempt;
810 self
811 }
812}
813
814#[async_trait]
815impl TaskSink for RegistryTaskSink {
816 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()> {
817 self.registry
818 .update(
819 self.session_id,
820 &self.task_id,
821 SessionTaskUpdate {
822 state: Some(state),
823 state_detail: detail,
824 expected_attempt: Some(self.attempt),
825 ..Default::default()
826 },
827 )
828 .await?;
829 Ok(())
830 }
831
832 async fn progress(&self, progress: TaskProgress) -> Result<()> {
833 self.registry
834 .update(
835 self.session_id,
836 &self.task_id,
837 SessionTaskUpdate {
838 progress: Some(progress),
839 expected_attempt: Some(self.attempt),
840 ..Default::default()
841 },
842 )
843 .await?;
844 Ok(())
845 }
846
847 async fn output(&self, _stream: &str, _delta: &str) -> Result<()> {
848 Ok(())
849 }
850
851 async fn post(&self, message: NewTaskMessage) -> Result<()> {
852 self.registry
855 .record_message(
856 self.session_id,
857 &self.task_id,
858 message.with_expected_attempt(self.attempt),
859 )
860 .await?;
861 Ok(())
862 }
863
864 async fn request_input(&self, request: TaskInputRequest) -> Result<()> {
865 self.registry
866 .update(
867 self.session_id,
868 &self.task_id,
869 SessionTaskUpdate {
870 input_request: Some(request),
871 expected_attempt: Some(self.attempt),
872 ..Default::default()
873 },
874 )
875 .await?;
876 Ok(())
877 }
878
879 async fn artifact(&self, artifact: TaskArtifact) -> Result<()> {
880 let Some(task) = self.registry.get(self.session_id, &self.task_id).await? else {
881 return Ok(());
882 };
883 if task.attempt != self.attempt {
885 return Ok(());
886 }
887 let mut artifacts = task.artifacts;
888 artifacts.push(artifact);
889 self.registry
890 .update(
891 self.session_id,
892 &self.task_id,
893 SessionTaskUpdate {
894 artifacts: Some(artifacts),
895 expected_attempt: Some(self.attempt),
896 ..Default::default()
897 },
898 )
899 .await?;
900 Ok(())
901 }
902}
903
904pub fn task_vfs_dir(task_id: &str) -> String {
906 format!("/.tasks/{task_id}")
907}
908
909pub fn task_result_path(task_id: &str) -> String {
911 format!("/.tasks/{task_id}/result.json")
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917
918 fn task() -> SessionTask {
919 new_session_task(
920 CreateSessionTask {
921 session_id: SessionId::new(),
922 id: None,
923 kind: TASK_KIND_BACKGROUND_TOOL.to_string(),
924 display_name: "Test".to_string(),
925 spec: serde_json::json!({}),
926 state: SessionTaskState::Queued,
927 links: TaskLinks::default(),
928 wake_policy: TaskWakePolicy::Silent,
929 },
930 Utc::now(),
931 )
932 }
933
934 #[test]
935 fn create_generates_prefixed_id() {
936 let t = task();
937 assert!(t.id.starts_with("task_"));
938 assert_eq!(t.state, SessionTaskState::Queued);
939 assert!(t.started_at.is_none());
940 }
941
942 #[test]
943 fn first_transition_out_of_queued_stamps_started_at() {
944 let mut t = task();
945 let now = Utc::now();
946 apply_task_update(
947 &mut t,
948 SessionTaskUpdate {
949 state: Some(SessionTaskState::Running),
950 ..Default::default()
951 },
952 now,
953 );
954 assert_eq!(t.state, SessionTaskState::Running);
955 assert_eq!(t.started_at, Some(now));
956 assert!(t.finished_at.is_none());
957 }
958
959 #[test]
960 fn terminal_transition_stamps_finished_at_and_is_final() {
961 let mut t = task();
962 let now = Utc::now();
963 apply_task_update(
964 &mut t,
965 SessionTaskUpdate {
966 state: Some(SessionTaskState::Succeeded),
967 summary: Some("done".to_string()),
968 ..Default::default()
969 },
970 now,
971 );
972 assert_eq!(t.state, SessionTaskState::Succeeded);
973 assert_eq!(t.finished_at, Some(now));
974
975 apply_task_update(
980 &mut t,
981 SessionTaskUpdate {
982 state: Some(SessionTaskState::Failed),
983 error: Some(TaskError {
984 kind: "orphaned".to_string(),
985 message: "stale".to_string(),
986 }),
987 ..Default::default()
988 },
989 Utc::now(),
990 );
991 assert_eq!(t.state, SessionTaskState::Succeeded);
992 assert!(t.error.is_none());
993
994 apply_task_update(
996 &mut t,
997 SessionTaskUpdate {
998 state: Some(SessionTaskState::Succeeded),
999 result_path: Some("/.tasks/x/result.json".to_string()),
1000 ..Default::default()
1001 },
1002 Utc::now(),
1003 );
1004 assert_eq!(t.result_path.as_deref(), Some("/.tasks/x/result.json"));
1005
1006 apply_task_update(
1008 &mut t,
1009 SessionTaskUpdate {
1010 summary: Some("enriched".to_string()),
1011 ..Default::default()
1012 },
1013 Utc::now(),
1014 );
1015 assert_eq!(t.summary.as_deref(), Some("enriched"));
1016 }
1017
1018 #[test]
1019 fn input_request_forces_awaiting_input_and_clears_on_resume() {
1020 let mut t = task();
1021 apply_task_update(
1022 &mut t,
1023 SessionTaskUpdate {
1024 input_request: Some(TaskInputRequest {
1025 id: "req_1".to_string(),
1026 prompt: "Approve?".to_string(),
1027 expected: None,
1028 }),
1029 ..Default::default()
1030 },
1031 Utc::now(),
1032 );
1033 assert_eq!(t.state, SessionTaskState::AwaitingInput);
1034 assert!(t.input_request.is_some());
1035
1036 apply_task_update(
1037 &mut t,
1038 SessionTaskUpdate {
1039 state: Some(SessionTaskState::Running),
1040 ..Default::default()
1041 },
1042 Utc::now(),
1043 );
1044 assert_eq!(t.state, SessionTaskState::Running);
1045 assert!(t.input_request.is_none());
1046 }
1047
1048 #[test]
1049 fn links_merge_without_duplicates() {
1050 let mut t = task();
1051 let child = SessionId::new();
1052 apply_task_update(
1053 &mut t,
1054 SessionTaskUpdate {
1055 links: Some(TaskLinks {
1056 child_session_id: Some(child),
1057 remote_task_id: None,
1058 resource_ids: vec!["res_1".to_string()],
1059 }),
1060 ..Default::default()
1061 },
1062 Utc::now(),
1063 );
1064 apply_task_update(
1065 &mut t,
1066 SessionTaskUpdate {
1067 links: Some(TaskLinks {
1068 child_session_id: None,
1069 remote_task_id: Some("rt_1".to_string()),
1070 resource_ids: vec!["res_1".to_string(), "res_2".to_string()],
1071 }),
1072 ..Default::default()
1073 },
1074 Utc::now(),
1075 );
1076 assert_eq!(t.links.child_session_id, Some(child));
1077 assert_eq!(t.links.remote_task_id.as_deref(), Some("rt_1"));
1078 assert_eq!(t.links.resource_ids, vec!["res_1", "res_2"]);
1079 }
1080
1081 #[test]
1082 fn message_text_rendering() {
1083 let msg = NewTaskMessage::outbound_text("hello");
1084 assert_eq!(task_message_text(&msg.content), "hello");
1085 }
1086
1087 #[test]
1092 fn update_with_matching_attempt_applies() {
1093 let mut t = task();
1094 assert_eq!(t.attempt, 1);
1096 let now = Utc::now();
1097
1098 apply_task_update(
1100 &mut t,
1101 SessionTaskUpdate {
1102 state: Some(SessionTaskState::Running),
1103 state_detail: Some("step 1".to_string()),
1104 heartbeat_at: Some(now),
1105 expected_attempt: Some(1),
1106 ..Default::default()
1107 },
1108 now,
1109 );
1110 assert_eq!(t.state, SessionTaskState::Running);
1111 assert_eq!(t.state_detail.as_deref(), Some("step 1"));
1112 assert_eq!(t.heartbeat_at, Some(now));
1113 }
1114
1115 #[test]
1116 fn update_with_stale_attempt_is_fully_ignored() {
1117 let mut t = task();
1118 t.attempt = 2;
1120
1121 let now = Utc::now();
1122 let before_updated = t.updated_at;
1123
1124 apply_task_update(
1127 &mut t,
1128 SessionTaskUpdate {
1129 state: Some(SessionTaskState::Running),
1130 state_detail: Some("superseded".to_string()),
1131 heartbeat_at: Some(now),
1132 expected_attempt: Some(1),
1133 ..Default::default()
1134 },
1135 now,
1136 );
1137 assert_eq!(t.state, SessionTaskState::Queued, "state must be unchanged");
1138 assert!(t.state_detail.is_none(), "state_detail must be unchanged");
1139 assert!(t.heartbeat_at.is_none(), "heartbeat must be unchanged");
1140 assert_eq!(t.updated_at, before_updated, "updated_at must be unchanged");
1141 }
1142
1143 #[test]
1144 fn update_with_none_expected_attempt_applies_regardless() {
1145 let mut t = task();
1146 t.attempt = 99;
1148 let now = Utc::now();
1149
1150 apply_task_update(
1151 &mut t,
1152 SessionTaskUpdate {
1153 summary: Some("cancel from API".to_string()),
1154 expected_attempt: None,
1155 ..Default::default()
1156 },
1157 now,
1158 );
1159 assert_eq!(t.summary.as_deref(), Some("cancel from API"));
1161 }
1162
1163 #[test]
1164 fn reaper_update_increments_attempt_and_fences_old_executor() {
1165 let mut t = task();
1166 t.state = SessionTaskState::Running;
1167 assert_eq!(t.attempt, 1);
1168 let now = Utc::now();
1169
1170 apply_task_update(
1172 &mut t,
1173 SessionTaskUpdate {
1174 state: Some(SessionTaskState::Failed),
1175 error: Some(TaskError {
1176 kind: "orphaned".to_string(),
1177 message: "worker heartbeat stopped".to_string(),
1178 }),
1179 increment_attempt: true,
1180 ..Default::default()
1181 },
1182 now,
1183 );
1184 assert_eq!(t.state, SessionTaskState::Failed);
1185 assert_eq!(t.attempt, 2, "orphan reap must supersede the attempt");
1186
1187 let later = now + chrono::Duration::seconds(5);
1190 apply_task_update(
1191 &mut t,
1192 SessionTaskUpdate {
1193 heartbeat_at: Some(later),
1194 expected_attempt: Some(1),
1195 ..Default::default()
1196 },
1197 later,
1198 );
1199 assert_ne!(
1200 t.heartbeat_at,
1201 Some(later),
1202 "stale heartbeat must be rejected"
1203 );
1204 }
1205
1206 #[test]
1207 fn increment_attempt_is_inert_when_update_is_dropped() {
1208 let mut t = task();
1209 t.state = SessionTaskState::Succeeded;
1210 assert_eq!(t.attempt, 1);
1211 let now = Utc::now();
1212
1213 apply_task_update(
1216 &mut t,
1217 SessionTaskUpdate {
1218 state: Some(SessionTaskState::Failed),
1219 error: Some(TaskError {
1220 kind: "orphaned".to_string(),
1221 message: "worker heartbeat stopped".to_string(),
1222 }),
1223 increment_attempt: true,
1224 ..Default::default()
1225 },
1226 now,
1227 );
1228 assert_eq!(t.state, SessionTaskState::Succeeded);
1229 assert_eq!(t.attempt, 1, "dropped update must not bump the attempt");
1230 }
1231}