1use crate::runner::AgentId;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11use tokio::sync::{Notify, watch};
12use tokio_util::sync::CancellationToken;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum RunStatus {
16 Running,
17 Paused,
18 Completed,
19 Failed,
20 Stopped,
21}
22
23impl RunStatus {
24 pub fn is_finished(self) -> bool {
25 matches!(
26 self,
27 RunStatus::Completed | RunStatus::Failed | RunStatus::Stopped
28 )
29 }
30
31 pub fn label(self) -> &'static str {
32 match self {
33 RunStatus::Running => "running",
34 RunStatus::Paused => "paused",
35 RunStatus::Completed => "completed",
36 RunStatus::Failed => "failed",
37 RunStatus::Stopped => "stopped",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AgentStatus {
44 Queued,
45 Running,
46 Completed,
47 Failed,
48 Stopped,
49 Reused,
51}
52
53impl AgentStatus {
54 pub fn is_finished(self) -> bool {
55 !matches!(self, AgentStatus::Queued | AgentStatus::Running)
56 }
57
58 pub fn label(self) -> &'static str {
59 match self {
60 AgentStatus::Queued => "queued",
61 AgentStatus::Running => "running",
62 AgentStatus::Completed => "completed",
63 AgentStatus::Failed => "failed",
64 AgentStatus::Stopped => "stopped",
65 AgentStatus::Reused => "reused",
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct AgentSnapshot {
73 pub id: AgentId,
74 pub label: String,
75 pub status: AgentStatus,
76 pub tokens: u64,
77 pub elapsed: Duration,
78 pub prompt: String,
79 pub result: Option<String>,
80 pub error: Option<String>,
81}
82
83#[derive(Debug, Clone)]
85pub struct PhaseSnapshot {
86 pub title: String,
87 pub agents: Vec<AgentSnapshot>,
88 pub tokens: u64,
89}
90
91impl PhaseSnapshot {
92 pub fn finished_agents(&self) -> usize {
93 self.agents
94 .iter()
95 .filter(|agent| agent.status.is_finished())
96 .count()
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct RunSnapshot {
103 pub status: RunStatus,
104 pub elapsed: Duration,
105 pub log: Vec<String>,
106 pub phases: Vec<PhaseSnapshot>,
107 pub tokens: u64,
108 pub error: Option<String>,
109}
110
111impl RunSnapshot {
112 pub fn total_agents(&self) -> usize {
113 self.phases.iter().map(|phase| phase.agents.len()).sum()
114 }
115
116 pub fn finished_agents(&self) -> usize {
117 self.phases.iter().map(PhaseSnapshot::finished_agents).sum()
118 }
119
120 pub fn active_phase(&self) -> Option<&PhaseSnapshot> {
122 self.phases
123 .iter()
124 .find(|phase| phase.agents.iter().any(|agent| !agent.status.is_finished()))
125 .or_else(|| {
126 self.phases
127 .iter()
128 .rev()
129 .find(|phase| !phase.agents.is_empty())
130 })
131 }
132}
133
134struct AgentRecord {
135 id: AgentId,
136 label: String,
137 status: AgentStatus,
138 tokens: u64,
139 started: Option<Instant>,
140 elapsed: Duration,
141 prompt: String,
142 result: Option<String>,
143 error: Option<String>,
144 cancel: CancellationToken,
145 restart: bool,
149}
150
151struct PhaseRecord {
152 title: String,
153 agents: Vec<AgentRecord>,
154}
155
156struct Inner {
157 status: RunStatus,
158 started: Instant,
159 finished: Option<Instant>,
160 log: Vec<String>,
161 phases: Vec<PhaseRecord>,
162 current_phase: usize,
163 error: Option<String>,
164}
165
166pub(crate) struct RunState {
168 inner: Mutex<Inner>,
169 version: watch::Sender<u64>,
170 cached: Mutex<Option<(u64, Arc<RunSnapshot>)>>,
172 paused: AtomicBool,
173 resumed: Notify,
174 stop: CancellationToken,
175}
176
177pub(crate) const DEFAULT_PHASE: &str = "Workflow";
179
180impl RunState {
181 pub(crate) fn new(declared_phases: &[String]) -> Arc<RunState> {
182 let mut phases: Vec<PhaseRecord> = declared_phases
183 .iter()
184 .map(|title| PhaseRecord {
185 title: title.clone(),
186 agents: Vec::new(),
187 })
188 .collect();
189 if phases.is_empty() {
190 phases.push(PhaseRecord {
191 title: DEFAULT_PHASE.to_string(),
192 agents: Vec::new(),
193 });
194 }
195 let (version, _) = watch::channel(0);
196 Arc::new(RunState {
197 inner: Mutex::new(Inner {
198 status: RunStatus::Running,
199 started: Instant::now(),
200 finished: None,
201 log: Vec::new(),
202 phases,
203 current_phase: 0,
204 error: None,
205 }),
206 version,
207 cached: Mutex::new(None),
208 paused: AtomicBool::new(false),
209 resumed: Notify::new(),
210 stop: CancellationToken::new(),
211 })
212 }
213
214 pub(crate) fn stop_token(&self) -> CancellationToken {
215 self.stop.clone()
216 }
217
218 pub fn subscribe(&self) -> watch::Receiver<u64> {
219 self.version.subscribe()
220 }
221
222 fn changed(&self) {
223 if let Ok(mut cached) = self.cached.lock() {
224 *cached = None;
225 }
226 self.version
227 .send_modify(|version| *version = version.wrapping_add(1));
228 }
229
230 pub(crate) fn set_phase(&self, title: &str) {
232 let Ok(mut inner) = self.inner.lock() else {
233 return;
234 };
235 match inner.phases.iter().position(|phase| phase.title == title) {
236 Some(index) => inner.current_phase = index,
237 None => {
238 inner.phases.push(PhaseRecord {
239 title: title.to_string(),
240 agents: Vec::new(),
241 });
242 inner.current_phase = inner.phases.len() - 1;
243 }
244 }
245 drop(inner);
246 self.changed();
247 }
248
249 pub(crate) fn current_phase_title(&self) -> String {
250 let Ok(inner) = self.inner.lock() else {
251 return DEFAULT_PHASE.to_string();
252 };
253 inner
254 .phases
255 .get(inner.current_phase)
256 .map(|phase| phase.title.clone())
257 .unwrap_or_else(|| DEFAULT_PHASE.to_string())
258 }
259
260 pub(crate) fn log(&self, message: String) {
261 if let Ok(mut inner) = self.inner.lock() {
262 inner.log.push(message);
263 }
264 self.changed();
265 }
266
267 pub(crate) fn register_agent(
269 &self,
270 id: AgentId,
271 label: String,
272 prompt: String,
273 ) -> CancellationToken {
274 let cancel = self.stop.child_token();
275 if let Ok(mut inner) = self.inner.lock() {
276 let phase = inner.current_phase;
277 if let Some(phase) = inner.phases.get_mut(phase) {
278 phase.agents.push(AgentRecord {
279 id,
280 label,
281 status: AgentStatus::Queued,
282 tokens: 0,
283 started: None,
284 elapsed: Duration::ZERO,
285 prompt,
286 result: None,
287 error: None,
288 cancel: cancel.clone(),
289 restart: false,
290 });
291 }
292 }
293 self.changed();
294 cancel
295 }
296
297 pub(crate) fn agent_started(&self, id: AgentId) {
298 self.with_agent(id, |agent| {
299 agent.status = AgentStatus::Running;
300 agent.started = Some(Instant::now());
301 });
302 }
303
304 pub(crate) fn agent_finished(
305 &self,
306 id: AgentId,
307 status: AgentStatus,
308 result: Option<String>,
309 error: Option<String>,
310 tokens: u64,
311 ) {
312 self.with_agent(id, |agent| {
313 agent.status = status;
314 agent.result = result;
315 agent.error = error;
316 agent.tokens = tokens;
317 if let Some(started) = agent.started {
318 agent.elapsed = started.elapsed();
319 }
320 });
321 }
322
323 fn with_agent(&self, id: AgentId, apply: impl FnOnce(&mut AgentRecord)) {
324 if let Ok(mut inner) = self.inner.lock()
325 && let Some(agent) = inner
326 .phases
327 .iter_mut()
328 .flat_map(|phase| phase.agents.iter_mut())
329 .find(|agent| agent.id == id)
330 {
331 apply(agent);
332 }
333 self.changed();
334 }
335
336 pub(crate) fn finish(&self, status: RunStatus, error: Option<String>) {
337 if let Ok(mut inner) = self.inner.lock() {
338 inner.status = status;
339 inner.error = error;
340 inner.finished = Some(Instant::now());
341 }
342 self.changed();
343 }
344
345 pub fn pause(&self) {
348 if self.paused.swap(true, Ordering::SeqCst) {
349 return;
350 }
351 if let Ok(mut inner) = self.inner.lock()
352 && inner.status == RunStatus::Running
353 {
354 inner.status = RunStatus::Paused;
355 }
356 self.changed();
357 }
358
359 pub fn resume(&self) {
360 if !self.paused.swap(false, Ordering::SeqCst) {
361 return;
362 }
363 if let Ok(mut inner) = self.inner.lock()
364 && inner.status == RunStatus::Paused
365 {
366 inner.status = RunStatus::Running;
367 }
368 self.resumed.notify_waiters();
369 self.changed();
370 }
371
372 pub fn is_paused(&self) -> bool {
373 self.paused.load(Ordering::SeqCst)
374 }
375
376 pub(crate) async fn wait_while_paused(&self) -> bool {
379 while self.paused.load(Ordering::SeqCst) {
380 if self.stop.is_cancelled() {
381 return false;
382 }
383 let resumed = self.resumed.notified();
384 if !self.paused.load(Ordering::SeqCst) {
387 break;
388 }
389 tokio::select! {
390 _ = resumed => {}
391 _ = self.stop.cancelled() => return false,
392 }
393 }
394 !self.stop.is_cancelled()
395 }
396
397 pub fn stop(&self) {
398 self.stop.cancel();
399 self.resume();
401 self.changed();
402 }
403
404 pub fn stop_agent(&self, id: AgentId) {
405 if let Ok(inner) = self.inner.lock()
406 && let Some(agent) = inner
407 .phases
408 .iter()
409 .flat_map(|phase| phase.agents.iter())
410 .find(|agent| agent.id == id)
411 {
412 agent.cancel.cancel();
413 }
414 self.changed();
415 }
416
417 pub fn restart_agent(&self, id: AgentId) {
423 if let Ok(mut inner) = self.inner.lock()
424 && let Some(agent) = inner
425 .phases
426 .iter_mut()
427 .flat_map(|phase| phase.agents.iter_mut())
428 .find(|agent| agent.id == id)
429 && matches!(agent.status, AgentStatus::Queued | AgentStatus::Running)
430 {
431 agent.restart = true;
432 agent.cancel.cancel();
433 }
434 self.changed();
435 }
436
437 pub(crate) fn take_restart(&self, id: AgentId) -> Option<CancellationToken> {
439 if self.stop.is_cancelled() {
440 return None;
441 }
442 let mut inner = self.inner.lock().ok()?;
443 let agent = inner
444 .phases
445 .iter_mut()
446 .flat_map(|phase| phase.agents.iter_mut())
447 .find(|agent| agent.id == id && agent.restart)?;
448 agent.restart = false;
449 agent.status = AgentStatus::Queued;
450 agent.started = None;
451 agent.elapsed = Duration::ZERO;
452 agent.result = None;
453 agent.error = None;
454 agent.cancel = self.stop.child_token();
455 let cancel = agent.cancel.clone();
456 drop(inner);
457 self.changed();
458 Some(cancel)
459 }
460
461 pub fn snapshot(&self) -> Arc<RunSnapshot> {
464 let version = *self.version.borrow();
465 if let Ok(cached) = self.cached.lock()
466 && let Some((cached_version, snapshot)) = cached.as_ref()
467 && *cached_version == version
468 {
469 return snapshot.clone();
470 }
471 let snapshot = Arc::new(self.build_snapshot());
472 if let Ok(mut cached) = self.cached.lock() {
473 *cached = Some((version, snapshot.clone()));
474 }
475 snapshot
476 }
477
478 fn build_snapshot(&self) -> RunSnapshot {
479 let Ok(inner) = self.inner.lock() else {
480 return RunSnapshot {
481 status: RunStatus::Failed,
482 elapsed: Duration::ZERO,
483 log: Vec::new(),
484 phases: Vec::new(),
485 tokens: 0,
486 error: Some("the run state was poisoned".into()),
487 };
488 };
489 let mut total_tokens = 0;
490 let phases = inner
491 .phases
492 .iter()
493 .map(|phase| {
494 let mut phase_tokens = 0;
495 let agents = phase
496 .agents
497 .iter()
498 .map(|agent| {
499 phase_tokens += agent.tokens;
500 AgentSnapshot {
501 id: agent.id,
502 label: agent.label.clone(),
503 status: agent.status,
504 tokens: agent.tokens,
505 elapsed: match (agent.status.is_finished(), agent.started) {
506 (false, Some(started)) => started.elapsed(),
507 _ => agent.elapsed,
508 },
509 prompt: agent.prompt.clone(),
510 result: agent.result.clone(),
511 error: agent.error.clone(),
512 }
513 })
514 .collect();
515 total_tokens += phase_tokens;
516 PhaseSnapshot {
517 title: phase.title.clone(),
518 agents,
519 tokens: phase_tokens,
520 }
521 })
522 .collect();
523 RunSnapshot {
524 status: inner.status,
525 elapsed: inner
526 .finished
527 .unwrap_or_else(Instant::now)
528 .saturating_duration_since(inner.started),
529 log: inner.log.clone(),
530 phases,
531 tokens: total_tokens,
532 error: inner.error.clone(),
533 }
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn declared_phases_appear_before_any_agent_runs() {
543 let state = RunState::new(&["Discover".into(), "Audit".into()]);
544 let snapshot = state.snapshot();
545 assert_eq!(snapshot.phases.len(), 2);
546 assert_eq!(snapshot.phases[0].title, "Discover");
547 assert_eq!(snapshot.total_agents(), 0);
548 }
549
550 #[test]
551 fn a_script_without_phases_gets_one_default_phase() {
552 let state = RunState::new(&[]);
553 assert_eq!(state.snapshot().phases[0].title, DEFAULT_PHASE);
554 }
555
556 #[test]
557 fn an_undeclared_phase_is_added_when_it_is_first_used() {
558 let state = RunState::new(&["Discover".into()]);
559 state.set_phase("Verify");
560 state.register_agent(0, "check".into(), "prompt".into());
561 let snapshot = state.snapshot();
562 assert_eq!(snapshot.phases.len(), 2);
563 assert_eq!(snapshot.phases[1].title, "Verify");
564 assert_eq!(snapshot.phases[1].agents.len(), 1);
565 }
566
567 #[test]
568 fn snapshots_are_shared_until_the_state_changes() {
569 let state = RunState::new(&[]);
570 let first = state.snapshot();
571 let second = state.snapshot();
572 assert!(Arc::ptr_eq(&first, &second));
573
574 state.register_agent(0, "one".into(), "prompt".into());
575 let third = state.snapshot();
576 assert!(!Arc::ptr_eq(&first, &third));
577 }
578
579 #[test]
580 fn the_version_moves_on_every_change_so_viewers_never_poll() {
581 let state = RunState::new(&[]);
582 let mut versions = state.subscribe();
583 assert_eq!(*versions.borrow_and_update(), 0);
584 state.log("started".into());
585 assert!(versions.has_changed().unwrap_or(false));
586 }
587
588 #[test]
589 fn finished_counts_ignore_agents_still_working() {
590 let state = RunState::new(&[]);
591 state.register_agent(0, "one".into(), "p".into());
592 state.register_agent(1, "two".into(), "p".into());
593 state.agent_started(0);
594 state.agent_finished(0, AgentStatus::Completed, Some("ok".into()), None, 12);
595 let snapshot = state.snapshot();
596 assert_eq!(snapshot.total_agents(), 2);
597 assert_eq!(snapshot.finished_agents(), 1);
598 assert_eq!(snapshot.tokens, 12);
599 }
600
601 #[tokio::test]
602 async fn a_paused_run_continues_after_resume() {
603 let state = RunState::new(&[]);
604 state.pause();
605 assert!(state.is_paused());
606 assert_eq!(state.snapshot().status, RunStatus::Paused);
607
608 let waiter = state.clone();
609 let handle = tokio::spawn(async move { waiter.wait_while_paused().await });
610 tokio::task::yield_now().await;
611 state.resume();
612 assert!(handle.await.unwrap());
613 assert_eq!(state.snapshot().status, RunStatus::Running);
614 }
615
616 #[tokio::test]
617 async fn stopping_a_paused_run_releases_it() {
618 let state = RunState::new(&[]);
619 state.pause();
620 let waiter = state.clone();
621 let handle = tokio::spawn(async move { waiter.wait_while_paused().await });
622 tokio::task::yield_now().await;
623 state.stop();
624 assert!(!handle.await.unwrap());
626 }
627}