1use std::collections::{BTreeMap, BTreeSet, VecDeque};
3
4use serde::{Deserialize, Serialize};
5
6use super::{
7 CompletionSignal, Decision, MAX_TASK_BYTES, RunAgentStatus, RunError, RunStatus, WorkflowRun,
8};
9use crate::workflow::timestamp;
10use crate::workflow_type::{AgentGraph, GraphEvent, GraphFinish, GraphReceive};
11
12const MAX_QUEUED: usize = 32;
13
14#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct GraphRun {
17 pub started: bool,
18 pub ready: BTreeSet<u64>,
19 #[serde(default)]
20 pub assigned: BTreeSet<u64>,
21 #[serde(default)]
22 pub assignment_generations: BTreeMap<u64, u64>,
23 pub queues: BTreeMap<u64, BTreeMap<String, VecDeque<String>>>,
24 pub activations: BTreeMap<u64, u16>,
25 pub started_at: BTreeMap<u64, u64>,
26 pub finished_links: BTreeSet<String>,
27 pub outputs: BTreeMap<u64, GraphOutput>,
28 pub selected: Option<u64>,
29}
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct GraphOutput {
34 pub agent_id: u64,
35 pub label: String,
36 pub summary: String,
37 pub elapsed_ms: u64,
38}
39
40fn feedback_key(definition: &AgentGraph, role: &str, key: &str) -> bool {
41 definition.edges.iter().any(|edge| {
42 edge.to == role
43 && edge.event == GraphEvent::Feedback
44 && key.rsplit_once(':').map(|(id, _)| id) == Some(edge.id.as_str())
45 })
46}
47
48fn sender_message(agents: &[super::RunAgent], key: &str, value: &str) -> String {
49 let sender = key
50 .rsplit_once(':')
51 .and_then(|(_, id)| id.parse::<u64>().ok());
52 let label = agents
53 .iter()
54 .find(|agent| Some(agent.agent_id) == sender)
55 .map_or("Agent", |agent| agent.label.as_str());
56 format!("From {label}:\n{value}")
57}
58
59impl WorkflowRun {
60 pub(crate) fn final_review_messages(&self, reviewer_id: u64) -> BTreeMap<u64, String> {
62 let Some(graph) = &self.workflow_type.definition.graph else {
63 return self.incoming.clone();
64 };
65 let Some(reviewer) = self
66 .agents
67 .iter()
68 .find(|agent| agent.agent_id == reviewer_id)
69 else {
70 return BTreeMap::new();
71 };
72 let Some(signal) = self.completions.get(&reviewer_id) else {
73 return BTreeMap::new();
74 };
75 self.agents
76 .iter()
77 .filter(|target| {
78 graph.edges.iter().any(|edge| {
79 edge.from == reviewer.role
80 && edge.to == target.role
81 && edge.event == GraphEvent::Feedback
82 })
83 })
84 .map(|target| {
85 (
86 target.agent_id,
87 format!("From {}:\n{}", reviewer.label, signal.summary),
88 )
89 })
90 .collect()
91 }
92
93 #[must_use]
94 pub fn stage_name(&self) -> &str {
95 if self.graph.is_some() {
96 "Graph"
97 } else {
98 &self.workflow_type.definition.stages[self.stage_index].name
99 }
100 }
101 #[must_use]
102 pub fn stage_id(&self) -> &str {
103 if self.graph.is_some() {
104 "graph"
105 } else {
106 &self.workflow_type.definition.stages[self.stage_index].id.0
107 }
108 }
109
110 #[must_use]
111 pub fn assignment_generation(&self, agent_id: u64) -> u64 {
112 self.graph
113 .as_ref()
114 .and_then(|graph| graph.assignment_generations.get(&agent_id).copied())
115 .unwrap_or(self.generation)
116 }
117
118 pub(crate) fn prepare_graph_resume(&mut self) -> Result<(), RunError> {
119 if self.graph.is_none() {
120 return Ok(());
121 }
122 let active: Vec<_> = self
123 .active_agents()
124 .iter()
125 .map(|agent| agent.agent_id)
126 .collect();
127 let counters = active
128 .iter()
129 .map(|&agent| {
130 self.assignment_generation(agent)
131 .checked_add(1)
132 .map(|generation| (agent, generation))
133 .ok_or(RunError::StaleSignal)
134 })
135 .collect::<Result<Vec<_>, _>>()?;
136 for agent in &mut self.agents {
137 if active.contains(&agent.agent_id) {
138 agent.status = RunAgentStatus::Waiting;
139 }
140 }
141 let graph = self.graph.as_mut().expect("graph");
142 graph.ready.extend(active.iter().copied());
143 graph.assigned.clear();
144 graph.assignment_generations.extend(counters);
145 Ok(())
146 }
147
148 pub(crate) fn graph_roots(&self) -> Vec<String> {
149 self.workflow_type
150 .definition
151 .graph
152 .as_ref()
153 .map(|graph| {
154 self.workflow_type
155 .definition
156 .roles
157 .iter()
158 .filter(|role| graph.is_start(&role.id.0))
159 .map(|role| role.id.0.clone())
160 .collect()
161 })
162 .unwrap_or_default()
163 }
164
165 pub(crate) fn start_graph_task(&mut self, task: String) -> Result<(), RunError> {
166 if self.graph.as_ref().is_none_or(|graph| graph.started)
167 || self.status != RunStatus::Running
168 {
169 return Err(RunError::StaleSignal);
170 }
171 if task.trim().is_empty() || task.len() > MAX_TASK_BYTES {
172 return Err(RunError::Prompt);
173 }
174 let roots = self.graph_roots();
175 self.prompt = task;
176 let graph = self.graph.as_mut().expect("graph checked");
177 graph.started = true;
178 let started = timestamp();
179 for agent in &mut self.agents {
180 if roots.contains(&agent.role) {
181 graph.ready.insert(agent.agent_id);
182 graph.started_at.insert(agent.agent_id, started);
183 graph.activations.insert(agent.agent_id, 1);
184 graph
185 .assignment_generations
186 .entry(agent.agent_id)
187 .or_insert(1);
188 self.incoming.insert(agent.agent_id, self.prompt.clone());
189 agent.status = RunAgentStatus::Waiting;
190 }
191 }
192 self.trace(
193 "taskSubmitted",
194 None,
195 None,
196 "Task sent to the graph's starting roles",
197 );
198 Ok(())
199 }
200
201 pub(crate) fn adopt_graph_task(
203 &mut self,
204 agent_id: u64,
205 task: String,
206 ) -> Result<bool, RunError> {
207 if self.graph.as_ref().is_none_or(|graph| graph.started) {
208 return Ok(false);
209 }
210 if !self.agents.iter().any(|agent| {
211 agent.agent_id == agent_id
212 && self
213 .workflow_type
214 .definition
215 .graph
216 .as_ref()
217 .is_some_and(|graph| graph.is_start(&agent.role))
218 }) {
219 return Err(RunError::StaleSignal);
220 }
221 self.start_graph_task(task)?;
222 self.graph_agent_launched(agent_id);
223 self.agents
224 .iter_mut()
225 .find(|agent| agent.agent_id == agent_id)
226 .expect("start agent checked")
227 .status = RunAgentStatus::Running;
228 Ok(true)
229 }
230
231 pub(crate) fn graph_agent_launched(&mut self, agent: u64) {
232 if let Some(graph) = &mut self.graph {
233 graph.ready.remove(&agent);
234 if graph.started {
235 graph.assigned.insert(agent);
236 graph.started_at.entry(agent).or_insert_with(timestamp);
237 }
238 }
239 }
240
241 pub(crate) fn complete_graph(
242 &mut self,
243 agent_id: u64,
244 generation: u64,
245 signal: &CompletionSignal,
246 ) -> Result<bool, RunError> {
247 let mut next = self.clone();
249 let advance = next.complete_graph_inner(agent_id, generation, signal)?;
250 *self = next;
251 Ok(advance)
252 }
253
254 fn complete_graph_inner(
255 &mut self,
256 agent_id: u64,
257 generation: u64,
258 signal: &CompletionSignal,
259 ) -> Result<bool, RunError> {
260 if self.status != RunStatus::Running
261 || generation != self.assignment_generation(agent_id)
262 || !self
263 .active_agents()
264 .iter()
265 .any(|agent| agent.agent_id == agent_id)
266 || self.completions.contains_key(&agent_id)
267 {
268 return Err(RunError::StaleSignal);
269 }
270 if serde_json::to_vec(signal)
271 .map_err(|_| RunError::SignalTooLarge)?
272 .len()
273 > super::completion::MAX_SIGNAL_BYTES
274 {
275 return Err(RunError::SignalTooLarge);
276 }
277 if !signal.assignments.is_empty() {
278 return Err(RunError::Assignments);
279 }
280 let sender = self
281 .agents
282 .iter()
283 .find(|agent| agent.agent_id == agent_id)
284 .ok_or(RunError::StaleSignal)?
285 .clone();
286 let definition = self
287 .workflow_type
288 .definition
289 .graph
290 .as_ref()
291 .ok_or(RunError::InvalidType)?
292 .clone();
293 let event = match signal.decision {
294 Decision::Done if !self.is_reviewer(agent_id) => GraphEvent::Done,
295 Decision::Approve if self.is_reviewer(agent_id) => GraphEvent::Approved,
296 Decision::RequestChanges if self.is_reviewer(agent_id) => GraphEvent::Feedback,
297 Decision::PieceReady
298 if definition.edges.iter().any(|edge| {
299 edge.from == sender.role && edge.event == GraphEvent::PieceReady
300 }) =>
301 {
302 GraphEvent::PieceReady
303 }
304 _ => return Err(RunError::Decision),
305 };
306 if matches!(event, GraphEvent::Feedback | GraphEvent::PieceReady)
307 && signal.summary.trim().is_empty()
308 {
309 return Err(RunError::Feedback);
310 }
311 if !self.graph.as_ref().expect("graph").started {
312 self.adopt_graph_task(agent_id, signal.task.clone())?;
313 }
314 let piece = event == GraphEvent::PieceReady;
315 if !piece {
316 self.graph
317 .as_mut()
318 .expect("graph")
319 .assigned
320 .remove(&agent_id);
321 self.completions.insert(agent_id, signal.clone());
322 self.agents
323 .iter_mut()
324 .find(|agent| agent.agent_id == agent_id)
325 .expect("sender")
326 .status = RunAgentStatus::Completed;
327 }
328 self.trace(
329 if piece { "pieceReady" } else { "roleCompleted" },
330 Some(agent_id),
331 None,
332 &signal.summary,
333 );
334 self.dispatch_graph_signal(&sender, event, &signal.summary)?;
335 if let Err(error) = self.activate_graph_receivers() {
336 if matches!(error, RunError::ActivationLimit) {
337 self.finish(RunStatus::LimitReached, "Graph activation limit reached.");
338 return Ok(true);
339 }
340 return Err(error);
341 }
342 Ok(self.check_graph_finish(piece))
343 }
344
345 fn graph_finish_ready(&self) -> bool {
346 let Some(graph) = &self.graph else {
347 return false;
348 };
349 let Some(definition) = &self.workflow_type.definition.graph else {
350 return false;
351 };
352 graph.started
353 && graph.ready.is_empty()
354 && graph.assigned.is_empty()
355 && graph
356 .queues
357 .values()
358 .all(|queues| queues.values().all(VecDeque::is_empty))
359 && self
360 .agents
361 .iter()
362 .filter(|agent| definition.is_result(&agent.role))
363 .all(|agent| graph.outputs.contains_key(&agent.agent_id))
364 }
365
366 pub(crate) fn graph_choice_ready(&self) -> bool {
367 self.workflow_type
368 .definition
369 .graph
370 .as_ref()
371 .is_some_and(|definition| definition.finish == GraphFinish::Choose)
372 && self.graph_finish_ready()
373 }
374
375 fn check_graph_finish(&mut self, piece: bool) -> bool {
376 let active = self
377 .graph
378 .as_ref()
379 .is_some_and(|graph| !graph.ready.is_empty() || !graph.assigned.is_empty());
380 if self.graph_finish_ready() {
381 if self.graph_choice_ready() {
382 self.message = Some("All results are ready. Choose a result.".to_owned());
383 } else {
384 self.finish(RunStatus::Completed, "Workflow completed");
385 }
386 } else if !active && !piece {
387 self.finish(
388 RunStatus::Failed,
389 "The graph stopped before all results arrived. Check the links and receive rules.",
390 );
391 }
392 !self.graph.as_ref().expect("graph").ready.is_empty() || self.status != RunStatus::Running
393 }
394
395 fn dispatch_graph_signal(
396 &mut self,
397 sender: &super::RunAgent,
398 event: GraphEvent,
399 summary: &str,
400 ) -> Result<(), RunError> {
401 let definition = self
402 .workflow_type
403 .definition
404 .graph
405 .as_ref()
406 .expect("graph")
407 .clone();
408 let legacy_results = definition.edges.iter().any(|edge| edge.to == "finish");
409 let returns_result = if legacy_results {
410 definition
411 .edges
412 .iter()
413 .any(|edge| edge.from == sender.role && edge.to == "finish" && edge.event == event)
414 } else {
415 definition.is_result(&sender.role)
416 && matches!(event, GraphEvent::Done | GraphEvent::Approved)
417 };
418 if returns_result {
419 let graph = self.graph.as_mut().expect("graph");
420 graph.outputs.insert(
421 sender.agent_id,
422 GraphOutput {
423 agent_id: sender.agent_id,
424 label: sender.label.clone(),
425 summary: summary.to_owned(),
426 elapsed_ms: timestamp().saturating_sub(
427 *graph
428 .started_at
429 .get(&sender.agent_id)
430 .unwrap_or(×tamp()),
431 ),
432 },
433 );
434 }
435 for edge in definition
436 .edges
437 .iter()
438 .filter(|edge| edge.from == sender.role && edge.event == event)
439 {
440 if edge.to == "finish" {
441 let graph = self.graph.as_mut().expect("graph");
442 graph
443 .finished_links
444 .insert(format!("{}:{}", edge.id, sender.agent_id));
445 graph.outputs.insert(
446 sender.agent_id,
447 GraphOutput {
448 agent_id: sender.agent_id,
449 label: sender.label.clone(),
450 summary: summary.to_owned(),
451 elapsed_ms: timestamp().saturating_sub(
452 *graph
453 .started_at
454 .get(&sender.agent_id)
455 .unwrap_or(×tamp()),
456 ),
457 },
458 );
459 continue;
460 }
461 let receivers: Vec<_> = self
462 .agents
463 .iter()
464 .filter(|agent| agent.role == edge.to)
465 .map(|agent| agent.agent_id)
466 .collect();
467 for target in receivers {
468 let graph = self.graph.as_mut().expect("graph");
469 let queues = graph.queues.entry(target).or_default();
470 if queues.values().map(VecDeque::len).sum::<usize>() >= MAX_QUEUED {
471 return Err(RunError::QueueFull);
472 }
473 queues
474 .entry(format!("{}:{}", edge.id, sender.agent_id))
475 .or_default()
476 .push_back(summary.to_owned());
477 self.trace("itemQueued", Some(sender.agent_id), Some(target), summary);
478 }
479 }
480 Ok(())
481 }
482
483 fn activate_graph_receivers(&mut self) -> Result<(), RunError> {
484 let definition = self
485 .workflow_type
486 .definition
487 .graph
488 .as_ref()
489 .expect("graph")
490 .clone();
491 let senders = self.agents.clone();
492 for agent in &mut self.agents {
493 let graph = self.graph.as_mut().expect("graph");
494 if graph.assigned.contains(&agent.agent_id) {
495 continue;
496 }
497 if graph.ready.contains(&agent.agent_id) {
498 continue;
499 }
500 let Some(queues) = graph.queues.get_mut(&agent.agent_id) else {
501 continue;
502 };
503 let required: Vec<_> = definition
504 .edges
505 .iter()
506 .filter(|edge| {
507 edge.to == agent.role
508 && edge.from != "task"
509 && edge.event != GraphEvent::Feedback
510 })
511 .flat_map(|edge| {
512 senders
513 .iter()
514 .filter(|sender| sender.role == edge.from)
515 .map(move |sender| format!("{}:{}", edge.id, sender.agent_id))
516 })
517 .collect();
518 let all = definition.receive.get(&agent.role) == Some(&GraphReceive::All);
519 let feedback = queues.iter().any(|(key, queue)| {
520 !queue.is_empty() && feedback_key(&definition, &agent.role, key)
521 });
522 if all
523 && !feedback
524 && required
525 .iter()
526 .any(|key| queues.get(key).is_none_or(VecDeque::is_empty))
527 {
528 continue;
529 }
530 let mut messages = Vec::new();
531 if all && !feedback {
532 for key in required {
533 if let Some(value) = queues.get_mut(&key).and_then(VecDeque::pop_front) {
534 messages.push(sender_message(&senders, &key, &value));
535 }
536 }
537 } else if let Some((key, queue)) = queues.iter_mut().find(|(key, queue)| {
538 !queue.is_empty() && (!feedback || feedback_key(&definition, &agent.role, key))
539 }) && let Some(value) = queue.pop_front()
540 {
541 messages.push(sender_message(&senders, key, &value));
542 }
543 if messages.is_empty() {
544 continue;
545 }
546 let count = graph.activations.entry(agent.agent_id).or_default();
547 if *count >= definition.max_activations {
548 return Err(RunError::ActivationLimit);
549 }
550 *count += 1;
551 graph.ready.insert(agent.agent_id);
552 *graph
553 .assignment_generations
554 .entry(agent.agent_id)
555 .or_default() += 1;
556 graph.started_at.insert(agent.agent_id, timestamp());
557 graph.outputs.remove(&agent.agent_id);
558 for edge in definition
559 .edges
560 .iter()
561 .filter(|edge| edge.from == agent.role && edge.to == "finish")
562 {
563 graph
564 .finished_links
565 .remove(&format!("{}:{}", edge.id, agent.agent_id));
566 }
567 self.completions.remove(&agent.agent_id);
568 self.incoming.insert(agent.agent_id, messages.join("\n\n"));
569 agent.status = RunAgentStatus::Waiting;
570 }
571 Ok(())
572 }
573
574 pub(crate) fn choose_graph_result(
575 &mut self,
576 agent_id: u64,
577 generation: u64,
578 ) -> Result<(), RunError> {
579 let graph = self.graph.as_ref().ok_or(RunError::InvalidType)?;
580 if self.generation != generation
581 || self.status != RunStatus::Running
582 || !self.graph_choice_ready()
583 || !graph.outputs.contains_key(&agent_id)
584 {
585 return Err(RunError::StaleSignal);
586 }
587 self.graph.as_mut().expect("graph").selected = Some(agent_id);
588 self.trace(
589 "resultSelected",
590 Some(agent_id),
591 None,
592 "User selected this result",
593 );
594 self.finish(RunStatus::Completed, "Result selected");
595 Ok(())
596 }
597
598 pub(crate) fn graph_instructions(&self, agent_id: u64, command: &str) -> String {
599 let agent = self
600 .agents
601 .iter()
602 .find(|agent| agent.agent_id == agent_id)
603 .expect("agent");
604 let role = self
605 .workflow_type
606 .definition
607 .roles
608 .iter()
609 .find(|role| role.id.0 == agent.role)
610 .expect("role");
611 let graph = self.workflow_type.definition.graph.as_ref().expect("graph");
612 let routes = graph
613 .edges
614 .iter()
615 .filter(|edge| edge.from == agent.role)
616 .map(|edge| format!("{:?} -> {}", edge.event, edge.to))
617 .collect::<Vec<_>>()
618 .join("\n");
619 let decision = if self.is_reviewer(agent_id) {
620 "approve"
621 } else {
622 "done"
623 };
624 format!(
625 "You are {} in a Twine workflow.\n{}\n\nThese are role instructions. Wait for an actual task or handoff. Do not start work at launch.\nAll agents work in this folder. Do not create an isolated checkout.\n\nOutgoing links:\n{routes}\n\nSubmit explicit signals with this command:\n{command} <<'TWINE_COMPLETION'\n{{\"decision\":\"{decision}\",\"summary\":\"Your complete result\",\"task\":\"The user's full task\"}}\nTWINE_COMPLETION\n\nUse pieceReady to send a completed piece without stopping your work. Put the full piece or file references in summary. Do not include assignments. Use done when your assignment is complete. Review roles use approve or requestChanges. A request for changes needs actionable feedback in summary. Include the user's full task in task when you submit the first result or piece. Keep this command for later assignments. Do not infer completion from terminal output. A full receiver queue rejects the signal; retry after the receiver completes work.",
626 agent.label, role.instructions
627 )
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use crate::workflow_type::{
635 AgentGraph, GraphEdge, InstanceCount, Role, RoleId, WorkflowType, WorkflowTypeDefinition,
636 WorkflowTypeRef,
637 };
638 use crate::{HarnessId, RoleLaunch};
639
640 fn run(edges: &[(&str, &str, GraphEvent)], finish: GraphFinish) -> WorkflowRun {
641 let roles: BTreeSet<_> = edges
642 .iter()
643 .flat_map(|(from, to, _)| [*from, *to])
644 .filter(|id| *id != "task" && *id != "finish")
645 .collect();
646 let definition = WorkflowTypeDefinition {
647 name: "Graph test".into(),
648 description: String::new(),
649 roles: roles
650 .iter()
651 .map(|id| Role {
652 id: RoleId((*id).into()),
653 name: (*id).into(),
654 instructions: "Complete incoming work".into(),
655 instances: InstanceCount::default(),
656 color: None,
657 agent_defaults: None,
658 })
659 .collect(),
660 stages: vec![],
661 handoffs: vec![],
662 review_loops: vec![],
663 graph: Some(AgentGraph {
664 edges: edges
665 .iter()
666 .enumerate()
667 .map(|(index, (from, to, event))| GraphEdge {
668 id: index.to_string(),
669 from: (*from).into(),
670 to: (*to).into(),
671 event: *event,
672 })
673 .collect(),
674 receive: BTreeMap::new(),
675 review_agents: BTreeSet::new(),
676 positions: BTreeMap::new(),
677 finish,
678 max_activations: 32,
679 }),
680 };
681 let choices = roles
682 .iter()
683 .map(|role| RoleLaunch {
684 role: (*role).into(),
685 harness: HarnessId::Pi,
686 model: None,
687 effort: None,
688 yolo: false,
689 })
690 .collect::<Vec<_>>();
691 let mut run = WorkflowRun::new(
692 WorkflowType {
693 reference: WorkflowTypeRef::User {
694 type_id: 1,
695 version: 1,
696 },
697 definition,
698 },
699 String::new(),
700 &choices,
701 )
702 .unwrap();
703 for (index, agent) in run.agents.iter_mut().enumerate() {
704 agent.agent_id = u64::try_from(index + 1).unwrap();
705 }
706 run.start_graph_task("The exact same user task\nincluding all details".into())
707 .unwrap();
708 launch_ready(&mut run);
709 run
710 }
711
712 fn example_run(type_id: u64, file: &str) -> WorkflowRun {
713 let mut store = crate::store::Store::open_in_memory().unwrap();
715 std::fs::create_dir_all(&store.workflow_directory).unwrap();
716 std::fs::write(store.workflow_directory.join("example.toml"), file).unwrap();
717 let workflow_type = crate::workflow_type::WorkflowCatalog::new(&mut store)
718 .get(WorkflowTypeRef::User {
719 type_id,
720 version: 1,
721 })
722 .unwrap();
723 let choices = workflow_type
724 .definition
725 .roles
726 .iter()
727 .flat_map(|role| {
728 (0..role.instances.min).map(|_| RoleLaunch {
729 role: role.id.0.clone(),
730 harness: HarnessId::Pi,
731 model: None,
732 effort: None,
733 yolo: false,
734 })
735 })
736 .collect::<Vec<_>>();
737 let mut run = WorkflowRun::new(workflow_type, String::new(), &choices).unwrap();
738 for (index, agent) in run.agents.iter_mut().enumerate() {
739 agent.agent_id = u64::try_from(index + 1).unwrap();
740 }
741 assert_ne!(run.active_agents(), [] as [&super::super::RunAgent; 0]);
742 run.start_graph_task("The original user task".into())
743 .unwrap();
744 launch_ready(&mut run);
745 run
746 }
747
748 fn adversarial_example() -> WorkflowRun {
749 example_run(
750 202_610_100_001,
751 include_str!("../../../examples/workflows/adversarial.toml"),
752 )
753 }
754
755 #[test]
756 fn idle_resume_advances_the_effective_root_generation() {
757 let mut run = adversarial_example();
758 run.graph = Some(GraphRun::default());
759 run.prompt.clear();
760 let old = run.assignment_generation(1);
761 run.prepare_graph_resume().unwrap();
762 assert_eq!(run.assignment_generation(1), old + 1);
763 let mut completion = signal(Decision::Done, "Implementation");
764 completion.task = "New task".into();
765 assert!(run.complete(1, old, completion.clone()).is_err());
766 assert!(run.complete(1, old + 1, completion).is_ok());
767 }
768
769 #[test]
770 fn adversarial_follow_up_resets_cycle_and_keeps_completion_tokens_fresh() {
771 for limit in [false, true] {
772 let mut run = adversarial_example();
773 if limit {
774 run.workflow_type
775 .definition
776 .graph
777 .as_mut()
778 .unwrap()
779 .max_activations = 1;
780 }
781 let old_root = run.assignment_generation(1);
782 run.complete(1, old_root, signal(Decision::Done, "First work"))
783 .unwrap();
784 launch_ready(&mut run);
785 let old_review = run.assignment_generation(2);
786 run.complete(
787 2,
788 old_review,
789 signal(
790 if limit {
791 Decision::RequestChanges
792 } else {
793 Decision::Approve
794 },
795 "First review",
796 ),
797 )
798 .unwrap();
799 assert_eq!(
800 run.status,
801 if limit {
802 RunStatus::LimitReached
803 } else {
804 RunStatus::Completed
805 }
806 );
807 assert!(run.continue_with(2, run.generation, 0).is_err());
808 assert!(run.continue_with(1, run.generation, 0).unwrap());
809 assert!(run.needs_task());
810 let graph = run.graph.as_ref().unwrap();
811 assert!(
812 graph.outputs.is_empty() && graph.queues.is_empty() && graph.activations.is_empty()
813 );
814 assert!(
815 run.complete(1, old_root, signal(Decision::Done, "Stale"))
816 .is_err()
817 );
818 run.adopt_graph_task(1, "Second user task".into()).unwrap();
819 let root = run.assignment_generation(1);
820 assert!(root > old_root);
821 run.complete(1, root, signal(Decision::Done, "Second work"))
822 .unwrap();
823 launch_ready(&mut run);
824 assert!(
825 run.complete(2, old_review, signal(Decision::Approve, "Stale"))
826 .is_err()
827 );
828 run.complete(
829 2,
830 run.assignment_generation(2),
831 signal(Decision::Approve, "Second review"),
832 )
833 .unwrap();
834 assert_eq!(run.status, RunStatus::Completed);
835 assert_eq!(run.prompt, "Second user task");
836 assert!(!run.continue_with(1, 1, 0).unwrap());
837 }
838 }
839
840 #[test]
841 fn graph_continuation_uses_cycle_generation_after_multiple_review_activations() {
842 let mut run = adversarial_example();
843 for cycle in 1..=3 {
844 for changes in [true, false] {
845 run.complete(
846 1,
847 run.assignment_generation(1),
848 signal(Decision::Done, "Work"),
849 )
850 .unwrap();
851 launch_ready(&mut run);
852 run.complete(
853 2,
854 run.assignment_generation(2),
855 signal(
856 if changes {
857 Decision::RequestChanges
858 } else {
859 Decision::Approve
860 },
861 "Review",
862 ),
863 )
864 .unwrap();
865 launch_ready(&mut run);
866 }
867 assert_eq!(run.status, RunStatus::Completed);
868 assert_eq!(run.generation, cycle);
869 assert!(run.continue_with(1, cycle, 0).unwrap());
870 assert_eq!(run.traces.last().unwrap().generation, cycle + 1);
871 run.adopt_graph_task(1, format!("Task {}", cycle + 1))
872 .unwrap();
873 }
874 }
875
876 #[test]
877 fn adversarial_file_supports_approval_after_zero_to_three_change_requests() {
878 for changes in 0..=3_u16 {
879 let mut run = adversarial_example();
880 for round in 0..=changes {
881 let generation = run.assignment_generation(1);
882 run.complete(1, generation, signal(Decision::Done, "Implementation"))
883 .unwrap();
884 assert!(
885 run.follow_up_instructions(2)
886 .contains("The original user task")
887 );
888 launch_ready(&mut run);
889 let generation = run.assignment_generation(2);
890 if round < changes {
891 run.complete(
892 2,
893 generation,
894 signal(Decision::RequestChanges, "Change this"),
895 )
896 .unwrap();
897 assert!(
898 run.follow_up_instructions(1)
899 .contains("The original user task")
900 );
901 launch_ready(&mut run);
902 } else {
903 run.complete(2, generation, signal(Decision::Approve, "Final approval"))
904 .unwrap();
905 }
906 }
907 assert_eq!(run.status, RunStatus::Completed);
908 assert_eq!(
909 run.final_review_messages(2),
910 BTreeMap::from([(1, "From Reviewer:\nFinal approval".into())])
911 );
912 assert_eq!(run.graph.as_ref().unwrap().activations[&1], changes + 1);
913 }
914 }
915
916 #[test]
917 fn adversarial_file_delivers_latest_rejection_without_a_fifth_implementation() {
918 let mut run = adversarial_example();
919 for round in 1..=4 {
920 run.complete(
921 1,
922 run.assignment_generation(1),
923 signal(Decision::Done, "Implementation"),
924 )
925 .unwrap();
926 launch_ready(&mut run);
927 run.complete(
928 2,
929 run.assignment_generation(2),
930 signal(Decision::RequestChanges, &format!("Feedback {round}")),
931 )
932 .unwrap();
933 launch_ready(&mut run);
934 }
935 assert_eq!(run.status, RunStatus::LimitReached);
936 assert_eq!(run.active_agents().len(), 0);
937 assert_eq!(run.graph.as_ref().unwrap().activations[&1], 4);
938 assert_eq!(
939 run.final_review_messages(2),
940 BTreeMap::from([(1, "From Reviewer:\nFeedback 4".into())])
941 );
942 }
943
944 #[test]
945 fn coordinator_file_reuses_one_coordinator_and_gathers_named_parallel_results() {
946 let mut run = example_run(
947 202_610_100_002,
948 include_str!("../../../examples/workflows/coordinator-shared-plan.toml"),
949 );
950 assert_eq!(run.active_agents().len(), 1);
951 run.complete(1, 1, signal(Decision::PieceReady, "1. Task A\n2. Task B"))
952 .unwrap();
953 launch_ready(&mut run);
954 assert_eq!(run.active_agents().len(), 3);
955 assert_eq!(run.incoming[&2], run.incoming[&3]);
956 run.complete(2, 1, signal(Decision::Done, "Result A"))
958 .unwrap();
959 assert_eq!(run.assignment_generation(1), 1);
960 run.complete(1, 1, signal(Decision::Done, "Plan sent"))
961 .unwrap();
962 assert_eq!(run.status, RunStatus::Running);
963 assert_eq!(run.active_agents().len(), 1);
964 run.complete(3, 1, signal(Decision::Done, "Result B"))
965 .unwrap();
966 assert_eq!(run.assignment_generation(1), 2);
967 assert_eq!(
968 run.incoming[&1],
969 "From Worker 1:\nResult A\n\nFrom Worker 2:\nResult B"
970 );
971 assert!(
972 run.follow_up_instructions(1)
973 .contains("The original user task")
974 );
975 assert!(run.graph.as_ref().unwrap().outputs.is_empty());
976 launch_ready(&mut run);
977 run.complete(1, 2, signal(Decision::Done, "Combined result"))
978 .unwrap();
979 assert_eq!(run.status, RunStatus::Completed);
980 let graph = run.graph.as_ref().unwrap();
981 assert_eq!(graph.activations, BTreeMap::from([(1, 2), (2, 1), (3, 1)]));
982 assert_eq!(graph.outputs[&1].summary, "Combined result");
983 }
984
985 fn launch_ready(run: &mut WorkflowRun) {
986 let ready = run.graph.as_ref().unwrap().ready.clone();
987 for id in ready {
988 run.graph_agent_launched(id);
989 run.agents
990 .iter_mut()
991 .find(|agent| agent.agent_id == id)
992 .unwrap()
993 .status = RunAgentStatus::Running;
994 }
995 }
996
997 fn signal(decision: Decision, summary: &str) -> CompletionSignal {
998 CompletionSignal {
999 decision,
1000 summary: summary.into(),
1001 assignments: vec![],
1002 task: String::new(),
1003 }
1004 }
1005
1006 #[test]
1007 fn parallel_results_wait_for_selection_and_keep_both_outputs() {
1008 let mut run = run(
1009 &[
1010 ("task", "a", GraphEvent::Task),
1011 ("task", "b", GraphEvent::Task),
1012 ("a", "finish", GraphEvent::Done),
1013 ("b", "finish", GraphEvent::Done),
1014 ],
1015 GraphFinish::Choose,
1016 );
1017 assert_eq!(run.initial_task(1), run.initial_task(2));
1018 assert_eq!(run.active_agents().len(), 2);
1019 run.complete(1, 1, signal(Decision::Done, "First result"))
1020 .unwrap();
1021 assert_eq!(run.active_agents().len(), 1);
1022 assert!(run.choose_graph_result(1, 1).is_err());
1023 run.complete(2, 1, signal(Decision::Done, "Second result"))
1024 .unwrap();
1025 assert_eq!(run.status, RunStatus::Running);
1026 assert_eq!(run.graph.as_ref().unwrap().outputs.len(), 2);
1027 run.status = RunStatus::Interrupted;
1028 run.prepare_graph_resume().unwrap();
1029 run.status = RunStatus::Running;
1030 run.message = None;
1031 run.generation += 1;
1032 assert_eq!(run.active_agents().len(), 0);
1033 run.choose_graph_result(2, 2).unwrap();
1034 assert_eq!(run.status, RunStatus::Completed);
1035 assert_eq!(run.graph.as_ref().unwrap().selected, Some(2));
1036 }
1037
1038 #[test]
1039 fn pieces_start_the_receiver_while_the_producer_runs_and_queue_busy_work() {
1040 let mut run = run(
1041 &[
1042 ("task", "a", GraphEvent::Task),
1043 ("a", "b", GraphEvent::PieceReady),
1044 ("b", "finish", GraphEvent::Done),
1045 ],
1046 GraphFinish::All,
1047 );
1048 assert!(
1049 run.complete(1, 1, signal(Decision::PieceReady, "Piece one"))
1050 .unwrap()
1051 );
1052 assert_eq!(run.agents[0].status, RunAgentStatus::Running);
1053 assert!(!run.completions.contains_key(&1));
1054 launch_ready(&mut run);
1055 assert!(
1056 !run.complete(1, 1, signal(Decision::PieceReady, "Piece two"))
1057 .unwrap()
1058 );
1059 run.complete(1, 1, signal(Decision::Done, "Producer done"))
1060 .unwrap();
1061 assert!(
1062 run.complete(2, 1, signal(Decision::Done, "Port one"))
1063 .unwrap()
1064 );
1065 assert!(run.incoming[&2].contains("Piece two"));
1066 assert_eq!(run.assignment_generation(2), 2);
1067 launch_ready(&mut run);
1068 assert!(matches!(
1069 run.complete(2, 1, signal(Decision::Done, "Stale")),
1070 Err(RunError::StaleSignal)
1071 ));
1072 run.complete(2, 2, signal(Decision::Done, "Port two"))
1073 .unwrap();
1074 assert_eq!(run.status, RunStatus::Completed);
1075 assert_eq!(run.graph.as_ref().unwrap().outputs[&2].summary, "Port two");
1076 assert_eq!(
1077 run.traces
1078 .iter()
1079 .filter(|event| event.kind == "roleCompleted" && event.agent_id == Some(2))
1080 .map(|event| event.generation)
1081 .collect::<Vec<_>>(),
1082 [1, 2]
1083 );
1084 }
1085
1086 #[test]
1087 fn a_full_queue_rejects_the_whole_fan_out_without_consuming_the_signal() {
1088 let mut run = run(
1089 &[
1090 ("task", "a", GraphEvent::Task),
1091 ("a", "b", GraphEvent::PieceReady),
1092 ("b", "finish", GraphEvent::Done),
1093 ],
1094 GraphFinish::All,
1095 );
1096 run.complete(1, 1, signal(Decision::PieceReady, "First"))
1097 .unwrap();
1098 launch_ready(&mut run);
1099 for _ in 0..MAX_QUEUED {
1100 run.complete(1, 1, signal(Decision::PieceReady, "Queued"))
1101 .unwrap();
1102 }
1103 let before = run.clone();
1104 assert!(matches!(
1105 run.complete(1, 1, signal(Decision::PieceReady, "Overflow")),
1106 Err(RunError::QueueFull)
1107 ));
1108 assert_eq!(run, before);
1109 }
1110
1111 #[test]
1112 fn all_inputs_join_only_after_every_sender_and_blocked_joins_end() {
1113 let mut run = run(
1114 &[
1115 ("task", "a", GraphEvent::Task),
1116 ("task", "b", GraphEvent::Task),
1117 ("a", "c", GraphEvent::Done),
1118 ("b", "c", GraphEvent::Done),
1119 ("c", "finish", GraphEvent::Done),
1120 ],
1121 GraphFinish::All,
1122 );
1123 run.workflow_type
1124 .definition
1125 .graph
1126 .as_mut()
1127 .unwrap()
1128 .receive
1129 .insert("c".into(), GraphReceive::All);
1130 run.complete(1, 1, signal(Decision::Done, "A")).unwrap();
1131 assert!(run.graph.as_ref().unwrap().ready.is_empty());
1132 run.complete(2, 1, signal(Decision::Done, "B")).unwrap();
1133 assert!(run.incoming[&3].contains('A') && run.incoming[&3].contains('B'));
1134 launch_ready(&mut run);
1135 run.complete(3, 1, signal(Decision::Done, "Joined"))
1136 .unwrap();
1137 assert_eq!(run.status, RunStatus::Completed);
1138
1139 let mut blocked = tests::run(
1140 &[
1141 ("task", "a", GraphEvent::Task),
1142 ("a", "b", GraphEvent::PieceReady),
1143 ("a", "b", GraphEvent::Done),
1144 ("b", "finish", GraphEvent::Done),
1145 ],
1146 GraphFinish::All,
1147 );
1148 blocked
1149 .workflow_type
1150 .definition
1151 .graph
1152 .as_mut()
1153 .unwrap()
1154 .receive
1155 .insert("b".into(), GraphReceive::All);
1156 blocked
1157 .complete(1, 1, signal(Decision::Done, "No piece"))
1158 .unwrap();
1159 assert_eq!(blocked.status, RunStatus::Failed);
1160 }
1161
1162 #[test]
1163 fn feedback_uses_exact_link_ids_and_does_not_consume_a_pending_join_item() {
1164 let mut run = run(
1165 &[
1166 ("task", "b", GraphEvent::Task),
1167 ("task", "c", GraphEvent::Task),
1168 ("b", "a", GraphEvent::Done),
1169 ("c", "a", GraphEvent::PieceReady),
1170 ("c", "a", GraphEvent::Feedback),
1171 ("c", "finish", GraphEvent::Approved),
1172 ("a", "finish", GraphEvent::Done),
1173 ],
1174 GraphFinish::All,
1175 );
1176 let graph = run.workflow_type.definition.graph.as_mut().unwrap();
1177 graph.receive.insert("a".into(), GraphReceive::All);
1178 for edge in &mut graph.edges {
1179 if edge.event == GraphEvent::Feedback {
1180 edge.id = "a".into();
1181 }
1182 if edge.from == "b" && edge.to == "a" {
1183 edge.id = "a:x".into();
1184 }
1185 }
1186 let encoded = toml::to_string(&run.workflow_type.definition).unwrap();
1187 run.workflow_type.definition = toml::from_str(&encoded).unwrap();
1188 crate::workflow_type::validate(&run.workflow_type.definition).unwrap();
1189 run.complete(2, 1, signal(Decision::Done, "Pending normal item"))
1190 .unwrap();
1191 assert!(!run.graph.as_ref().unwrap().ready.contains(&1));
1192 run.complete(3, 1, signal(Decision::RequestChanges, "Actual feedback"))
1193 .unwrap();
1194 assert!(run.graph.as_ref().unwrap().ready.contains(&1));
1195 assert!(run.incoming[&1].contains("Actual feedback"));
1196 assert!(!run.incoming[&1].contains("Pending normal item"));
1197 assert_eq!(run.graph.as_ref().unwrap().queues[&1]["a:x:2"].len(), 1);
1198 }
1199
1200 #[test]
1201 fn graph_recovery_keeps_the_assignment_and_elapsed_times_are_independent() {
1202 let mut run = run(
1203 &[
1204 ("task", "a", GraphEvent::Task),
1205 ("task", "b", GraphEvent::Task),
1206 ("a", "finish", GraphEvent::Done),
1207 ("b", "finish", GraphEvent::Done),
1208 ],
1209 GraphFinish::Timed,
1210 );
1211 let now = timestamp();
1212 run.graph
1213 .as_mut()
1214 .unwrap()
1215 .started_at
1216 .insert(1, now.saturating_sub(100));
1217 run.graph
1218 .as_mut()
1219 .unwrap()
1220 .started_at
1221 .insert(2, now.saturating_sub(20));
1222 run.complete(1, 1, signal(Decision::Done, "A")).unwrap();
1223 run.agents[1].status = RunAgentStatus::Interrupted;
1224 run.status = RunStatus::Interrupted;
1225 assert_eq!(run.active_agents()[0].agent_id, 2);
1226 run.prepare_graph_resume().unwrap();
1227 run.status = RunStatus::Running;
1228 launch_ready(&mut run);
1229 run.complete(2, run.assignment_generation(2), signal(Decision::Done, "B"))
1230 .unwrap();
1231 assert_eq!(run.status, RunStatus::Completed);
1232 assert!(run.graph.as_ref().unwrap().outputs[&1].elapsed_ms >= 100);
1233 assert!(run.graph.as_ref().unwrap().outputs[&2].elapsed_ms >= 20);
1234 }
1235
1236 #[test]
1237 fn an_approved_only_role_can_finish_and_feedback_loops_are_bounded() {
1238 let mut approved = run(
1239 &[
1240 ("task", "a", GraphEvent::Task),
1241 ("a", "finish", GraphEvent::Approved),
1242 ],
1243 GraphFinish::All,
1244 );
1245 assert!(approved.is_reviewer(1));
1246 approved
1247 .complete(1, 1, signal(Decision::Approve, "Approved"))
1248 .unwrap();
1249 assert_eq!(approved.status, RunStatus::Completed);
1250 let mut run = run(
1251 &[
1252 ("task", "a", GraphEvent::Task),
1253 ("a", "b", GraphEvent::Done),
1254 ("b", "a", GraphEvent::Feedback),
1255 ("b", "finish", GraphEvent::Approved),
1256 ],
1257 GraphFinish::All,
1258 );
1259 run.workflow_type
1260 .definition
1261 .graph
1262 .as_mut()
1263 .unwrap()
1264 .max_activations = 1;
1265 run.complete(1, 1, signal(Decision::Done, "Result"))
1266 .unwrap();
1267 launch_ready(&mut run);
1268 run.complete(2, 1, signal(Decision::RequestChanges, "Change this"))
1269 .unwrap();
1270 assert_eq!(run.status, RunStatus::LimitReached);
1271 }
1272}