1use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize, Serializer};
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";
36pub const TASK_KIND_AGENT_HANDOFF: &str = "agent_handoff";
41pub const TASK_KIND_EXTERNAL_AGENT: &str = "external_agent";
42pub const TASK_KIND_BACKGROUND_TOOL: &str = "background_tool";
43pub const TASK_KIND_MONITOR: &str = "monitor";
46
47pub fn generate_task_id() -> String {
49 format!("task_{}", uuid::Uuid::now_v7().simple())
50}
51
52pub fn generate_task_message_id() -> String {
54 format!("tmsg_{}", uuid::Uuid::now_v7().simple())
55}
56
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
63#[cfg_attr(feature = "openapi", derive(ToSchema))]
64#[serde(rename_all = "snake_case")]
65pub enum SessionTaskState {
66 Queued,
67 Running,
68 AwaitingInput,
69 Succeeded,
70 Failed,
71 Canceled,
72}
73
74impl SessionTaskState {
75 pub fn is_terminal(&self) -> bool {
76 matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
77 }
78
79 pub fn parse(s: &str) -> Option<Self> {
84 match s {
85 "queued" => Some(Self::Queued),
86 "running" => Some(Self::Running),
87 "awaiting_input" => Some(Self::AwaitingInput),
88 "succeeded" => Some(Self::Succeeded),
89 "failed" => Some(Self::Failed),
90 "canceled" => Some(Self::Canceled),
91 _ => None,
92 }
93 }
94}
95
96impl std::fmt::Display for SessionTaskState {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 let s = match self {
99 Self::Queued => "queued",
100 Self::Running => "running",
101 Self::AwaitingInput => "awaiting_input",
102 Self::Succeeded => "succeeded",
103 Self::Failed => "failed",
104 Self::Canceled => "canceled",
105 };
106 write!(f, "{s}")
107 }
108}
109
110impl From<&str> for SessionTaskState {
111 fn from(s: &str) -> Self {
112 match s {
113 "running" => Self::Running,
114 "awaiting_input" => Self::AwaitingInput,
115 "succeeded" => Self::Succeeded,
116 "failed" => Self::Failed,
117 "canceled" => Self::Canceled,
118 _ => Self::Queued,
119 }
120 }
121}
122
123#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
125#[cfg_attr(feature = "openapi", derive(ToSchema))]
126#[serde(rename_all = "snake_case")]
127pub enum TaskWakePolicy {
128 #[default]
130 Silent,
131 OnTerminal,
133 OnActivity,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139#[cfg_attr(feature = "openapi", derive(ToSchema))]
140pub struct TaskInputRequest {
141 pub id: String,
143 pub prompt: String,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
148 pub expected: Option<Value>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[cfg_attr(feature = "openapi", derive(ToSchema))]
154pub struct TaskError {
155 pub kind: String,
156 pub message: String,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161#[cfg_attr(feature = "openapi", derive(ToSchema))]
162pub struct TaskArtifact {
163 pub name: String,
164 #[serde(rename = "type")]
166 pub artifact_type: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub path: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub url: Option<String>,
173}
174
175#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
177#[cfg_attr(feature = "openapi", derive(ToSchema))]
178pub struct TaskLinks {
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
182 pub child_session_id: Option<SessionId>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub remote_task_id: Option<String>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub resource_ids: Vec<String>,
189}
190
191impl TaskLinks {
192 pub fn is_empty(&self) -> bool {
193 self.child_session_id.is_none()
194 && self.remote_task_id.is_none()
195 && self.resource_ids.is_empty()
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201#[cfg_attr(feature = "openapi", derive(ToSchema))]
202pub struct SessionTask {
203 pub id: String,
205 #[cfg_attr(feature = "openapi", schema(value_type = String))]
207 pub session_id: SessionId,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
214 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
215 pub root_session_id: Option<SessionId>,
216 pub kind: String,
218 pub display_name: String,
220 #[serde(default, serialize_with = "serialize_public_task_spec")]
222 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
223 pub spec: Value,
224 pub state: SessionTaskState,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub state_detail: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub progress: Option<TaskProgress>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub input_request: Option<TaskInputRequest>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub cancel_requested_at: Option<DateTime<Utc>>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub summary: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub result_path: Option<String>,
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub artifacts: Vec<TaskArtifact>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub error: Option<TaskError>,
246 #[serde(default = "default_attempt")]
248 pub attempt: i32,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub worker_id: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub heartbeat_at: Option<DateTime<Utc>>,
253 #[serde(default, skip_serializing_if = "TaskLinks::is_empty")]
254 pub links: TaskLinks,
255 #[serde(default)]
256 pub wake_policy: TaskWakePolicy,
257 pub created_at: DateTime<Utc>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub started_at: Option<DateTime<Utc>>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub finished_at: Option<DateTime<Utc>>,
262 pub updated_at: DateTime<Utc>,
263}
264
265fn default_attempt() -> i32 {
266 1
267}
268
269fn serialize_public_task_spec<S>(
270 spec: &Value,
271 serializer: S,
272) -> std::result::Result<S::Ok, S::Error>
273where
274 S: Serializer,
275{
276 redacted_public_task_spec(spec).serialize(serializer)
277}
278
279fn redacted_public_task_spec(spec: &Value) -> Value {
280 let mut public = spec.clone();
281 let Some(configs) = public.get_mut("push_configs").and_then(Value::as_array_mut) else {
282 return public;
283 };
284 for config in configs {
285 let Some(config) = config.as_object_mut() else {
286 continue;
287 };
288 if config.remove("secret").is_some() {
289 config.insert("has_secret".to_string(), Value::Bool(true));
290 }
291 }
292 public
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct CreateSessionTask {
298 pub session_id: SessionId,
299 #[serde(default)]
301 pub id: Option<String>,
302 pub kind: String,
303 pub display_name: String,
304 #[serde(default)]
305 pub spec: Value,
306 #[serde(default = "default_queued")]
308 pub state: SessionTaskState,
309 #[serde(default)]
310 pub links: TaskLinks,
311 #[serde(default)]
312 pub wake_policy: TaskWakePolicy,
313}
314
315fn default_queued() -> SessionTaskState {
316 SessionTaskState::Queued
317}
318
319#[derive(Debug, Clone, Default, Serialize, Deserialize)]
321pub struct SessionTaskUpdate {
322 pub state: Option<SessionTaskState>,
323 pub state_detail: Option<String>,
324 pub progress: Option<TaskProgress>,
325 pub input_request: Option<TaskInputRequest>,
327 pub summary: Option<String>,
328 pub result_path: Option<String>,
329 pub artifacts: Option<Vec<TaskArtifact>>,
331 pub error: Option<TaskError>,
332 pub links: Option<TaskLinks>,
334 pub worker_id: Option<String>,
335 pub heartbeat_at: Option<DateTime<Utc>>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub expected_attempt: Option<i32>,
345 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
350 pub increment_attempt: bool,
351}
352
353#[derive(Debug, Clone, Default)]
355pub struct SessionTaskFilter {
356 pub kind: Option<String>,
357 pub state: Option<SessionTaskState>,
358}
359
360pub fn apply_task_update(task: &mut SessionTask, update: SessionTaskUpdate, now: DateTime<Utc>) {
371 if let Some(expected) = update.expected_attempt
375 && expected != task.attempt
376 {
377 return;
378 }
379
380 let was_terminal = task.state.is_terminal();
381
382 if was_terminal
389 && let Some(state) = update.state
390 && state != task.state
391 {
392 return;
393 }
394
395 if update.increment_attempt {
398 task.attempt += 1;
399 }
400
401 let mut next_state = update.state;
402 if update.input_request.is_some() && !was_terminal {
403 next_state = Some(SessionTaskState::AwaitingInput);
404 }
405
406 if let Some(input_request) = update.input_request
407 && !was_terminal
408 {
409 task.input_request = Some(input_request);
410 }
411
412 if let Some(state) = next_state
413 && !was_terminal
414 && task.state != state
415 {
416 if task.state == SessionTaskState::Queued && state != SessionTaskState::Queued {
417 task.started_at.get_or_insert(now);
418 }
419 if state.is_terminal() {
420 task.finished_at.get_or_insert(now);
421 }
422 if state != SessionTaskState::AwaitingInput {
423 task.input_request = None;
424 }
425 task.state = state;
426 }
427
428 if let Some(detail) = update.state_detail {
429 task.state_detail = Some(detail);
430 }
431 if let Some(progress) = update.progress {
432 task.progress = Some(progress);
433 }
434 if let Some(summary) = update.summary {
435 task.summary = Some(summary);
436 }
437 if let Some(result_path) = update.result_path {
438 task.result_path = Some(result_path);
439 }
440 if let Some(artifacts) = update.artifacts {
441 task.artifacts = artifacts;
442 }
443 if let Some(error) = update.error {
444 task.error = Some(error);
445 }
446 if let Some(links) = update.links {
447 if links.child_session_id.is_some() {
448 task.links.child_session_id = links.child_session_id;
449 }
450 if links.remote_task_id.is_some() {
451 task.links.remote_task_id = links.remote_task_id;
452 }
453 for id in links.resource_ids {
454 if !task.links.resource_ids.contains(&id) {
455 task.links.resource_ids.push(id);
456 }
457 }
458 }
459 if let Some(worker_id) = update.worker_id {
460 task.worker_id = Some(worker_id);
461 }
462 if let Some(heartbeat_at) = update.heartbeat_at {
463 task.heartbeat_at = Some(heartbeat_at);
464 }
465
466 task.updated_at = now;
467}
468
469pub fn new_session_task(input: CreateSessionTask, now: DateTime<Utc>) -> SessionTask {
471 let state = input.state;
472 SessionTask {
473 id: input.id.unwrap_or_else(generate_task_id),
474 session_id: input.session_id,
475 root_session_id: None,
478 kind: input.kind,
479 display_name: input.display_name,
480 spec: input.spec,
481 state,
482 state_detail: None,
483 progress: None,
484 input_request: None,
485 cancel_requested_at: None,
486 summary: None,
487 result_path: None,
488 artifacts: Vec::new(),
489 error: None,
490 attempt: 1,
491 worker_id: None,
492 heartbeat_at: None,
493 links: input.links,
494 wake_policy: input.wake_policy,
495 created_at: now,
496 started_at: if state == SessionTaskState::Queued {
497 None
498 } else {
499 Some(now)
500 },
501 finished_at: if state.is_terminal() { Some(now) } else { None },
502 updated_at: now,
503 }
504}
505
506#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
512#[cfg_attr(feature = "openapi", derive(ToSchema))]
513#[serde(rename_all = "snake_case")]
514pub enum TaskMessageDirection {
515 Inbound,
516 Outbound,
517}
518
519impl std::fmt::Display for TaskMessageDirection {
520 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521 match self {
522 Self::Inbound => write!(f, "inbound"),
523 Self::Outbound => write!(f, "outbound"),
524 }
525 }
526}
527
528impl From<&str> for TaskMessageDirection {
529 fn from(s: &str) -> Self {
530 match s {
531 "outbound" => Self::Outbound,
532 _ => Self::Inbound,
533 }
534 }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[cfg_attr(feature = "openapi", derive(ToSchema))]
540#[serde(tag = "type", rename_all = "snake_case")]
541pub enum TaskMessagePart {
542 Text {
543 text: String,
544 },
545 Data {
546 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
547 data: Value,
548 },
549}
550
551impl TaskMessagePart {
552 pub fn text(text: impl Into<String>) -> Self {
553 Self::Text { text: text.into() }
554 }
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize)]
559#[cfg_attr(feature = "openapi", derive(ToSchema))]
560pub struct TaskMessage {
561 pub id: String,
563 pub task_id: String,
564 pub direction: TaskMessageDirection,
565 pub content: Vec<TaskMessagePart>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub in_reply_to: Option<String>,
569 pub created_at: DateTime<Utc>,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct NewTaskMessage {
575 pub direction: TaskMessageDirection,
576 pub content: Vec<TaskMessagePart>,
577 #[serde(default)]
578 pub in_reply_to: Option<String>,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub expected_attempt: Option<i32>,
585}
586
587impl NewTaskMessage {
588 pub fn inbound_text(text: impl Into<String>) -> Self {
589 Self {
590 direction: TaskMessageDirection::Inbound,
591 content: vec![TaskMessagePart::text(text)],
592 in_reply_to: None,
593 expected_attempt: None,
594 }
595 }
596
597 pub fn outbound_text(text: impl Into<String>) -> Self {
598 Self {
599 direction: TaskMessageDirection::Outbound,
600 content: vec![TaskMessagePart::text(text)],
601 in_reply_to: None,
602 expected_attempt: None,
603 }
604 }
605
606 pub fn with_expected_attempt(mut self, attempt: i32) -> Self {
608 self.expected_attempt = Some(attempt);
609 self
610 }
611}
612
613pub fn task_message_text(content: &[TaskMessagePart]) -> String {
615 content
616 .iter()
617 .filter_map(|part| match part {
618 TaskMessagePart::Text { text } => Some(text.as_str()),
619 TaskMessagePart::Data { .. } => None,
620 })
621 .collect::<Vec<_>>()
622 .join("\n")
623}
624
625#[async_trait]
633pub trait SessionTaskRegistry: Send + Sync {
634 async fn create(&self, input: CreateSessionTask) -> Result<SessionTask>;
637
638 async fn update(
640 &self,
641 session_id: SessionId,
642 task_id: &str,
643 update: SessionTaskUpdate,
644 ) -> Result<Option<SessionTask>>;
645
646 async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>>;
647
648 async fn list(
649 &self,
650 session_id: SessionId,
651 filter: Option<&SessionTaskFilter>,
652 ) -> Result<Vec<SessionTask>>;
653
654 async fn request_cancel(
657 &self,
658 session_id: SessionId,
659 task_id: &str,
660 ) -> Result<Option<SessionTask>>;
661
662 async fn record_message(
666 &self,
667 session_id: SessionId,
668 task_id: &str,
669 message: NewTaskMessage,
670 ) -> Result<TaskMessage>;
671
672 async fn list_messages(
679 &self,
680 session_id: SessionId,
681 task_id: &str,
682 limit: Option<u32>,
683 after_id: Option<&str>,
684 ) -> Result<Vec<TaskMessage>>;
685}
686
687#[async_trait]
697pub trait TaskExecutor: Send + Sync {
698 fn kind(&self) -> &str;
699
700 fn can_reattach(&self) -> bool {
708 false
709 }
710
711 fn can_reattach_task(&self, task: &SessionTask) -> bool {
718 let _ = task;
719 self.can_reattach()
720 }
721
722 async fn start(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()> {
728 let _ = (task, context);
729 Err(crate::error::AgentLoopError::tool(format!(
730 "task kind '{}' does not support start via the registry",
731 self.kind()
732 )))
733 }
734
735 async fn deliver(
737 &self,
738 task: &SessionTask,
739 message: &TaskMessage,
740 context: &crate::traits::ToolContext,
741 ) -> Result<()> {
742 let _ = (task, message, context);
743 Err(crate::error::AgentLoopError::tool(format!(
744 "task kind '{}' does not accept inbound messages",
745 self.kind()
746 )))
747 }
748
749 async fn cancel(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()>;
751
752 async fn reconcile(
755 &self,
756 task: &SessionTask,
757 context: &crate::traits::ToolContext,
758 ) -> Result<()> {
759 let _ = (task, context);
760 Ok(())
761 }
762}
763
764pub struct TaskExecutorPlugin {
767 pub executor: fn() -> Arc<dyn TaskExecutor>,
768}
769
770inventory::collect!(TaskExecutorPlugin);
771
772pub fn find_task_executor(kind: &str) -> Option<Arc<dyn TaskExecutor>> {
774 inventory::iter::<TaskExecutorPlugin>
775 .into_iter()
776 .map(|plugin| (plugin.executor)())
777 .find(|executor| executor.kind() == kind)
778}
779
780#[async_trait]
788pub trait TaskSink: Send + Sync {
789 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()>;
790
791 async fn progress(&self, progress: TaskProgress) -> Result<()>;
792
793 async fn output(&self, stream: &str, delta: &str) -> Result<()>;
795
796 async fn post(&self, message: NewTaskMessage) -> Result<()>;
798
799 async fn request_input(&self, request: TaskInputRequest) -> Result<()>;
801
802 async fn artifact(&self, artifact: TaskArtifact) -> Result<()>;
803}
804
805pub struct RegistryTaskSink {
812 registry: Arc<dyn SessionTaskRegistry>,
813 session_id: SessionId,
814 task_id: String,
815 attempt: i32,
817}
818
819impl RegistryTaskSink {
820 pub fn new(
821 registry: Arc<dyn SessionTaskRegistry>,
822 session_id: SessionId,
823 task_id: String,
824 ) -> Self {
825 Self {
826 registry,
827 session_id,
828 task_id,
829 attempt: 1,
830 }
831 }
832
833 pub fn with_attempt(mut self, attempt: i32) -> Self {
836 self.attempt = attempt;
837 self
838 }
839}
840
841#[async_trait]
842impl TaskSink for RegistryTaskSink {
843 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()> {
844 self.registry
845 .update(
846 self.session_id,
847 &self.task_id,
848 SessionTaskUpdate {
849 state: Some(state),
850 state_detail: detail,
851 expected_attempt: Some(self.attempt),
852 ..Default::default()
853 },
854 )
855 .await?;
856 Ok(())
857 }
858
859 async fn progress(&self, progress: TaskProgress) -> Result<()> {
860 self.registry
861 .update(
862 self.session_id,
863 &self.task_id,
864 SessionTaskUpdate {
865 progress: Some(progress),
866 expected_attempt: Some(self.attempt),
867 ..Default::default()
868 },
869 )
870 .await?;
871 Ok(())
872 }
873
874 async fn output(&self, _stream: &str, _delta: &str) -> Result<()> {
875 Ok(())
876 }
877
878 async fn post(&self, message: NewTaskMessage) -> Result<()> {
879 self.registry
882 .record_message(
883 self.session_id,
884 &self.task_id,
885 message.with_expected_attempt(self.attempt),
886 )
887 .await?;
888 Ok(())
889 }
890
891 async fn request_input(&self, request: TaskInputRequest) -> Result<()> {
892 self.registry
893 .update(
894 self.session_id,
895 &self.task_id,
896 SessionTaskUpdate {
897 input_request: Some(request),
898 expected_attempt: Some(self.attempt),
899 ..Default::default()
900 },
901 )
902 .await?;
903 Ok(())
904 }
905
906 async fn artifact(&self, artifact: TaskArtifact) -> Result<()> {
907 let Some(task) = self.registry.get(self.session_id, &self.task_id).await? else {
908 return Ok(());
909 };
910 if task.attempt != self.attempt {
912 return Ok(());
913 }
914 let mut artifacts = task.artifacts;
915 artifacts.push(artifact);
916 self.registry
917 .update(
918 self.session_id,
919 &self.task_id,
920 SessionTaskUpdate {
921 artifacts: Some(artifacts),
922 expected_attempt: Some(self.attempt),
923 ..Default::default()
924 },
925 )
926 .await?;
927 Ok(())
928 }
929}
930
931pub fn task_vfs_dir(task_id: &str) -> String {
933 format!("/.tasks/{task_id}")
934}
935
936pub fn task_result_path(task_id: &str) -> String {
938 format!("/.tasks/{task_id}/result.json")
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944
945 fn task() -> SessionTask {
946 new_session_task(
947 CreateSessionTask {
948 session_id: SessionId::new(),
949 id: None,
950 kind: TASK_KIND_BACKGROUND_TOOL.to_string(),
951 display_name: "Test".to_string(),
952 spec: serde_json::json!({}),
953 state: SessionTaskState::Queued,
954 links: TaskLinks::default(),
955 wake_policy: TaskWakePolicy::Silent,
956 },
957 Utc::now(),
958 )
959 }
960
961 #[test]
962 fn create_generates_prefixed_id() {
963 let t = task();
964 assert!(t.id.starts_with("task_"));
965 assert_eq!(t.state, SessionTaskState::Queued);
966 assert!(t.started_at.is_none());
967 }
968
969 #[test]
970 fn serialization_redacts_spec_push_config_secrets() {
971 let mut t = task();
972 t.spec = serde_json::json!({
973 "instructions": "notify",
974 "push_configs": [
975 {
976 "url": "https://hooks.example.com/everruns",
977 "secret": "LEAKME-HMAC-KEY",
978 "event_filter": ["terminal"]
979 },
980 {
981 "url": "https://hooks.example.com/no-secret",
982 "event_filter": ["message"]
983 }
984 ]
985 });
986
987 let serialized = serde_json::to_value(&t).expect("task serializes");
988 let configs = serialized["spec"]["push_configs"]
989 .as_array()
990 .expect("push configs stay visible");
991 assert_eq!(configs[0]["url"], "https://hooks.example.com/everruns");
992 assert_eq!(configs[0]["has_secret"], true);
993 assert!(configs[0].get("secret").is_none());
994 assert!(configs[1].get("secret").is_none());
995
996 assert_eq!(
999 t.spec["push_configs"][0]["secret"], "LEAKME-HMAC-KEY",
1000 "stored spec remains unchanged for delivery"
1001 );
1002 }
1003
1004 #[test]
1005 fn first_transition_out_of_queued_stamps_started_at() {
1006 let mut t = task();
1007 let now = Utc::now();
1008 apply_task_update(
1009 &mut t,
1010 SessionTaskUpdate {
1011 state: Some(SessionTaskState::Running),
1012 ..Default::default()
1013 },
1014 now,
1015 );
1016 assert_eq!(t.state, SessionTaskState::Running);
1017 assert_eq!(t.started_at, Some(now));
1018 assert!(t.finished_at.is_none());
1019 }
1020
1021 #[test]
1022 fn terminal_transition_stamps_finished_at_and_is_final() {
1023 let mut t = task();
1024 let now = Utc::now();
1025 apply_task_update(
1026 &mut t,
1027 SessionTaskUpdate {
1028 state: Some(SessionTaskState::Succeeded),
1029 summary: Some("done".to_string()),
1030 ..Default::default()
1031 },
1032 now,
1033 );
1034 assert_eq!(t.state, SessionTaskState::Succeeded);
1035 assert_eq!(t.finished_at, Some(now));
1036
1037 apply_task_update(
1042 &mut t,
1043 SessionTaskUpdate {
1044 state: Some(SessionTaskState::Failed),
1045 error: Some(TaskError {
1046 kind: "orphaned".to_string(),
1047 message: "stale".to_string(),
1048 }),
1049 ..Default::default()
1050 },
1051 Utc::now(),
1052 );
1053 assert_eq!(t.state, SessionTaskState::Succeeded);
1054 assert!(t.error.is_none());
1055
1056 apply_task_update(
1058 &mut t,
1059 SessionTaskUpdate {
1060 state: Some(SessionTaskState::Succeeded),
1061 result_path: Some("/.tasks/x/result.json".to_string()),
1062 ..Default::default()
1063 },
1064 Utc::now(),
1065 );
1066 assert_eq!(t.result_path.as_deref(), Some("/.tasks/x/result.json"));
1067
1068 apply_task_update(
1070 &mut t,
1071 SessionTaskUpdate {
1072 summary: Some("enriched".to_string()),
1073 ..Default::default()
1074 },
1075 Utc::now(),
1076 );
1077 assert_eq!(t.summary.as_deref(), Some("enriched"));
1078 }
1079
1080 #[test]
1081 fn input_request_forces_awaiting_input_and_clears_on_resume() {
1082 let mut t = task();
1083 apply_task_update(
1084 &mut t,
1085 SessionTaskUpdate {
1086 input_request: Some(TaskInputRequest {
1087 id: "req_1".to_string(),
1088 prompt: "Approve?".to_string(),
1089 expected: None,
1090 }),
1091 ..Default::default()
1092 },
1093 Utc::now(),
1094 );
1095 assert_eq!(t.state, SessionTaskState::AwaitingInput);
1096 assert!(t.input_request.is_some());
1097
1098 apply_task_update(
1099 &mut t,
1100 SessionTaskUpdate {
1101 state: Some(SessionTaskState::Running),
1102 ..Default::default()
1103 },
1104 Utc::now(),
1105 );
1106 assert_eq!(t.state, SessionTaskState::Running);
1107 assert!(t.input_request.is_none());
1108 }
1109
1110 #[test]
1111 fn links_merge_without_duplicates() {
1112 let mut t = task();
1113 let child = SessionId::new();
1114 apply_task_update(
1115 &mut t,
1116 SessionTaskUpdate {
1117 links: Some(TaskLinks {
1118 child_session_id: Some(child),
1119 remote_task_id: None,
1120 resource_ids: vec!["res_1".to_string()],
1121 }),
1122 ..Default::default()
1123 },
1124 Utc::now(),
1125 );
1126 apply_task_update(
1127 &mut t,
1128 SessionTaskUpdate {
1129 links: Some(TaskLinks {
1130 child_session_id: None,
1131 remote_task_id: Some("rt_1".to_string()),
1132 resource_ids: vec!["res_1".to_string(), "res_2".to_string()],
1133 }),
1134 ..Default::default()
1135 },
1136 Utc::now(),
1137 );
1138 assert_eq!(t.links.child_session_id, Some(child));
1139 assert_eq!(t.links.remote_task_id.as_deref(), Some("rt_1"));
1140 assert_eq!(t.links.resource_ids, vec!["res_1", "res_2"]);
1141 }
1142
1143 #[test]
1144 fn message_text_rendering() {
1145 let msg = NewTaskMessage::outbound_text("hello");
1146 assert_eq!(task_message_text(&msg.content), "hello");
1147 }
1148
1149 #[test]
1154 fn update_with_matching_attempt_applies() {
1155 let mut t = task();
1156 assert_eq!(t.attempt, 1);
1158 let now = Utc::now();
1159
1160 apply_task_update(
1162 &mut t,
1163 SessionTaskUpdate {
1164 state: Some(SessionTaskState::Running),
1165 state_detail: Some("step 1".to_string()),
1166 heartbeat_at: Some(now),
1167 expected_attempt: Some(1),
1168 ..Default::default()
1169 },
1170 now,
1171 );
1172 assert_eq!(t.state, SessionTaskState::Running);
1173 assert_eq!(t.state_detail.as_deref(), Some("step 1"));
1174 assert_eq!(t.heartbeat_at, Some(now));
1175 }
1176
1177 #[test]
1178 fn update_with_stale_attempt_is_fully_ignored() {
1179 let mut t = task();
1180 t.attempt = 2;
1182
1183 let now = Utc::now();
1184 let before_updated = t.updated_at;
1185
1186 apply_task_update(
1189 &mut t,
1190 SessionTaskUpdate {
1191 state: Some(SessionTaskState::Running),
1192 state_detail: Some("superseded".to_string()),
1193 heartbeat_at: Some(now),
1194 expected_attempt: Some(1),
1195 ..Default::default()
1196 },
1197 now,
1198 );
1199 assert_eq!(t.state, SessionTaskState::Queued, "state must be unchanged");
1200 assert!(t.state_detail.is_none(), "state_detail must be unchanged");
1201 assert!(t.heartbeat_at.is_none(), "heartbeat must be unchanged");
1202 assert_eq!(t.updated_at, before_updated, "updated_at must be unchanged");
1203 }
1204
1205 #[test]
1206 fn update_with_none_expected_attempt_applies_regardless() {
1207 let mut t = task();
1208 t.attempt = 99;
1210 let now = Utc::now();
1211
1212 apply_task_update(
1213 &mut t,
1214 SessionTaskUpdate {
1215 summary: Some("cancel from API".to_string()),
1216 expected_attempt: None,
1217 ..Default::default()
1218 },
1219 now,
1220 );
1221 assert_eq!(t.summary.as_deref(), Some("cancel from API"));
1223 }
1224
1225 #[test]
1226 fn reaper_update_increments_attempt_and_fences_old_executor() {
1227 let mut t = task();
1228 t.state = SessionTaskState::Running;
1229 assert_eq!(t.attempt, 1);
1230 let now = Utc::now();
1231
1232 apply_task_update(
1234 &mut t,
1235 SessionTaskUpdate {
1236 state: Some(SessionTaskState::Failed),
1237 error: Some(TaskError {
1238 kind: "orphaned".to_string(),
1239 message: "worker heartbeat stopped".to_string(),
1240 }),
1241 increment_attempt: true,
1242 ..Default::default()
1243 },
1244 now,
1245 );
1246 assert_eq!(t.state, SessionTaskState::Failed);
1247 assert_eq!(t.attempt, 2, "orphan reap must supersede the attempt");
1248
1249 let later = now + chrono::Duration::seconds(5);
1252 apply_task_update(
1253 &mut t,
1254 SessionTaskUpdate {
1255 heartbeat_at: Some(later),
1256 expected_attempt: Some(1),
1257 ..Default::default()
1258 },
1259 later,
1260 );
1261 assert_ne!(
1262 t.heartbeat_at,
1263 Some(later),
1264 "stale heartbeat must be rejected"
1265 );
1266 }
1267
1268 #[test]
1269 fn increment_attempt_is_inert_when_update_is_dropped() {
1270 let mut t = task();
1271 t.state = SessionTaskState::Succeeded;
1272 assert_eq!(t.attempt, 1);
1273 let now = Utc::now();
1274
1275 apply_task_update(
1278 &mut t,
1279 SessionTaskUpdate {
1280 state: Some(SessionTaskState::Failed),
1281 error: Some(TaskError {
1282 kind: "orphaned".to_string(),
1283 message: "worker heartbeat stopped".to_string(),
1284 }),
1285 increment_attempt: true,
1286 ..Default::default()
1287 },
1288 now,
1289 );
1290 assert_eq!(t.state, SessionTaskState::Succeeded);
1291 assert_eq!(t.attempt, 1, "dropped update must not bump the attempt");
1292 }
1293}