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 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub append_artifact: Option<TaskArtifact>,
335 pub error: Option<TaskError>,
336 pub links: Option<TaskLinks>,
338 pub worker_id: Option<String>,
339 pub heartbeat_at: Option<DateTime<Utc>>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub expected_attempt: Option<i32>,
349 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
354 pub increment_attempt: bool,
355}
356
357#[derive(Debug, Clone, Default)]
359pub struct SessionTaskFilter {
360 pub kind: Option<String>,
361 pub state: Option<SessionTaskState>,
362}
363
364pub fn apply_task_update(task: &mut SessionTask, update: SessionTaskUpdate, now: DateTime<Utc>) {
375 if let Some(expected) = update.expected_attempt
379 && expected != task.attempt
380 {
381 return;
382 }
383
384 let was_terminal = task.state.is_terminal();
385
386 if was_terminal
393 && let Some(state) = update.state
394 && state != task.state
395 {
396 return;
397 }
398
399 if update.increment_attempt {
402 task.attempt += 1;
403 }
404
405 let mut next_state = update.state;
406 if update.input_request.is_some() && !was_terminal {
407 next_state = Some(SessionTaskState::AwaitingInput);
408 }
409
410 if let Some(input_request) = update.input_request
411 && !was_terminal
412 {
413 task.input_request = Some(input_request);
414 }
415
416 if let Some(state) = next_state
417 && !was_terminal
418 && task.state != state
419 {
420 if task.state == SessionTaskState::Queued && state != SessionTaskState::Queued {
421 task.started_at.get_or_insert(now);
422 }
423 if state.is_terminal() {
424 task.finished_at.get_or_insert(now);
425 }
426 if state != SessionTaskState::AwaitingInput {
427 task.input_request = None;
428 }
429 task.state = state;
430 }
431
432 if let Some(detail) = update.state_detail {
433 task.state_detail = Some(detail);
434 }
435 if let Some(progress) = update.progress {
436 task.progress = Some(progress);
437 }
438 if let Some(summary) = update.summary {
439 task.summary = Some(summary);
440 }
441 if let Some(result_path) = update.result_path {
442 task.result_path = Some(result_path);
443 }
444 if let Some(artifacts) = update.artifacts {
445 task.artifacts = artifacts;
446 }
447 if let Some(artifact) = update.append_artifact {
448 task.artifacts.push(artifact);
449 }
450 if let Some(error) = update.error {
451 task.error = Some(error);
452 }
453 if let Some(links) = update.links {
454 if links.child_session_id.is_some() {
455 task.links.child_session_id = links.child_session_id;
456 }
457 if links.remote_task_id.is_some() {
458 task.links.remote_task_id = links.remote_task_id;
459 }
460 for id in links.resource_ids {
461 if !task.links.resource_ids.contains(&id) {
462 task.links.resource_ids.push(id);
463 }
464 }
465 }
466 if let Some(worker_id) = update.worker_id {
467 task.worker_id = Some(worker_id);
468 }
469 if let Some(heartbeat_at) = update.heartbeat_at {
470 task.heartbeat_at = Some(heartbeat_at);
471 }
472
473 task.updated_at = now;
474}
475
476pub fn new_session_task(input: CreateSessionTask, now: DateTime<Utc>) -> SessionTask {
478 let state = input.state;
479 SessionTask {
480 id: input.id.unwrap_or_else(generate_task_id),
481 session_id: input.session_id,
482 root_session_id: None,
485 kind: input.kind,
486 display_name: input.display_name,
487 spec: input.spec,
488 state,
489 state_detail: None,
490 progress: None,
491 input_request: None,
492 cancel_requested_at: None,
493 summary: None,
494 result_path: None,
495 artifacts: Vec::new(),
496 error: None,
497 attempt: 1,
498 worker_id: None,
499 heartbeat_at: None,
500 links: input.links,
501 wake_policy: input.wake_policy,
502 created_at: now,
503 started_at: if state == SessionTaskState::Queued {
504 None
505 } else {
506 Some(now)
507 },
508 finished_at: if state.is_terminal() { Some(now) } else { None },
509 updated_at: now,
510 }
511}
512
513#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
519#[cfg_attr(feature = "openapi", derive(ToSchema))]
520#[serde(rename_all = "snake_case")]
521pub enum TaskMessageDirection {
522 Inbound,
523 Outbound,
524}
525
526impl std::fmt::Display for TaskMessageDirection {
527 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 match self {
529 Self::Inbound => write!(f, "inbound"),
530 Self::Outbound => write!(f, "outbound"),
531 }
532 }
533}
534
535impl From<&str> for TaskMessageDirection {
536 fn from(s: &str) -> Self {
537 match s {
538 "outbound" => Self::Outbound,
539 _ => Self::Inbound,
540 }
541 }
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
546#[cfg_attr(feature = "openapi", derive(ToSchema))]
547#[serde(tag = "type", rename_all = "snake_case")]
548pub enum TaskMessagePart {
549 Text {
550 text: String,
551 },
552 Data {
553 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
554 data: Value,
555 },
556}
557
558impl TaskMessagePart {
559 pub fn text(text: impl Into<String>) -> Self {
560 Self::Text { text: text.into() }
561 }
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
566#[cfg_attr(feature = "openapi", derive(ToSchema))]
567pub struct TaskMessage {
568 pub id: String,
570 pub task_id: String,
571 pub direction: TaskMessageDirection,
572 pub content: Vec<TaskMessagePart>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub in_reply_to: Option<String>,
576 pub created_at: DateTime<Utc>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct NewTaskMessage {
582 pub direction: TaskMessageDirection,
583 pub content: Vec<TaskMessagePart>,
584 #[serde(default)]
585 pub in_reply_to: Option<String>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub expected_attempt: Option<i32>,
592}
593
594impl NewTaskMessage {
595 pub fn inbound_text(text: impl Into<String>) -> Self {
596 Self {
597 direction: TaskMessageDirection::Inbound,
598 content: vec![TaskMessagePart::text(text)],
599 in_reply_to: None,
600 expected_attempt: None,
601 }
602 }
603
604 pub fn outbound_text(text: impl Into<String>) -> Self {
605 Self {
606 direction: TaskMessageDirection::Outbound,
607 content: vec![TaskMessagePart::text(text)],
608 in_reply_to: None,
609 expected_attempt: None,
610 }
611 }
612
613 pub fn with_expected_attempt(mut self, attempt: i32) -> Self {
615 self.expected_attempt = Some(attempt);
616 self
617 }
618}
619
620pub fn task_message_text(content: &[TaskMessagePart]) -> String {
622 content
623 .iter()
624 .filter_map(|part| match part {
625 TaskMessagePart::Text { text } => Some(text.as_str()),
626 TaskMessagePart::Data { .. } => None,
627 })
628 .collect::<Vec<_>>()
629 .join("\n")
630}
631
632#[async_trait]
640pub trait SessionTaskRegistry: Send + Sync {
641 async fn create(&self, input: CreateSessionTask) -> Result<SessionTask>;
644
645 async fn update(
647 &self,
648 session_id: SessionId,
649 task_id: &str,
650 update: SessionTaskUpdate,
651 ) -> Result<Option<SessionTask>>;
652
653 async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>>;
654
655 async fn list(
656 &self,
657 session_id: SessionId,
658 filter: Option<&SessionTaskFilter>,
659 ) -> Result<Vec<SessionTask>>;
660
661 async fn request_cancel(
664 &self,
665 session_id: SessionId,
666 task_id: &str,
667 ) -> Result<Option<SessionTask>>;
668
669 async fn record_message(
673 &self,
674 session_id: SessionId,
675 task_id: &str,
676 message: NewTaskMessage,
677 ) -> Result<TaskMessage>;
678
679 async fn list_messages(
686 &self,
687 session_id: SessionId,
688 task_id: &str,
689 limit: Option<u32>,
690 after_id: Option<&str>,
691 ) -> Result<Vec<TaskMessage>>;
692}
693
694#[async_trait]
704pub trait TaskExecutor: Send + Sync {
705 fn kind(&self) -> &str;
706
707 fn can_reattach(&self) -> bool {
715 false
716 }
717
718 fn can_reattach_task(&self, task: &SessionTask) -> bool {
725 let _ = task;
726 self.can_reattach()
727 }
728
729 async fn start(
735 &self,
736 task: &SessionTask,
737 context: &crate::tool_context::ToolContext,
738 ) -> Result<()> {
739 let _ = (task, context);
740 Err(crate::error::AgentLoopError::tool(format!(
741 "task kind '{}' does not support start via the registry",
742 self.kind()
743 )))
744 }
745
746 async fn deliver(
748 &self,
749 task: &SessionTask,
750 message: &TaskMessage,
751 context: &crate::tool_context::ToolContext,
752 ) -> Result<()> {
753 let _ = (task, message, context);
754 Err(crate::error::AgentLoopError::tool(format!(
755 "task kind '{}' does not accept inbound messages",
756 self.kind()
757 )))
758 }
759
760 async fn cancel(
762 &self,
763 task: &SessionTask,
764 context: &crate::tool_context::ToolContext,
765 ) -> Result<()>;
766
767 async fn reconcile(
770 &self,
771 task: &SessionTask,
772 context: &crate::tool_context::ToolContext,
773 ) -> Result<()> {
774 let _ = (task, context);
775 Ok(())
776 }
777}
778
779pub struct TaskExecutorPlugin {
783 pub executor: fn() -> Arc<dyn TaskExecutor>,
784}
785
786inventory::collect!(TaskExecutorPlugin);
787
788pub fn find_task_executor(kind: &str) -> Option<Arc<dyn TaskExecutor>> {
790 inventory::iter::<TaskExecutorPlugin>
791 .into_iter()
792 .map(|plugin| (plugin.executor)())
793 .find(|executor| executor.kind() == kind)
794}
795
796#[async_trait]
804pub trait TaskSink: Send + Sync {
805 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()>;
806
807 async fn progress(&self, progress: TaskProgress) -> Result<()>;
808
809 async fn output(&self, stream: &str, delta: &str) -> Result<()>;
811
812 async fn post(&self, message: NewTaskMessage) -> Result<()>;
814
815 async fn request_input(&self, request: TaskInputRequest) -> Result<()>;
817
818 async fn artifact(&self, artifact: TaskArtifact) -> Result<()>;
819}
820
821pub struct RegistryTaskSink {
828 registry: Arc<dyn SessionTaskRegistry>,
829 session_id: SessionId,
830 task_id: String,
831 attempt: i32,
833}
834
835impl RegistryTaskSink {
836 pub fn new(
837 registry: Arc<dyn SessionTaskRegistry>,
838 session_id: SessionId,
839 task_id: String,
840 ) -> Self {
841 Self {
842 registry,
843 session_id,
844 task_id,
845 attempt: 1,
846 }
847 }
848
849 pub fn with_attempt(mut self, attempt: i32) -> Self {
852 self.attempt = attempt;
853 self
854 }
855}
856
857#[async_trait]
858impl TaskSink for RegistryTaskSink {
859 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()> {
860 self.registry
861 .update(
862 self.session_id,
863 &self.task_id,
864 SessionTaskUpdate {
865 state: Some(state),
866 state_detail: detail,
867 expected_attempt: Some(self.attempt),
868 ..Default::default()
869 },
870 )
871 .await?;
872 Ok(())
873 }
874
875 async fn progress(&self, progress: TaskProgress) -> Result<()> {
876 self.registry
877 .update(
878 self.session_id,
879 &self.task_id,
880 SessionTaskUpdate {
881 progress: Some(progress),
882 expected_attempt: Some(self.attempt),
883 ..Default::default()
884 },
885 )
886 .await?;
887 Ok(())
888 }
889
890 async fn output(&self, _stream: &str, _delta: &str) -> Result<()> {
891 Ok(())
892 }
893
894 async fn post(&self, message: NewTaskMessage) -> Result<()> {
895 self.registry
898 .record_message(
899 self.session_id,
900 &self.task_id,
901 message.with_expected_attempt(self.attempt),
902 )
903 .await?;
904 Ok(())
905 }
906
907 async fn request_input(&self, request: TaskInputRequest) -> Result<()> {
908 self.registry
909 .update(
910 self.session_id,
911 &self.task_id,
912 SessionTaskUpdate {
913 input_request: Some(request),
914 expected_attempt: Some(self.attempt),
915 ..Default::default()
916 },
917 )
918 .await?;
919 Ok(())
920 }
921
922 async fn artifact(&self, artifact: TaskArtifact) -> Result<()> {
923 self.registry
924 .update(
925 self.session_id,
926 &self.task_id,
927 SessionTaskUpdate {
928 append_artifact: Some(artifact),
929 expected_attempt: Some(self.attempt),
930 ..Default::default()
931 },
932 )
933 .await?;
934 Ok(())
935 }
936}
937
938pub fn task_vfs_dir(task_id: &str) -> String {
940 format!("/.tasks/{task_id}")
941}
942
943pub fn task_result_path(task_id: &str) -> String {
945 format!("/.tasks/{task_id}/result.json")
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 fn instant(seconds: i64) -> DateTime<Utc> {
953 DateTime::from_timestamp(seconds, 0).unwrap()
954 }
955
956 fn snapshot(task: &SessionTask) -> Value {
957 serde_json::to_value(task).unwrap()
958 }
959
960 fn task() -> SessionTask {
961 new_session_task(
962 CreateSessionTask {
963 session_id: SessionId::from_uuid(uuid::Uuid::from_u128(1)),
964 id: Some("task_fixed".into()),
965 kind: TASK_KIND_BACKGROUND_TOOL.to_string(),
966 display_name: "Test".to_string(),
967 spec: serde_json::json!({}),
968 state: SessionTaskState::Queued,
969 links: TaskLinks::default(),
970 wake_policy: TaskWakePolicy::Silent,
971 },
972 instant(10),
973 )
974 }
975
976 #[test]
977 fn creation_preserves_inputs_and_initial_lifecycle_timestamps() {
978 for (state, started, finished) in [
979 (SessionTaskState::Queued, None, None),
980 (SessionTaskState::Running, Some(instant(10)), None),
981 (SessionTaskState::AwaitingInput, Some(instant(10)), None),
982 (
983 SessionTaskState::Succeeded,
984 Some(instant(10)),
985 Some(instant(10)),
986 ),
987 (
988 SessionTaskState::Failed,
989 Some(instant(10)),
990 Some(instant(10)),
991 ),
992 (
993 SessionTaskState::Canceled,
994 Some(instant(10)),
995 Some(instant(10)),
996 ),
997 ] {
998 let input = CreateSessionTask {
999 session_id: task().session_id,
1000 id: Some("task_external".into()),
1001 kind: "custom_kind".into(),
1002 display_name: "Work".into(),
1003 spec: serde_json::json!({"input": [1, 2]}),
1004 state,
1005 links: TaskLinks {
1006 remote_task_id: Some("remote".into()),
1007 ..Default::default()
1008 },
1009 wake_policy: TaskWakePolicy::OnActivity,
1010 };
1011 let actual = new_session_task(input.clone(), instant(10));
1012 let mut expected = task();
1013 expected.id = "task_external".into();
1014 expected.kind = "custom_kind".into();
1015 expected.display_name = "Work".into();
1016 expected.spec = serde_json::json!({"input": [1, 2]});
1017 expected.state = state;
1018 expected.links.remote_task_id = Some("remote".into());
1019 expected.wake_policy = TaskWakePolicy::OnActivity;
1020 expected.started_at = started;
1021 expected.finished_at = finished;
1022 assert_eq!(snapshot(&actual), snapshot(&expected));
1023 let generated = new_session_task(CreateSessionTask { id: None, ..input }, instant(10));
1024 let suffix = generated.id.strip_prefix("task_").unwrap();
1025 assert_eq!(suffix.len(), 32);
1026 assert_eq!(uuid::Uuid::parse_str(suffix).unwrap().get_version_num(), 7);
1027 }
1028 }
1029
1030 #[test]
1031 fn serialization_redacts_spec_push_config_secrets() {
1032 let mut t = task();
1033 t.spec = serde_json::json!({
1034 "instructions": "notify",
1035 "push_configs": [
1036 {
1037 "url": "https://hooks.example.com/everruns",
1038 "secret": "LEAKME-HMAC-KEY",
1039 "event_filter": ["terminal"]
1040 },
1041 {
1042 "url": "https://hooks.example.com/no-secret",
1043 "event_filter": ["message"]
1044 }
1045 ]
1046 });
1047
1048 let stored = t.spec.clone();
1049 assert_eq!(
1050 snapshot(&t)["spec"],
1051 serde_json::json!({
1052 "instructions": "notify",
1053 "push_configs": [
1054 {"url": "https://hooks.example.com/everruns", "has_secret": true, "event_filter": ["terminal"]},
1055 {"url": "https://hooks.example.com/no-secret", "event_filter": ["message"]}
1056 ]
1057 })
1058 );
1059 assert_eq!(
1060 t.spec, stored,
1061 "presentation must not mutate delivery secrets"
1062 );
1063 }
1064
1065 #[test]
1066 fn first_transition_out_of_queued_stamps_started_at() {
1067 let mut t = task();
1068 let now = instant(20);
1069 apply_task_update(
1070 &mut t,
1071 SessionTaskUpdate {
1072 state: Some(SessionTaskState::Running),
1073 ..Default::default()
1074 },
1075 now,
1076 );
1077 assert_eq!(t.state, SessionTaskState::Running);
1078 assert_eq!(t.started_at, Some(now));
1079 assert!(t.finished_at.is_none());
1080 assert_eq!(t.updated_at, instant(20));
1081 for (state, at) in [
1082 (SessionTaskState::Queued, 30),
1083 (SessionTaskState::Running, 40),
1084 ] {
1085 apply_task_update(
1086 &mut t,
1087 SessionTaskUpdate {
1088 state: Some(state),
1089 ..Default::default()
1090 },
1091 instant(at),
1092 );
1093 assert_eq!(
1094 t.started_at,
1095 Some(instant(20)),
1096 "first start must survive requeue"
1097 );
1098 assert_eq!(t.updated_at, instant(at));
1099 }
1100 }
1101
1102 #[test]
1103 fn terminal_transitions_reject_conflicting_updates_but_allow_enrichment() {
1104 use SessionTaskState::*;
1105 for terminal in [Succeeded, Failed, Canceled] {
1106 let mut t = task();
1107 apply_task_update(
1108 &mut t,
1109 SessionTaskUpdate {
1110 state: Some(terminal),
1111 summary: Some("done".into()),
1112 ..Default::default()
1113 },
1114 instant(20),
1115 );
1116 assert_eq!(t.state, terminal);
1117 assert_eq!(t.started_at, Some(instant(20)));
1118 assert_eq!(t.finished_at, Some(instant(20)));
1119 assert_eq!(t.updated_at, instant(20));
1120 let before = snapshot(&t);
1121 for other in [Queued, Running, AwaitingInput, Succeeded, Failed, Canceled] {
1122 if other == terminal {
1123 continue;
1124 }
1125 apply_task_update(
1126 &mut t,
1127 SessionTaskUpdate {
1128 state: Some(other),
1129 summary: Some("stale".into()),
1130 error: Some(TaskError {
1131 kind: "orphaned".into(),
1132 message: "stale".into(),
1133 }),
1134 append_artifact: Some(artifact("stale")),
1135 increment_attempt: true,
1136 ..Default::default()
1137 },
1138 instant(30),
1139 );
1140 assert_eq!(snapshot(&t), before, "{terminal:?} -> {other:?}");
1141 }
1142 let mut expected = t.clone();
1143 for state in [Some(terminal), None] {
1144 apply_task_update(
1145 &mut t,
1146 SessionTaskUpdate {
1147 state,
1148 result_path: Some("/result".into()),
1149 summary: Some("enriched".into()),
1150 input_request: Some(TaskInputRequest {
1151 id: "late".into(),
1152 prompt: "too late".into(),
1153 expected: None,
1154 }),
1155 ..Default::default()
1156 },
1157 instant(40),
1158 );
1159 expected.result_path = Some("/result".into());
1160 expected.summary = Some("enriched".into());
1161 expected.updated_at = instant(40);
1162 assert_eq!(
1163 snapshot(&t),
1164 snapshot(&expected),
1165 "enrichment must preserve lifecycle and ignore late input"
1166 );
1167 }
1168 }
1169 }
1170
1171 #[test]
1172 fn input_request_forces_awaiting_input_and_clears_on_resume() {
1173 let mut t = task();
1174 apply_task_update(
1175 &mut t,
1176 SessionTaskUpdate {
1177 input_request: Some(TaskInputRequest {
1178 id: "req_1".to_string(),
1179 prompt: "Approve?".to_string(),
1180 expected: None,
1181 }),
1182 ..Default::default()
1183 },
1184 instant(10),
1185 );
1186 assert_eq!(t.state, SessionTaskState::AwaitingInput);
1187 assert_eq!(
1188 t.input_request,
1189 Some(TaskInputRequest {
1190 id: "req_1".into(),
1191 prompt: "Approve?".into(),
1192 expected: None
1193 })
1194 );
1195 assert_eq!(t.started_at, Some(instant(10)));
1196
1197 apply_task_update(
1198 &mut t,
1199 SessionTaskUpdate {
1200 state: Some(SessionTaskState::Running),
1201 ..Default::default()
1202 },
1203 instant(10),
1204 );
1205 assert_eq!(t.state, SessionTaskState::Running);
1206 assert!(t.input_request.is_none());
1207 }
1208
1209 #[test]
1210 fn links_merge_without_duplicates() {
1211 let mut t = task();
1212 let child = SessionId::from_uuid(uuid::Uuid::from_u128(1));
1213 apply_task_update(
1214 &mut t,
1215 SessionTaskUpdate {
1216 links: Some(TaskLinks {
1217 child_session_id: Some(child),
1218 remote_task_id: None,
1219 resource_ids: vec!["res_1".to_string()],
1220 }),
1221 ..Default::default()
1222 },
1223 instant(10),
1224 );
1225 apply_task_update(
1226 &mut t,
1227 SessionTaskUpdate {
1228 links: Some(TaskLinks {
1229 child_session_id: None,
1230 remote_task_id: Some("rt_1".to_string()),
1231 resource_ids: vec!["res_1".to_string(), "res_2".to_string()],
1232 }),
1233 ..Default::default()
1234 },
1235 instant(10),
1236 );
1237 assert_eq!(t.links.child_session_id, Some(child));
1238 assert_eq!(t.links.remote_task_id.as_deref(), Some("rt_1"));
1239 assert_eq!(t.links.resource_ids, vec!["res_1", "res_2"]);
1240 let replacement = SessionId::from_uuid(uuid::Uuid::from_u128(2));
1241 apply_task_update(
1242 &mut t,
1243 SessionTaskUpdate {
1244 links: Some(TaskLinks {
1245 child_session_id: Some(replacement),
1246 remote_task_id: Some("rt_2".into()),
1247 resource_ids: vec!["res_2".into(), "res_3".into(), "res_3".into()],
1248 }),
1249 ..Default::default()
1250 },
1251 instant(30),
1252 );
1253 assert_eq!(
1254 t.links,
1255 TaskLinks {
1256 child_session_id: Some(replacement),
1257 remote_task_id: Some("rt_2".into()),
1258 resource_ids: vec!["res_1".into(), "res_2".into(), "res_3".into()]
1259 }
1260 );
1261 }
1262
1263 #[test]
1264 fn message_text_rendering() {
1265 let content = vec![
1266 TaskMessagePart::Data {
1267 data: serde_json::json!({"text": "hidden"}),
1268 },
1269 TaskMessagePart::text("first\nline"),
1270 TaskMessagePart::text(""),
1271 TaskMessagePart::Data {
1272 data: serde_json::json!([1, 2]),
1273 },
1274 TaskMessagePart::text("last 🦀"),
1275 ];
1276 assert_eq!(task_message_text(&content), "first\nline\n\nlast 🦀");
1277 assert_eq!(task_message_text(&[]), "");
1278 assert_eq!(task_message_text(&content[..1]), "");
1279 }
1280
1281 #[test]
1286 fn attempt_fence_rejects_entire_update_and_allows_current_or_unfenced_writes() {
1287 for expected_attempt in [Some(1), Some(2), Some(3), None] {
1288 let mut actual = task();
1289 actual.attempt = 2;
1290 actual.artifacts = vec![artifact("old")];
1291 let before = snapshot(&actual);
1292 let update = SessionTaskUpdate {
1293 state: Some(SessionTaskState::Running),
1294 state_detail: Some("working".into()),
1295 summary: Some("summary".into()),
1296 result_path: Some("/result".into()),
1297 artifacts: Some(vec![artifact("replacement")]),
1298 append_artifact: Some(artifact("append")),
1299 error: Some(TaskError {
1300 kind: "diagnostic".into(),
1301 message: "detail".into(),
1302 }),
1303 links: Some(TaskLinks {
1304 remote_task_id: Some("remote".into()),
1305 ..Default::default()
1306 }),
1307 worker_id: Some("worker".into()),
1308 heartbeat_at: Some(instant(19)),
1309 expected_attempt,
1310 increment_attempt: true,
1311 ..Default::default()
1312 };
1313 let mut expected = actual.clone();
1314 apply_task_update(&mut actual, update, instant(20));
1315 if matches!(expected_attempt, Some(1 | 3)) {
1316 assert_eq!(
1317 snapshot(&actual),
1318 before,
1319 "stale/future attempt {expected_attempt:?}"
1320 );
1321 } else {
1322 expected.state = SessionTaskState::Running;
1323 expected.state_detail = Some("working".into());
1324 expected.summary = Some("summary".into());
1325 expected.result_path = Some("/result".into());
1326 expected.artifacts = vec![artifact("replacement"), artifact("append")];
1327 expected.error = Some(TaskError {
1328 kind: "diagnostic".into(),
1329 message: "detail".into(),
1330 });
1331 expected.links.remote_task_id = Some("remote".into());
1332 expected.worker_id = Some("worker".into());
1333 expected.heartbeat_at = Some(instant(19));
1334 expected.started_at = Some(instant(20));
1335 expected.updated_at = instant(20);
1336 expected.attempt = 3;
1337 assert_eq!(snapshot(&actual), snapshot(&expected));
1338 apply_task_update(
1339 &mut actual,
1340 SessionTaskUpdate {
1341 artifacts: Some(vec![]),
1342 ..Default::default()
1343 },
1344 instant(30),
1345 );
1346 assert!(
1347 actual.artifacts.is_empty(),
1348 "explicit empty replacement clears artifacts"
1349 );
1350 }
1351 }
1352 }
1353
1354 #[test]
1355 fn reaper_update_increments_attempt_and_fences_old_executor() {
1356 let mut t = task();
1357 t.state = SessionTaskState::Running;
1358 assert_eq!(t.attempt, 1);
1359 let now = instant(20);
1360
1361 apply_task_update(
1363 &mut t,
1364 SessionTaskUpdate {
1365 state: Some(SessionTaskState::Failed),
1366 error: Some(TaskError {
1367 kind: "orphaned".to_string(),
1368 message: "worker heartbeat stopped".to_string(),
1369 }),
1370 increment_attempt: true,
1371 ..Default::default()
1372 },
1373 now,
1374 );
1375 assert_eq!(t.state, SessionTaskState::Failed);
1376 assert_eq!(t.attempt, 2, "orphan reap must supersede the attempt");
1377
1378 assert_eq!(
1379 t.error,
1380 Some(TaskError {
1381 kind: "orphaned".into(),
1382 message: "worker heartbeat stopped".into()
1383 })
1384 );
1385 assert_eq!(t.finished_at, Some(instant(20)));
1386 let before = snapshot(&t);
1387 apply_task_update(
1388 &mut t,
1389 SessionTaskUpdate {
1390 heartbeat_at: Some(instant(30)),
1391 append_artifact: Some(artifact("zombie")),
1392 expected_attempt: Some(1),
1393 ..Default::default()
1394 },
1395 instant(30),
1396 );
1397 assert_eq!(snapshot(&t), before);
1398 }
1399
1400 struct ArtifactRegistry {
1401 task: tokio::sync::Mutex<SessionTask>,
1402 }
1403
1404 #[async_trait]
1405 impl SessionTaskRegistry for ArtifactRegistry {
1406 async fn create(&self, _input: CreateSessionTask) -> Result<SessionTask> {
1407 panic!("unexpected create")
1408 }
1409 async fn update(
1410 &self,
1411 session_id: SessionId,
1412 task_id: &str,
1413 update: SessionTaskUpdate,
1414 ) -> Result<Option<SessionTask>> {
1415 let mut task = self.task.lock().await;
1416 assert_eq!(task.session_id, session_id);
1417 assert_eq!(task.id, task_id);
1418 apply_task_update(&mut task, update, instant(10));
1419 Ok(Some(task.clone()))
1420 }
1421 async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>> {
1422 let task = self.task.lock().await.clone();
1423 assert_eq!(task.session_id, session_id);
1424 assert_eq!(task.id, task_id);
1425 tokio::task::yield_now().await;
1427 Ok(Some(task))
1428 }
1429 async fn list(
1430 &self,
1431 _session_id: SessionId,
1432 _filter: Option<&SessionTaskFilter>,
1433 ) -> Result<Vec<SessionTask>> {
1434 panic!("unexpected list")
1435 }
1436 async fn request_cancel(
1437 &self,
1438 _session_id: SessionId,
1439 _task_id: &str,
1440 ) -> Result<Option<SessionTask>> {
1441 panic!("unexpected cancel")
1442 }
1443 async fn record_message(
1444 &self,
1445 _session_id: SessionId,
1446 _task_id: &str,
1447 _message: NewTaskMessage,
1448 ) -> Result<TaskMessage> {
1449 panic!("unexpected message")
1450 }
1451 async fn list_messages(
1452 &self,
1453 _session_id: SessionId,
1454 _task_id: &str,
1455 _limit: Option<u32>,
1456 _after_id: Option<&str>,
1457 ) -> Result<Vec<TaskMessage>> {
1458 panic!("unexpected messages")
1459 }
1460 }
1461
1462 fn artifact(name: &str) -> TaskArtifact {
1463 TaskArtifact {
1464 name: name.into(),
1465 artifact_type: "file".into(),
1466 path: Some(format!("/results/{name}")),
1467 url: None,
1468 }
1469 }
1470
1471 #[tokio::test]
1472 async fn concurrent_sinks_append_artifacts_without_losing_siblings() {
1473 let mut task = task();
1474 task.artifacts.push(artifact("initial"));
1475 let session_id = task.session_id;
1476 let task_id = task.id.clone();
1477 let registry = Arc::new(ArtifactRegistry {
1478 task: tokio::sync::Mutex::new(task),
1479 });
1480 let first = RegistryTaskSink::new(registry.clone(), session_id, task_id.clone());
1481 let second = RegistryTaskSink::new(registry.clone(), session_id, task_id);
1482 let (a, b) = tokio::join!(
1483 first.artifact(artifact("a")),
1484 second.artifact(artifact("b"))
1485 );
1486 a.unwrap();
1487 b.unwrap();
1488 let mut artifacts = registry.task.lock().await.artifacts.clone();
1489 artifacts.sort_by(|a, b| a.name.cmp(&b.name));
1490 assert_eq!(
1491 artifacts,
1492 [artifact("a"), artifact("b"), artifact("initial")]
1493 );
1494 registry.task.lock().await.attempt = 2;
1495 let before = snapshot(&*registry.task.lock().await);
1496 first.artifact(artifact("stale")).await.unwrap();
1497 assert_eq!(snapshot(&*registry.task.lock().await), before);
1498 }
1499}