1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Instant;
4
5use serde::{Deserialize, Serialize};
6use tokio::sync::broadcast;
7use tokio_util::sync::CancellationToken;
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
11#[serde(transparent)]
12pub struct TaskId(pub Uuid);
13
14impl TaskId {
15 pub fn now() -> Self {
16 Self(Uuid::now_v7())
17 }
18}
19
20impl std::fmt::Display for TaskId {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 self.0.fmt(f)
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum TaskKind {
29 Bash,
30 Terminal,
31 Flow,
32}
33
34impl TaskKind {
35 pub fn label(self) -> &'static str {
36 match self {
37 TaskKind::Bash => "Bash",
38 TaskKind::Terminal => "Terminal",
39 TaskKind::Flow => "Flow",
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum TaskStatus {
47 Running,
48 Killing,
49 Ok,
50 Err,
51 Killed,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum TaskTermination {
57 Killed,
58 Suicide,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum KillOutcome {
63 Killed { termination: TaskTermination },
64 NotFound,
65 NotRunning,
66 SelfKillRejected,
67}
68
69impl TaskStatus {
70 pub fn display_label(self) -> &'static str {
71 match self {
72 TaskStatus::Running => "running",
73 TaskStatus::Killing => "stopping",
74 TaskStatus::Ok => "completed",
75 TaskStatus::Err => "failed",
76 TaskStatus::Killed => "stopped",
77 }
78 }
79
80 pub fn is_terminal(self) -> bool {
81 matches!(self, TaskStatus::Ok | TaskStatus::Err | TaskStatus::Killed)
82 }
83
84 pub fn is_running(self) -> bool {
85 matches!(self, TaskStatus::Running | TaskStatus::Killing)
86 }
87}
88
89pub fn normalize_task_label(value: &str) -> Option<String> {
90 crate::message::ToolCallIntent::new(value).map(|label| label.as_str().to_owned())
91}
92
93#[derive(Debug, Clone)]
94pub struct TaskSnapshot {
95 pub id: TaskId,
96 pub kind: TaskKind,
97 pub label: String,
98 pub command: Option<String>,
99 pub status: TaskStatus,
100 pub started_at: Instant,
101 pub ended_at: Option<Instant>,
102 pub source_handle: String,
103 pub session_id: String,
104 pub workspace_id: Option<String>,
105 pub flow_run_id: Option<crate::event::FlowRunId>,
106 pub termination: Option<TaskTermination>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TaskDisplay {
111 pub label: String,
112 pub command: Option<String>,
113}
114
115impl From<String> for TaskDisplay {
116 fn from(label: String) -> Self {
117 Self {
118 label,
119 command: None,
120 }
121 }
122}
123
124impl From<&str> for TaskDisplay {
125 fn from(label: &str) -> Self {
126 label.to_owned().into()
127 }
128}
129
130impl TaskSnapshot {
131 pub fn elapsed_ms(&self) -> u64 {
132 self.ended_at
133 .unwrap_or_else(Instant::now)
134 .duration_since(self.started_at)
135 .as_millis() as u64
136 }
137
138 pub fn is_running(&self) -> bool {
139 self.status.is_running()
140 }
141}
142
143#[derive(Debug, Clone)]
144pub enum TaskEvent {
145 Registered(TaskSnapshot),
146 StatusChanged {
147 id: TaskId,
148 kind: TaskKind,
149 old: TaskStatus,
150 new: TaskStatus,
151 termination: Option<TaskTermination>,
152 },
153 Reaped {
154 id: TaskId,
155 },
156}
157
158#[derive(Debug, Clone, Default)]
159pub struct TaskFilter {
160 pub kind: Option<TaskKind>,
161 pub status: Option<TaskStatus>,
162 pub session_id: Option<String>,
163}
164
165impl TaskFilter {
166 pub fn all() -> Self {
167 Self::default()
168 }
169
170 pub fn running() -> Self {
171 Self {
172 status: Some(TaskStatus::Running),
173 ..Default::default()
174 }
175 }
176
177 pub fn matches(&self, snap: &TaskSnapshot) -> bool {
178 if let Some(k) = self.kind
179 && snap.kind != k
180 {
181 return false;
182 }
183 if let Some(s) = self.status
184 && snap.status != s
185 {
186 return false;
187 }
188 if let Some(ref sid) = self.session_id
189 && snap.session_id != *sid
190 {
191 return false;
192 }
193 true
194 }
195}
196
197struct TaskEntry {
198 snapshot: TaskSnapshot,
199 cancel: CancellationToken,
200 kill_hook: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
201}
202
203#[derive(Clone)]
210pub struct TaskRegistry {
211 inner: Arc<std::sync::Mutex<HashMap<TaskId, TaskEntry>>>,
212 event_tx: broadcast::Sender<TaskEvent>,
213}
214
215impl Default for TaskRegistry {
216 fn default() -> Self {
217 let (event_tx, _) = broadcast::channel(256);
218 Self {
219 inner: Arc::new(std::sync::Mutex::new(HashMap::new())),
220 event_tx,
221 }
222 }
223}
224
225fn running_snapshot(
226 kind: TaskKind,
227 display: TaskDisplay,
228 source_handle: String,
229 session_id: String,
230 workspace_id: Option<String>,
231 flow_run_id: Option<crate::event::FlowRunId>,
232) -> TaskSnapshot {
233 TaskSnapshot {
234 id: TaskId::now(),
235 kind,
236 label: display.label,
237 command: display.command,
238 status: TaskStatus::Running,
239 started_at: Instant::now(),
240 ended_at: None,
241 source_handle,
242 session_id,
243 workspace_id,
244 flow_run_id,
245 termination: None,
246 }
247}
248
249impl TaskRegistry {
250 pub fn new() -> Self {
251 Self::default()
252 }
253
254 pub fn register(
255 &self,
256 kind: TaskKind,
257 display: TaskDisplay,
258 source_handle: String,
259 session_id: String,
260 cancel: CancellationToken,
261 ) -> TaskId {
262 self.register_snapshot(
263 running_snapshot(kind, display, source_handle, session_id, None, None),
264 cancel,
265 None,
266 )
267 }
268
269 pub fn register_flow(
270 &self,
271 label: String,
272 source_handle: String,
273 session_id: String,
274 cancel: CancellationToken,
275 workspace_id: Option<String>,
276 ) -> TaskId {
277 self.register_snapshot(
278 running_snapshot(
279 TaskKind::Flow,
280 label.into(),
281 source_handle,
282 session_id,
283 workspace_id,
284 None,
285 ),
286 cancel,
287 None,
288 )
289 }
290
291 pub fn register_flow_with_run_id(
292 &self,
293 label: String,
294 source_handle: String,
295 session_id: String,
296 cancel: CancellationToken,
297 workspace_id: Option<String>,
298 flow_run_id: crate::event::FlowRunId,
299 ) -> TaskId {
300 self.register_snapshot(
301 running_snapshot(
302 TaskKind::Flow,
303 label.into(),
304 source_handle,
305 session_id,
306 workspace_id,
307 Some(flow_run_id),
308 ),
309 cancel,
310 None,
311 )
312 }
313
314 pub fn register_with_kill_hook(
315 &self,
316 kind: TaskKind,
317 display: TaskDisplay,
318 source_handle: String,
319 session_id: String,
320 cancel: CancellationToken,
321 kill_hook: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
322 ) -> TaskId {
323 self.register_snapshot(
324 running_snapshot(kind, display, source_handle, session_id, None, None),
325 cancel,
326 kill_hook,
327 )
328 }
329
330 fn register_snapshot(
331 &self,
332 snapshot: TaskSnapshot,
333 cancel: CancellationToken,
334 kill_hook: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
335 ) -> TaskId {
336 let id = snapshot.id.clone();
337 let entry = TaskEntry {
338 snapshot: snapshot.clone(),
339 cancel,
340 kill_hook,
341 };
342 self.inner.lock().unwrap().insert(id.clone(), entry);
343 let _ = self.event_tx.send(TaskEvent::Registered(snapshot));
344 id
345 }
346
347 pub fn lookup(&self, id: &TaskId) -> Option<TaskSnapshot> {
348 self.inner
349 .lock()
350 .unwrap()
351 .get(id)
352 .map(|e| e.snapshot.clone())
353 }
354
355 pub fn lookup_by_handle(&self, handle: &str) -> Option<TaskSnapshot> {
357 self.inner
358 .lock()
359 .unwrap()
360 .values()
361 .find(|e| e.snapshot.source_handle == handle)
362 .map(|e| e.snapshot.clone())
363 }
364
365 pub fn list(&self, filter: &TaskFilter) -> Vec<TaskSnapshot> {
366 let inner = self.inner.lock().unwrap();
367 let mut out: Vec<TaskSnapshot> = inner
368 .values()
369 .map(|e| e.snapshot.clone())
370 .filter(|s| filter.matches(s))
371 .collect();
372 out.sort_by_key(|s| s.started_at);
373 out
374 }
375
376 pub fn kill_from_operator(&self, id: &TaskId) -> KillOutcome {
381 self.kill_from(id, None, false)
382 }
383
384 pub fn kill_from(
385 &self,
386 id: &TaskId,
387 caller_flow_run_id: Option<&crate::event::FlowRunId>,
388 suicide: bool,
389 ) -> KillOutcome {
390 let mut inner = self.inner.lock().unwrap();
391 let Some(entry) = inner.get_mut(id) else {
392 return KillOutcome::NotFound;
393 };
394 if entry.snapshot.status.is_terminal() {
395 return KillOutcome::NotRunning;
396 }
397 let self_targeting = caller_flow_run_id.is_some()
398 && entry.snapshot.flow_run_id.as_ref() == caller_flow_run_id;
399 if self_targeting && !suicide {
400 return KillOutcome::SelfKillRejected;
401 }
402 let termination = if self_targeting {
403 TaskTermination::Suicide
404 } else {
405 TaskTermination::Killed
406 };
407 let old_status = entry.snapshot.status;
408 let kind = entry.snapshot.kind;
409 entry.snapshot.status = TaskStatus::Killing;
410 entry.snapshot.termination = Some(termination);
411 let cancel = entry.cancel.clone();
412 let hook = entry.kill_hook.clone();
413 drop(inner);
414 cancel.cancel();
415 if let Some(hook) = hook {
416 hook();
417 }
418 let _ = self.event_tx.send(TaskEvent::StatusChanged {
419 id: id.clone(),
420 kind,
421 old: old_status,
422 new: TaskStatus::Killing,
423 termination: Some(termination),
424 });
425 KillOutcome::Killed { termination }
426 }
427
428 pub fn finish(&self, id: &TaskId, status: TaskStatus) {
431 let mut inner = self.inner.lock().unwrap();
432 let Some(entry) = inner.get_mut(id) else {
433 return;
434 };
435 if entry.snapshot.status.is_terminal() {
436 return;
437 }
438 let old = entry.snapshot.status;
439 entry.snapshot.status = status;
440 entry.snapshot.ended_at = Some(Instant::now());
441 let kind = entry.snapshot.kind;
442 let termination = entry.snapshot.termination;
443 drop(inner);
444 let _ = self.event_tx.send(TaskEvent::StatusChanged {
445 id: id.clone(),
446 kind,
447 old,
448 new: status,
449 termination,
450 });
451 }
452
453 pub fn reap(&self, id: &TaskId) {
454 let mut inner = self.inner.lock().unwrap();
455 let should_remove = inner
456 .get(id)
457 .map(|e| e.snapshot.status.is_terminal())
458 .unwrap_or(false);
459 if should_remove {
460 inner.remove(id);
461 drop(inner);
462 let _ = self.event_tx.send(TaskEvent::Reaped { id: id.clone() });
463 }
464 }
465
466 pub fn subscribe(&self) -> broadcast::Receiver<TaskEvent> {
467 self.event_tx.subscribe()
468 }
469
470 pub fn running_count(&self) -> usize {
471 self.inner
472 .lock()
473 .unwrap()
474 .values()
475 .filter(|e| e.snapshot.status.is_running())
476 .count()
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn cancel() -> CancellationToken {
485 CancellationToken::new()
486 }
487
488 #[test]
489 fn register_and_lookup() {
490 let reg = TaskRegistry::new();
491 let id = reg.register(
492 TaskKind::Bash,
493 "cargo build".into(),
494 "bg_1".into(),
495 "sess".into(),
496 cancel(),
497 );
498 let snap = reg.lookup(&id).expect("found");
499 assert_eq!(snap.kind, TaskKind::Bash);
500 assert_eq!(snap.status, TaskStatus::Running);
501 assert!(snap.ended_at.is_none());
502 assert!(snap.command.is_none());
503 }
504
505 #[test]
506 fn command_is_independent_from_the_user_facing_label() {
507 let reg = TaskRegistry::new();
508 let id = reg.register(
509 TaskKind::Bash,
510 TaskDisplay {
511 label: "运行项目测试".into(),
512 command: Some("cargo test --workspace".into()),
513 },
514 "bg_1".into(),
515 "sess".into(),
516 cancel(),
517 );
518 let snap = reg.lookup(&id).expect("found");
519 assert_eq!(snap.label, "运行项目测试");
520 assert_eq!(snap.command.as_deref(), Some("cargo test --workspace"));
521 }
522
523 #[test]
524 fn lookup_by_handle() {
525 let reg = TaskRegistry::new();
526 let _id = reg.register(
527 TaskKind::Terminal,
528 "vim".into(),
529 "term_1".into(),
530 "sess".into(),
531 cancel(),
532 );
533 let snap = reg.lookup_by_handle("term_1").expect("found");
534 assert_eq!(snap.kind, TaskKind::Terminal);
535 assert!(reg.lookup_by_handle("nope").is_none());
536 }
537
538 #[test]
539 fn list_filters_by_kind_and_status() {
540 let reg = TaskRegistry::new();
541 let b1 = reg.register(
542 TaskKind::Bash,
543 "a".into(),
544 "bg_1".into(),
545 "s".into(),
546 cancel(),
547 );
548 let _t1 = reg.register(
549 TaskKind::Terminal,
550 "vim".into(),
551 "term_1".into(),
552 "s".into(),
553 cancel(),
554 );
555 let _b2 = reg.register(
556 TaskKind::Bash,
557 "ls".into(),
558 "bg_2".into(),
559 "s".into(),
560 cancel(),
561 );
562
563 let bash_only = reg.list(&TaskFilter {
564 kind: Some(TaskKind::Bash),
565 ..Default::default()
566 });
567 assert_eq!(bash_only.len(), 2);
568
569 reg.finish(&b1, TaskStatus::Ok);
570 let running = reg.list(&TaskFilter::running());
571 assert_eq!(running.len(), 2);
572 }
573
574 #[test]
575 fn kill_cancels_token() {
576 let reg = TaskRegistry::new();
577 let tok = cancel();
578 let id = reg.register(
579 TaskKind::Bash,
580 "x".into(),
581 "bg".into(),
582 "s".into(),
583 tok.clone(),
584 );
585 assert_eq!(
586 reg.kill_from_operator(&id),
587 KillOutcome::Killed {
588 termination: TaskTermination::Killed
589 }
590 );
591 assert!(tok.is_cancelled());
592 }
593
594 #[test]
595 fn self_kill_requires_suicide_confirmation() {
596 let reg = TaskRegistry::new();
597 let run_id = crate::event::FlowRunId::now();
598 let token = cancel();
599 let id = reg.register_flow_with_run_id(
600 "flow".into(),
601 "agent".into(),
602 "s".into(),
603 token.clone(),
604 None,
605 run_id.clone(),
606 );
607
608 assert_eq!(
609 reg.kill_from(&id, Some(&run_id), false),
610 KillOutcome::SelfKillRejected
611 );
612 assert!(!token.is_cancelled());
613 assert_eq!(reg.lookup(&id).unwrap().status, TaskStatus::Running);
614 }
615
616 #[test]
617 fn confirmed_self_kill_records_suicide_cause() {
618 let reg = TaskRegistry::new();
619 let run_id = crate::event::FlowRunId::now();
620 let token = cancel();
621 let id = reg.register_flow_with_run_id(
622 "flow".into(),
623 "agent".into(),
624 "s".into(),
625 token.clone(),
626 None,
627 run_id.clone(),
628 );
629
630 assert_eq!(
631 reg.kill_from(&id, Some(&run_id), true),
632 KillOutcome::Killed {
633 termination: TaskTermination::Suicide
634 }
635 );
636 assert!(token.is_cancelled());
637 assert_eq!(
638 reg.lookup(&id).unwrap().termination,
639 Some(TaskTermination::Suicide)
640 );
641 }
642
643 #[test]
644 fn operator_kill_is_not_classified_as_suicide() {
645 let reg = TaskRegistry::new();
646 let target_run_id = crate::event::FlowRunId::now();
647 let id = reg.register_flow_with_run_id(
648 "flow".into(),
649 "agent".into(),
650 "s".into(),
651 cancel(),
652 None,
653 target_run_id,
654 );
655
656 assert_eq!(
657 reg.kill_from_operator(&id),
658 KillOutcome::Killed {
659 termination: TaskTermination::Killed
660 }
661 );
662 assert_eq!(
663 reg.lookup(&id).unwrap().termination,
664 Some(TaskTermination::Killed)
665 );
666 }
667
668 #[test]
669 fn another_flow_can_kill_without_suicide_confirmation() {
670 let reg = TaskRegistry::new();
671 let target_run_id = crate::event::FlowRunId::now();
672 let caller_run_id = crate::event::FlowRunId::now();
673 let id = reg.register_flow_with_run_id(
674 "flow".into(),
675 "agent".into(),
676 "s".into(),
677 cancel(),
678 None,
679 target_run_id,
680 );
681
682 assert_eq!(
683 reg.kill_from(&id, Some(&caller_run_id), false),
684 KillOutcome::Killed {
685 termination: TaskTermination::Killed
686 }
687 );
688 }
689
690 #[test]
691 fn kill_returns_false_for_terminal() {
692 let reg = TaskRegistry::new();
693 let id = reg.register(
694 TaskKind::Bash,
695 "x".into(),
696 "bg".into(),
697 "s".into(),
698 cancel(),
699 );
700 reg.finish(&id, TaskStatus::Ok);
701 assert_eq!(reg.kill_from_operator(&id), KillOutcome::NotRunning);
702 }
703
704 #[test]
705 fn finish_is_idempotent() {
706 let reg = TaskRegistry::new();
707 let id = reg.register(
708 TaskKind::Bash,
709 "x".into(),
710 "bg".into(),
711 "s".into(),
712 cancel(),
713 );
714 reg.finish(&id, TaskStatus::Ok);
715 reg.finish(&id, TaskStatus::Err);
716 let snap = reg.lookup(&id).unwrap();
717 assert_eq!(snap.status, TaskStatus::Ok);
718 }
719
720 #[test]
721 fn reap_removes_terminal_only() {
722 let reg = TaskRegistry::new();
723 let id = reg.register(
724 TaskKind::Bash,
725 "x".into(),
726 "bg".into(),
727 "s".into(),
728 cancel(),
729 );
730 reg.reap(&id);
731 assert!(reg.lookup(&id).is_some());
732 reg.finish(&id, TaskStatus::Ok);
733 reg.reap(&id);
734 assert!(reg.lookup(&id).is_none());
735 }
736
737 #[test]
738 fn subscribe_receives_registered_event() {
739 let reg = TaskRegistry::new();
740 let mut rx = reg.subscribe();
741 let _id = reg.register(
742 TaskKind::Bash,
743 "x".into(),
744 "bg".into(),
745 "s".into(),
746 cancel(),
747 );
748 let ev = rx.try_recv().expect("got event");
749 match ev {
750 TaskEvent::Registered(s) => assert_eq!(s.kind, TaskKind::Bash),
751 _ => panic!("wrong event"),
752 }
753 }
754
755 #[test]
756 fn subscribe_receives_status_changed() {
757 let reg = TaskRegistry::new();
758 let mut rx = reg.subscribe();
759 let id = reg.register(
760 TaskKind::Bash,
761 "x".into(),
762 "bg".into(),
763 "s".into(),
764 cancel(),
765 );
766 let _ = rx.try_recv();
767 reg.finish(&id, TaskStatus::Ok);
768 let ev = rx.try_recv().expect("got status event");
769 match ev {
770 TaskEvent::StatusChanged { new, .. } => assert_eq!(new, TaskStatus::Ok),
771 _ => panic!("wrong event"),
772 }
773 }
774
775 #[test]
776 fn filter_matches_combines() {
777 let snap = TaskSnapshot {
778 id: TaskId::now(),
779 kind: TaskKind::Terminal,
780 label: "vim".into(),
781 command: None,
782 status: TaskStatus::Running,
783 started_at: Instant::now(),
784 ended_at: None,
785 source_handle: "term_1".into(),
786 session_id: "sess_a".into(),
787 workspace_id: None,
788 flow_run_id: None,
789 termination: None,
790 };
791 let f = TaskFilter {
792 kind: Some(TaskKind::Terminal),
793 status: Some(TaskStatus::Running),
794 session_id: Some("sess_a".into()),
795 };
796 assert!(f.matches(&snap));
797
798 let f2 = TaskFilter {
799 kind: Some(TaskKind::Bash),
800 ..Default::default()
801 };
802 assert!(!f2.matches(&snap));
803 }
804
805 #[test]
806 fn task_status_has_stable_display_labels() {
807 assert_eq!(TaskStatus::Running.display_label(), "running");
808 assert_eq!(TaskStatus::Killing.display_label(), "stopping");
809 assert_eq!(TaskStatus::Ok.display_label(), "completed");
810 assert_eq!(TaskStatus::Err.display_label(), "failed");
811 assert_eq!(TaskStatus::Killed.display_label(), "stopped");
812 }
813
814 #[test]
815 fn task_label_normalization_matches_tool_intent_bounds() {
816 assert_eq!(
817 normalize_task_label(" 检查 当前状态 ").as_deref(),
818 Some("检查 当前状态")
819 );
820 assert!(normalize_task_label(" \n\t ").is_none());
821 assert_eq!(
822 normalize_task_label(&"x".repeat(121))
823 .unwrap()
824 .chars()
825 .count(),
826 120
827 );
828 }
829}