Skip to main content

twine_core/workflow_run/
graph.rs

1//! Explicit, bounded agent coordination. Busy receivers keep a queue; producers never wait.
2use 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    /// Final review messages use this review's result, never a previous activation's input.
61    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    /// The first native prompt starts the graph without sending it back to its author.
202    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        // Queue overflow or a bad join must not consume a completion or part of its fan-out.
248        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(&timestamp()),
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(&timestamp()),
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        // Load through the catalog: this tests the actual shareable file envelope and validation.
714        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        // Results can arrive while the coordinator is still in its split turn.
957        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}