Skip to main content

twine_core/
workflow_run.rs

1//! Bounded, ordered workflow execution, independent of terminals and UI.
2
3use std::collections::BTreeMap;
4use std::fmt::Write;
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::harness::HarnessId;
10use crate::workflow::timestamp;
11use crate::workflow_type::{Completion, HandoffContent, WorkflowType, WorkflowTypeRef, validate};
12
13pub(crate) mod completion;
14mod graph;
15mod recovery;
16pub use graph::{GraphOutput, GraphRun};
17
18/// One harness choice per role instance, fixed at launch.
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct RoleLaunch {
22    pub role: String,
23    pub harness: HarnessId,
24    /// The harness's model, or its own default when absent.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub model: Option<String>,
27    /// The harness's effort level, or its own default when absent.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub effort: Option<String>,
30    /// Skips the harness's permission prompts, where it has them.
31    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
32    pub yolo: bool,
33}
34
35#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub enum Decision {
38    Done,
39    PieceReady,
40    Approve,
41    RequestChanges,
42}
43
44#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct Assignment {
47    pub role: String,
48    /// One-based instance number, as listed in the sender's instructions.
49    pub instance: u8,
50    pub task: String,
51    pub files: Vec<String>,
52}
53
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct CompletionSignal {
57    pub decision: Decision,
58    #[serde(default)]
59    pub summary: String,
60    #[serde(default)]
61    pub assignments: Vec<Assignment>,
62    /// The user's task, restated in full. Required while the run doesn't know its task yet.
63    #[serde(default, skip_serializing_if = "String::is_empty")]
64    pub task: String,
65}
66
67#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub enum RunStatus {
70    Running,
71    Completed,
72    LimitReached,
73    Cancelled,
74    Failed,
75    Interrupted,
76}
77
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
79#[serde(rename_all = "camelCase")]
80pub struct RunAgent {
81    pub agent_id: u64,
82    pub role: String,
83    pub instance: u8,
84    pub label: String,
85    pub harness: HarnessId,
86    /// The harness's model, or its own default when absent. Missing in older run records.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub model: Option<String>,
89    /// The harness's effort level, or its own default when absent. Missing in older run records.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub effort: Option<String>,
92    /// Skips the harness's permission prompts, where it has them. Missing in older run records.
93    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
94    pub yolo: bool,
95    /// Defaults for records saved before per-agent lifecycle tracking was introduced.
96    #[serde(default)]
97    pub status: RunAgentStatus,
98}
99
100#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub enum RunAgentStatus {
103    #[default]
104    Waiting,
105    Running,
106    Completed,
107    Exited,
108    Failed,
109    Cancelled,
110    Interrupted,
111}
112
113#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
114#[serde(rename_all = "camelCase")]
115pub struct WorkflowTrace {
116    pub sequence: u64,
117    pub generation: u64,
118    pub timestamp: u64,
119    pub kind: String,
120    pub stage: String,
121    pub agent_id: Option<u64>,
122    pub target_agent_id: Option<u64>,
123    pub message: String,
124    /// Missing in older run records and when no terminal boundary was observed.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub anchor: Option<crate::TraceAnchor>,
127}
128
129/// The most a task, typed up front or reported by the first stage, can hold.
130pub(crate) const MAX_TASK_BYTES: usize = 32 * 1024;
131
132/// Serializable run state. A run pins both its type reference and definition. Processes and
133/// completion mailboxes are deliberately absent; restoring an active run interrupts it.
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
136#[serde(rename_all = "camelCase")]
137pub struct WorkflowRun {
138    pub workflow_type: WorkflowType,
139    /// The task. Empty until the first stage reports it when the run starts without one.
140    pub prompt: String,
141    pub agents: Vec<RunAgent>,
142    pub stage_index: usize,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub graph: Option<GraphRun>,
145    /// Changes on every stage entry, including loop backs. UI actions must match this value.
146    pub generation: u64,
147    /// Private follow-ups never start another workflow cycle. Missing in older records.
148    #[serde(default)]
149    pub individual_mode: bool,
150    /// Invalidates input queued before a mode change without changing stage generations.
151    #[serde(default)]
152    pub mode_revision: u64,
153    pub status: RunStatus,
154    pub completions: BTreeMap<u64, CompletionSignal>,
155    pub rounds: BTreeMap<String, u8>,
156    pub incoming: BTreeMap<u64, String>,
157    /// Assignment ownership remains in force when a review loop revisits a worker.
158    pub assignments: BTreeMap<u64, Assignment>,
159    pub traces: Vec<WorkflowTrace>,
160    pub message: Option<String>,
161}
162
163impl WorkflowRun {
164    pub(crate) fn new(
165        workflow_type: WorkflowType,
166        prompt: String,
167        choices: &[RoleLaunch],
168    ) -> Result<Self, RunError> {
169        validate(&workflow_type.definition).map_err(|_| RunError::InvalidType)?;
170        if prompt.len() > MAX_TASK_BYTES {
171            return Err(RunError::Prompt);
172        }
173        let definition = &workflow_type.definition;
174        let mut agents = Vec::new();
175        for role in &definition.roles {
176            let selected: Vec<_> = choices
177                .iter()
178                .filter(|choice| choice.role == role.id.0)
179                .collect();
180            if !(usize::from(role.instances.min)..=usize::from(role.instances.max))
181                .contains(&selected.len())
182            {
183                return Err(RunError::HarnessChoices);
184            }
185            for (index, choice) in selected.iter().enumerate() {
186                let instance = u8::try_from(index + 1).map_err(|_| RunError::HarnessChoices)?;
187                let options = crate::harness::launch::validate_options(
188                    choice.harness,
189                    choice.model.as_deref(),
190                    choice.effort.as_deref(),
191                    choice.yolo,
192                )
193                .ok_or(RunError::Model)?;
194                agents.push(RunAgent {
195                    agent_id: 0,
196                    role: role.id.0.clone(),
197                    instance,
198                    label: if selected.len() > 1 {
199                        format!("{} {instance}", role.name)
200                    } else {
201                        role.name.clone()
202                    },
203                    harness: choice.harness,
204                    model: options.model().map(str::to_owned),
205                    effort: options.effort().map(str::to_owned),
206                    yolo: options.yolo(),
207                    status: RunAgentStatus::Waiting,
208                });
209            }
210        }
211        if choices.len() != agents.len() {
212            return Err(RunError::HarnessChoices);
213        }
214        let graph = definition.graph.as_ref().map(|_| GraphRun::default());
215        Ok(Self {
216            graph,
217            workflow_type,
218            prompt,
219            agents,
220            stage_index: 0,
221            generation: 1,
222            individual_mode: false,
223            mode_revision: 0,
224            status: RunStatus::Running,
225            completions: BTreeMap::new(),
226            rounds: BTreeMap::new(),
227            incoming: BTreeMap::new(),
228            assignments: BTreeMap::new(),
229            traces: Vec::new(),
230            message: None,
231        })
232    }
233
234    #[must_use]
235    pub fn reference(&self) -> WorkflowTypeRef {
236        self.workflow_type.reference
237    }
238
239    #[must_use]
240    pub fn active_agents(&self) -> Vec<&RunAgent> {
241        if let Some(graph) = &self.graph {
242            return self
243                .agents
244                .iter()
245                .filter(|agent| {
246                    if graph.started {
247                        graph.ready.contains(&agent.agent_id)
248                            || graph.assigned.contains(&agent.agent_id)
249                    } else {
250                        self.workflow_type
251                            .definition
252                            .graph
253                            .as_ref()
254                            .is_some_and(|definition| definition.is_start(&agent.role))
255                    }
256                })
257                .collect();
258        }
259        let stage = &self.workflow_type.definition.stages[self.stage_index];
260        self.agents
261            .iter()
262            .filter(|agent| stage.roles.iter().any(|role| role.0 == agent.role))
263            .collect()
264    }
265
266    #[must_use]
267    pub fn is_reviewer(&self, agent_id: u64) -> bool {
268        if let Some(graph) = &self.workflow_type.definition.graph {
269            return self
270                .agents
271                .iter()
272                .find(|agent| agent.agent_id == agent_id)
273                .is_some_and(|agent| graph.is_review(&agent.role));
274        }
275        matches!(&self.workflow_type.definition.stages[self.stage_index].completion,
276            Completion::ReviewDecision { reviewer } if self.agents.iter().any(|a| a.agent_id == agent_id && a.role == reviewer.0))
277    }
278
279    /// Receivers whose assignments must be supplied by this participant before it can finish.
280    #[must_use]
281    pub fn assignment_targets(&self, agent_id: u64) -> Vec<&RunAgent> {
282        if self.graph.is_some() {
283            return Vec::new();
284        }
285        let stage = &self.workflow_type.definition.stages[self.stage_index];
286        let Some(sender) = self.agents.iter().find(|a| a.agent_id == agent_id) else {
287            return Vec::new();
288        };
289        self.agents
290            .iter()
291            .filter(|target| {
292                self.workflow_type.definition.handoffs.iter().any(|h| {
293                    h.from.stage == stage.id
294                        && h.from.role.0 == sender.role
295                        && h.to.role.0 == target.role
296                        && h.content == HandoffContent::Assignment
297                })
298            })
299            .collect()
300    }
301
302    /// A user follow-up starts fresh work while keeping the workflow's agents and trace history.
303    pub(crate) fn continue_with(
304        &mut self,
305        agent_id: u64,
306        generation: u64,
307        mode_revision: u64,
308    ) -> Result<bool, RunError> {
309        let roots = if self.graph.is_some() {
310            self.graph_roots()
311        } else {
312            self.workflow_type.definition.stages[0]
313                .roles
314                .iter()
315                .map(|role| role.0.clone())
316                .collect()
317        };
318        if !self
319            .agents
320            .iter()
321            .any(|agent| agent.agent_id == agent_id && roots.contains(&agent.role))
322        {
323            return Err(RunError::StaleSignal);
324        }
325        if generation > self.generation {
326            return Err(RunError::StaleSignal);
327        }
328        if self.individual_mode || mode_revision != self.mode_revision {
329            return Ok(false);
330        }
331        // Input can arrive before Swift consumes the latest state, or queue behind an earlier
332        // continuation. A running workflow needs no reset, and old-cycle input still reaches the
333        // conversation without starting the same cycle twice.
334        if self.status == RunStatus::Running
335            || self
336                .traces
337                .iter()
338                .any(|event| event.kind == "workflowContinued" && event.generation > generation)
339        {
340            return Ok(false);
341        }
342        if !matches!(self.status, RunStatus::Completed | RunStatus::LimitReached) {
343            return Err(RunError::StaleSignal);
344        }
345        let next_generation = self
346            .generation
347            .checked_add(1)
348            .ok_or(RunError::StaleSignal)?;
349        if self.graph.is_some() {
350            let mut counters = self
351                .graph
352                .as_ref()
353                .expect("graph")
354                .assignment_generations
355                .clone();
356            for agent in &self.agents {
357                if roots.contains(&agent.role) {
358                    counters.insert(
359                        agent.agent_id,
360                        self.assignment_generation(agent.agent_id)
361                            .checked_add(1)
362                            .ok_or(RunError::StaleSignal)?,
363                    );
364                }
365            }
366            self.graph = Some(graph::GraphRun {
367                assignment_generations: counters,
368                ..graph::GraphRun::default()
369            });
370        }
371        self.stage_index = 0;
372        self.generation = next_generation;
373        self.status = RunStatus::Running;
374        self.prompt.clear();
375        self.completions.clear();
376        self.rounds.clear();
377        self.incoming.clear();
378        self.assignments.clear();
379        self.message = None;
380        for agent in &mut self.agents {
381            if roots.contains(&agent.role) {
382                agent.status = RunAgentStatus::Waiting;
383            } else if self.graph.is_some() {
384                agent.status = RunAgentStatus::Completed;
385            }
386        }
387        self.trace(
388            "workflowContinued",
389            self.graph.is_none().then_some(agent_id),
390            None,
391            "Continuing with a new task",
392        );
393        Ok(true)
394    }
395
396    /// Changing follow-up mode preserves the completed result and starts no work.
397    pub(crate) fn set_individual_mode(
398        &mut self,
399        individual_mode: bool,
400        generation: u64,
401        mode_revision: u64,
402    ) -> Result<bool, RunError> {
403        if generation != self.generation || mode_revision != self.mode_revision {
404            return Err(RunError::StaleSignal);
405        }
406        if !matches!(self.status, RunStatus::Completed | RunStatus::LimitReached) {
407            return Err(RunError::ModeWhileActive);
408        }
409        if self.individual_mode == individual_mode {
410            return Ok(false);
411        }
412        self.mode_revision = self
413            .mode_revision
414            .checked_add(1)
415            .ok_or(RunError::StaleSignal)?;
416        self.individual_mode = individual_mode;
417        self.trace(
418            "workflowIndividualModeChanged",
419            None,
420            None,
421            if individual_mode {
422                "Individual mode On"
423            } else {
424                "Individual mode Off"
425            },
426        );
427        Ok(true)
428    }
429
430    /// Validate fully before accepting a completion; failed signals never partially advance a run.
431    pub(crate) fn complete(
432        &mut self,
433        agent_id: u64,
434        generation: u64,
435        signal: CompletionSignal,
436    ) -> Result<bool, RunError> {
437        if self.graph.is_some() {
438            return self.complete_graph(agent_id, generation, &signal);
439        }
440        if self.status != RunStatus::Running
441            || generation != self.generation
442            || !self.active_agents().iter().any(|a| a.agent_id == agent_id)
443            || self.completions.contains_key(&agent_id)
444        {
445            return Err(RunError::StaleSignal);
446        }
447        self.validate(agent_id, &signal)?;
448        let reviewer = self.is_reviewer(agent_id);
449        let decision = self.record_completion(agent_id, signal);
450        // A review decision is the completion rule for the whole stage, even when other roles
451        // are helping the reviewer. AllRolesDone instead waits for every role instance.
452        let stage_done = reviewer
453            || self
454                .active_agents()
455                .iter()
456                .all(|a| self.completions.contains_key(&a.agent_id));
457        if !stage_done {
458            return Ok(false);
459        }
460        if self.needs_task() {
461            self.adopt_reported_task();
462        }
463        self.trace("stageCompleted", None, None, "Stage completed");
464        // A review decision can stop other participants without completing their work.
465        for agent in &mut self.agents {
466            if agent.status == RunAgentStatus::Running {
467                agent.status = RunAgentStatus::Cancelled;
468            }
469        }
470        let definition = &self.workflow_type.definition;
471        let stage = &definition.stages[self.stage_index];
472        let next = if decision == Decision::RequestChanges {
473            let review_loop = definition
474                .review_loops
475                .iter()
476                .find(|item| item.review_stage == stage.id)
477                .ok_or(RunError::InvalidType)?;
478            let count = self.rounds.entry(stage.id.0.clone()).or_default();
479            if *count >= review_loop.max_rounds {
480                self.deliver_review_feedback();
481                self.finish(
482                    RunStatus::LimitReached,
483                    "Review limit reached; approval is still required.",
484                );
485                return Ok(true);
486            }
487            *count += 1;
488            let index = definition
489                .stages
490                .iter()
491                .position(|s| s.id == review_loop.back_to)
492                .ok_or(RunError::InvalidType)?;
493            self.trace("reviewLoop", None, None, "Returning for requested changes");
494            index
495        } else {
496            self.stage_index + 1
497        };
498        if next == self.workflow_type.definition.stages.len() {
499            if reviewer {
500                self.deliver_review_feedback();
501            }
502            self.finish(RunStatus::Completed, "Workflow completed");
503        } else {
504            self.deliver_handoffs(next, true);
505            self.stage_index = next;
506            self.generation += 1;
507            self.completions.clear();
508            self.message = None;
509            let stage = &self.workflow_type.definition.stages[self.stage_index];
510            for agent in &mut self.agents {
511                if stage.roles.iter().any(|role| role.0 == agent.role) {
512                    agent.status = RunAgentStatus::Waiting;
513                }
514            }
515        }
516        Ok(true)
517    }
518
519    /// Checks a signal's size, decision, feedback, task, and assignments against the agent's role.
520    fn validate(&self, agent_id: u64, signal: &CompletionSignal) -> Result<(), RunError> {
521        if serde_json::to_vec(signal)
522            .map_err(|_| RunError::SignalTooLarge)?
523            .len()
524            > completion::MAX_SIGNAL_BYTES
525        {
526            return Err(RunError::SignalTooLarge);
527        }
528        if signal.decision == Decision::PieceReady {
529            return Err(RunError::Decision);
530        }
531        let reviewer = self.is_reviewer(agent_id);
532        if reviewer == (signal.decision == Decision::Done) {
533            return Err(RunError::Decision);
534        }
535        if signal.decision == Decision::RequestChanges && signal.summary.trim().is_empty() {
536            return Err(RunError::Feedback);
537        }
538        if self.needs_task()
539            && (signal.task.trim().is_empty() || signal.task.len() > MAX_TASK_BYTES)
540        {
541            return Err(RunError::Task);
542        }
543        let targets = self.assignment_targets(agent_id);
544        if signal.assignments.len() != targets.len()
545            || targets.iter().any(|target| {
546                let assignments: Vec<_> = signal
547                    .assignments
548                    .iter()
549                    .filter(|a| a.role == target.role && a.instance == target.instance)
550                    .collect();
551                assignments.len() != 1
552                    || assignments[0].task.trim().is_empty()
553                    || assignments[0]
554                        .files
555                        .iter()
556                        .any(|file| file.trim().is_empty())
557            })
558        {
559            return Err(RunError::Assignments);
560        }
561        Ok(())
562    }
563
564    /// Every agent in the first stage reports the task the user gave it. Later stages get the one
565    /// task when they agree, or each agent's, labeled, when they differ.
566    fn adopt_reported_task(&mut self) {
567        let reported: Vec<(&str, &str)> = self
568            .agents
569            .iter()
570            .filter_map(|agent| {
571                let task = self.completions.get(&agent.agent_id)?.task.trim();
572                (!task.is_empty()).then_some((agent.label.as_str(), task))
573            })
574            .collect();
575        self.prompt = match reported.as_slice() {
576            [] => String::new(),
577            [(_, task), rest @ ..] if rest.iter().all(|(_, other)| other == task) => {
578                (*task).to_owned()
579            }
580            _ => reported
581                .iter()
582                .map(|(label, task)| format!("{label}: {task}"))
583                .collect::<Vec<_>>()
584                .join("\n\n"),
585        };
586    }
587
588    fn record_completion(&mut self, agent_id: u64, signal: CompletionSignal) -> Decision {
589        self.trace(
590            "roleCompleted",
591            Some(agent_id),
592            None,
593            &format!(
594                "{}: {}",
595                match signal.decision {
596                    Decision::Done => "Marked done",
597                    Decision::PieceReady => "Piece ready",
598                    Decision::Approve => "Approved",
599                    Decision::RequestChanges => "Requested changes",
600                },
601                signal.summary
602            ),
603        );
604        let decision = signal.decision;
605        self.completions.insert(agent_id, signal);
606        self.agents
607            .iter_mut()
608            .find(|a| a.agent_id == agent_id)
609            .expect("completion agent was validated")
610            .status = RunAgentStatus::Completed;
611        decision
612    }
613
614    fn deliver_review_feedback(&mut self) {
615        let definition = &self.workflow_type.definition;
616        let stage = &definition.stages[self.stage_index].id;
617        let target = definition
618            .review_loops
619            .iter()
620            .find(|review_loop| &review_loop.review_stage == stage)
621            .and_then(|review_loop| {
622                definition
623                    .stages
624                    .iter()
625                    .position(|stage| stage.id == review_loop.back_to)
626            });
627        if let Some(target) = target {
628            self.deliver_handoffs(target, false);
629        } else {
630            self.incoming.clear();
631        }
632    }
633
634    fn deliver_handoffs(&mut self, next: usize, starts_assignment: bool) {
635        let definition = &self.workflow_type.definition;
636        let from = &definition.stages[self.stage_index].id;
637        let to = &definition.stages[next].id;
638        let mut incoming = BTreeMap::<u64, String>::new();
639        let mut deliveries = Vec::new();
640        for handoff in definition
641            .handoffs
642            .iter()
643            .filter(|h| &h.from.stage == from && &h.to.stage == to)
644        {
645            for sender in self.agents.iter().filter(|a| a.role == handoff.from.role.0) {
646                let Some(signal) = self.completions.get(&sender.agent_id) else {
647                    continue;
648                };
649                for target in self.agents.iter().filter(|a| a.role == handoff.to.role.0) {
650                    let content = match handoff.content {
651                        HandoffContent::Assignment => {
652                            let Some(assignment) = signal
653                                .assignments
654                                .iter()
655                                .find(|a| a.role == target.role && a.instance == target.instance)
656                            else {
657                                continue;
658                            };
659                            self.assignments.insert(target.agent_id, assignment.clone());
660                            format!(
661                                "Sub-task: {}\nOwned files: {}",
662                                assignment.task,
663                                assignment.files.join(", ")
664                            )
665                        }
666                        HandoffContent::Result | HandoffContent::Feedback => signal.summary.clone(),
667                    };
668                    let _ = write!(
669                        incoming.entry(target.agent_id).or_default(),
670                        "\nFrom {} ({:?}):\n{content}\n",
671                        sender.label,
672                        handoff.content
673                    );
674                    deliveries.push((sender.agent_id, target.agent_id, content));
675                }
676            }
677        }
678        self.incoming = incoming;
679        for (from, to, content) in deliveries {
680            self.trace(
681                if starts_assignment {
682                    "handoff"
683                } else {
684                    "reviewFeedback"
685                },
686                Some(from),
687                Some(to),
688                &format!(
689                    "{}: {content}",
690                    if starts_assignment {
691                        "Handoff delivered"
692                    } else {
693                        "Review feedback delivered"
694                    }
695                ),
696            );
697        }
698    }
699
700    /// Whether the run started without a task and nobody has reported it yet.
701    #[must_use]
702    pub fn needs_task(&self) -> bool {
703        self.prompt.trim().is_empty()
704    }
705
706    fn completion_example(&self, agent_id: u64) -> CompletionSignal {
707        let targets = self.assignment_targets(agent_id);
708        CompletionSignal {
709            decision: if self.is_reviewer(agent_id) {
710                Decision::Approve
711            } else {
712                Decision::Done
713            },
714            summary: "Describe the result or actionable review feedback".to_owned(),
715            assignments: targets
716                .iter()
717                .map(|a| Assignment {
718                    role: a.role.clone(),
719                    instance: a.instance,
720                    task: "The specific sub-task for this worker".to_owned(),
721                    files: vec!["relative/file".to_owned()],
722                })
723                .collect(),
724            task: if self.needs_task() {
725                "The user's task, restated in full".to_owned()
726            } else {
727                String::new()
728            },
729        }
730    }
731
732    pub(crate) fn follow_up_instructions(&self, agent_id: u64) -> String {
733        if self.graph.is_some() {
734            let incoming = self.incoming.get(&agent_id).map_or("", String::as_str);
735            if incoming == self.prompt {
736                return self.prompt.clone();
737            }
738            return format!(
739                "Task:\n{}\n\nIncoming work:\n{incoming}\n\nComplete this assignment using your existing Twine signal command.",
740                self.prompt
741            );
742        }
743        let stage = &self.workflow_type.definition.stages[self.stage_index];
744        format!(
745            "Continue with the {} stage.\n\nTask:\n{}\n\nYour assignment:\n{}\n\n{}\n\nWhen finished, submit the result using your existing completion command:\n{}\n{}",
746            stage.name,
747            self.prompt,
748            self.assignment_instructions(agent_id),
749            self.incoming.get(&agent_id).map_or("", String::as_str),
750            serde_json::to_string_pretty(&self.completion_example(agent_id))
751                .expect("signal serializes"),
752            if self.is_reviewer(agent_id) {
753                "Use approve only when no changes are needed. Otherwise use requestChanges and put actionable feedback in summary."
754            } else {
755                "Use done. Include assignments only for the receivers listed above, with their actual sub-tasks and owned files."
756            },
757        )
758    }
759
760    fn assignment_instructions(&self, agent_id: u64) -> String {
761        self.assignments
762            .get(&agent_id)
763            .map(|a| format!("Sub-task: {}\nOwned files: {}", a.task, a.files.join(", ")))
764            .unwrap_or_default()
765    }
766
767    pub(crate) fn initial_task(&self, agent_id: u64) -> String {
768        if self.needs_task() {
769            String::new()
770        } else {
771            self.follow_up_instructions(agent_id)
772        }
773    }
774
775    pub(crate) fn instructions(&self, agent_id: u64, command: &str) -> String {
776        if self.graph.is_some() {
777            return self.graph_instructions(agent_id, command);
778        }
779        let agent = self
780            .agents
781            .iter()
782            .find(|a| a.agent_id == agent_id)
783            .expect("a launched agent belongs to the run");
784        let role = self
785            .workflow_type
786            .definition
787            .roles
788            .iter()
789            .find(|r| r.id.0 == agent.role)
790            .expect("roles were validated");
791        let stage = &self.workflow_type.definition.stages[self.stage_index];
792        let example = self.completion_example(agent_id);
793        let task = if self.needs_task() {
794            "Wait for the user to give you a task in this terminal. These instructions are setup, not a task. Do not start work or ask a question at launch. Include the actual task, restated in full, as task when you submit your completion."
795        } else {
796            "Wait for a task or a handoff. The next user message supplies the work."
797        };
798        format!(
799            "You are {} in a Twine workflow.\n{}\n\n{}\n\nFirst assignment only:\nThe first task uses the {} stage and JSON example below. Later Twine task messages supply the current stage and JSON example. Use those current rules for each later assignment.\n\nAll agents work directly in this folder. Do not create a worktree or isolated checkout. Give parallel workers non-overlapping file sets; file ownership is advisory.\n\nWhen finished, explicitly submit JSON on stdin to this command:\n{} <<'TWINE_COMPLETION'\n{}\nTWINE_COMPLETION\n\n{}\nFor the first assignment only, supply an assignment for each receiver in this example. Use its exact role and instance, with actual sub-tasks and file sets. For each later assignment, use the receiver list in the current Twine task message. Omit assignments if that list is empty. For each new user task, include task with the user's full task text. A process exit or terminal message does not complete the stage. A rejected submission returns feedback; correct it and submit again. Keep using this completion command for subsequent assignments and user follow-ups in this conversation. The user can also mark done in Twine.\n",
800            agent.label,
801            role.instructions,
802            task,
803            stage.name,
804            command,
805            serde_json::to_string_pretty(&example).expect("signal serializes"),
806            if self.is_reviewer(agent_id) {
807                "Use decision approve or requestChanges. Requested changes require actionable feedback in summary."
808            } else {
809                "Use decision done."
810            }
811        )
812    }
813
814    pub(crate) fn trace(
815        &mut self,
816        kind: &str,
817        agent_id: Option<u64>,
818        target_agent_id: Option<u64>,
819        message: &str,
820    ) {
821        const MAX_TRACES: usize = 512;
822        if self.traces.len() == MAX_TRACES {
823            self.traces.remove(0);
824        }
825        self.traces.push(WorkflowTrace {
826            sequence: self.traces.last().map_or(1, |event| event.sequence + 1),
827            generation: agent_id.map_or(self.generation, |agent| self.assignment_generation(agent)),
828            timestamp: timestamp(),
829            kind: kind.to_owned(),
830            stage: self.stage_name().to_owned(),
831            agent_id,
832            target_agent_id,
833            message: message.to_owned(),
834            anchor: None,
835        });
836    }
837
838    pub(crate) fn finish(&mut self, status: RunStatus, message: &str) {
839        let active: Vec<_> = self.active_agents().iter().map(|a| a.agent_id).collect();
840        for agent in &mut self.agents {
841            if agent.status == RunAgentStatus::Running
842                || (agent.status == RunAgentStatus::Waiting
843                    && active.contains(&agent.agent_id)
844                    && !self.completions.contains_key(&agent.agent_id))
845            {
846                agent.status = match status {
847                    RunStatus::Interrupted => RunAgentStatus::Interrupted,
848                    RunStatus::Failed => RunAgentStatus::Failed,
849                    _ => RunAgentStatus::Cancelled,
850                };
851            }
852        }
853        self.status = status;
854        self.message = Some(message.to_owned());
855        self.trace("workflowEnded", None, None, message);
856    }
857}
858
859#[derive(Debug, Error)]
860pub enum RunError {
861    #[error("The receiver queue is full. Wait for the receiver to complete work, then retry.")]
862    QueueFull,
863    #[error(
864        "The graph activation limit was reached. Increase the limit or end the feedback cycle."
865    )]
866    ActivationLimit,
867    #[error("The workflow is invalid.")]
868    InvalidType,
869    #[error("Enter a prompt of at most 32 KiB.")]
870    Prompt,
871    #[error("Include the user's task, restated in full and at most 32 KiB, as task.")]
872    Task,
873    #[error("Choose a harness for each agent within the workflow's limits.")]
874    HarnessChoices,
875    #[error("Choose each agent's model and effort from the lists, or type one model name.")]
876    Model,
877    #[error("This stage has already advanced or this agent has already finished.")]
878    StaleSignal,
879    #[error("Individual mode can change after the workflow completes or reaches its review limit.")]
880    ModeWhileActive,
881    #[error("This role needs an explicit review decision, or done for a non-review role.")]
882    Decision,
883    #[error("Requested changes need actionable feedback.")]
884    Feedback,
885    #[error("Provide exactly one sub-task and file set for each assignment receiver.")]
886    Assignments,
887    #[error("Completion exceeds 64 KiB.")]
888    SignalTooLarge,
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use crate::{BuiltinType, WorkflowTypeDefinition};
895
896    fn run(definition: WorkflowTypeDefinition) -> WorkflowRun {
897        let choices: Vec<_> = definition
898            .roles
899            .iter()
900            .flat_map(|r| {
901                (0..r.instances.min).map(|_| RoleLaunch {
902                    model: None,
903                    effort: None,
904                    yolo: false,
905                    role: r.id.0.clone(),
906                    harness: HarnessId::Codex,
907                })
908            })
909            .collect();
910        let mut run = WorkflowRun::new(
911            WorkflowType {
912                reference: WorkflowTypeRef::Builtin(BuiltinType::Adversarial),
913                definition,
914            },
915            "Task".into(),
916            &choices,
917        )
918        .unwrap();
919        for (index, agent) in run.agents.iter_mut().enumerate() {
920            agent.agent_id = index as u64 + 1;
921        }
922        run
923    }
924
925    fn signal(decision: Decision) -> CompletionSignal {
926        CompletionSignal {
927            task: String::new(),
928            decision,
929            summary: "Specific result or feedback".into(),
930            assignments: vec![],
931        }
932    }
933
934    #[test]
935    fn review_signals_are_explicit_and_stale_signals_cannot_advance_a_later_round() {
936        let mut run = run(BuiltinType::Adversarial.legacy_definition());
937        assert!(run.complete(1, 1, signal(Decision::Approve)).is_err());
938        assert!(run.complete(2, 1, signal(Decision::Approve)).is_err());
939        run.complete(1, 1, signal(Decision::Done)).unwrap();
940        run.complete(2, 2, signal(Decision::RequestChanges))
941            .unwrap();
942        let previous = run.clone();
943        assert!(run.complete(1, 1, signal(Decision::Done)).is_err());
944        assert_eq!(run, previous);
945        assert!(run.incoming[&1].contains("feedback"));
946        run.complete(1, 3, signal(Decision::Done)).unwrap();
947        run.complete(2, 4, signal(Decision::Approve)).unwrap();
948        assert_eq!(run.status, RunStatus::Completed);
949    }
950
951    #[test]
952    fn individual_mode_preserves_results_and_invalidates_queued_continuations() {
953        let mut run = run(BuiltinType::Adversarial.legacy_definition());
954        assert!(!run.individual_mode);
955        run.complete(1, 1, signal(Decision::Done)).unwrap();
956        run.complete(2, 2, signal(Decision::Approve)).unwrap();
957        let completed = run.clone();
958        assert!(run.set_individual_mode(true, 2, 0).unwrap());
959        assert_eq!(run.generation, completed.generation);
960        assert_eq!(run.completions, completed.completions);
961        assert_eq!(run.agents, completed.agents);
962        assert_eq!(run.prompt, completed.prompt);
963        assert_eq!(run.message, completed.message);
964        assert!(run.traces.starts_with(&completed.traces));
965        assert!(!run.continue_with(1, 2, 1).unwrap());
966        assert!(run.complete(1, 2, signal(Decision::Done)).is_err());
967        assert!(run.set_individual_mode(false, 2, 0).is_err());
968        assert!(run.set_individual_mode(false, 2, 1).unwrap());
969        assert_eq!(run.status, RunStatus::Completed);
970        assert!(!run.continue_with(1, 2, 0).unwrap());
971        assert!(!run.continue_with(1, 2, 1).unwrap());
972        assert!(run.continue_with(1, 2, 2).unwrap());
973        assert_eq!(run.generation, 3);
974        assert_eq!(run.mode_revision, 2);
975    }
976
977    #[test]
978    fn individual_mode_only_changes_after_completion_or_review_limit() {
979        for status in [
980            RunStatus::Running,
981            RunStatus::Failed,
982            RunStatus::Cancelled,
983            RunStatus::Interrupted,
984        ] {
985            let mut run = run(BuiltinType::Adversarial.legacy_definition());
986            run.status = status;
987            let before = run.clone();
988            assert!(matches!(
989                run.set_individual_mode(true, 1, 0),
990                Err(RunError::ModeWhileActive)
991            ));
992            assert_eq!(run, before);
993        }
994        let mut run = run(BuiltinType::Adversarial.legacy_definition());
995        run.status = RunStatus::LimitReached;
996        assert!(run.set_individual_mode(true, 1, 0).unwrap());
997        let before = run.clone();
998        assert!(!run.set_individual_mode(true, 1, 1).unwrap());
999        assert_eq!(run, before);
1000        run.mode_revision = u64::MAX;
1001        let before = run.clone();
1002        assert!(run.set_individual_mode(false, 1, u64::MAX).is_err());
1003        assert_eq!(run, before);
1004    }
1005
1006    #[test]
1007    fn a_follow_up_starts_new_work_without_reusing_old_completions_or_feedback() {
1008        let mut run = run(BuiltinType::Adversarial.legacy_definition());
1009        run.complete(1, 1, signal(Decision::Done)).unwrap();
1010        run.complete(2, 2, signal(Decision::RequestChanges))
1011            .unwrap();
1012        run.complete(1, 3, signal(Decision::Done)).unwrap();
1013        run.complete(2, 4, signal(Decision::Approve)).unwrap();
1014        let history = run.traces.clone();
1015        assert!(run.continue_with(2, 4, 0).is_err());
1016        assert!(run.continue_with(1, u64::MAX, 0).is_err());
1017        assert!(run.continue_with(1, 4, 0).unwrap());
1018        assert_eq!(run.status, RunStatus::Running);
1019        assert_eq!(run.stage_index, 0);
1020        assert_eq!(run.generation, 5);
1021        assert!(run.needs_task());
1022        assert!(run.completions.is_empty() && run.incoming.is_empty() && run.rounds.is_empty());
1023        assert!(run.traces.starts_with(&history));
1024        assert!(!run.continue_with(1, 4, 0).unwrap());
1025        assert!(run.complete(1, 3, signal(Decision::Done)).is_err());
1026        assert!(matches!(
1027            run.complete(1, 5, signal(Decision::Done)),
1028            Err(RunError::Task)
1029        ));
1030        let mut completion = signal(Decision::Done);
1031        completion.task = "A new task".into();
1032        run.complete(1, 5, completion).unwrap();
1033        assert_eq!(run.prompt, "A new task");
1034        assert!(run.initial_task(2).contains("Task:\nA new task"));
1035        run.complete(2, 6, signal(Decision::Approve)).unwrap();
1036        assert!(!run.continue_with(1, 4, 0).unwrap());
1037    }
1038
1039    #[test]
1040    fn a_run_without_a_task_takes_it_from_the_first_completion() {
1041        let mut run = run(BuiltinType::Adversarial.legacy_definition());
1042        run.prompt = String::new();
1043        assert!(run.needs_task());
1044        let first = run.instructions(1, "complete");
1045        assert!(first.contains("Wait for the user to give you a task"));
1046        assert_eq!(run.initial_task(1), "");
1047        assert!(first.contains("\"task\": \"The user's task, restated in full\""));
1048
1049        let previous = run.clone();
1050        assert!(matches!(
1051            run.complete(1, 1, signal(Decision::Done)),
1052            Err(RunError::Task)
1053        ));
1054        let mut oversized = signal(Decision::Done);
1055        oversized.task = "x".repeat(MAX_TASK_BYTES + 1);
1056        assert!(matches!(run.complete(1, 1, oversized), Err(RunError::Task)));
1057        assert_eq!(run, previous);
1058        let mut largest = run.clone();
1059        let mut full = signal(Decision::Done);
1060        full.task = "x".repeat(MAX_TASK_BYTES);
1061        largest.complete(1, 1, full).unwrap();
1062
1063        let mut done = signal(Decision::Done);
1064        done.task = "  Add a dark mode toggle  ".into();
1065        run.complete(1, 1, done).unwrap();
1066        assert_eq!(run.prompt, "Add a dark mode toggle");
1067        let review = run.initial_task(2);
1068        assert!(review.contains("Task:\nAdd a dark mode toggle"));
1069        assert!(!review.contains("\"task\""));
1070        // A review loop back to the first stage keeps the task and doesn't ask for it again.
1071        run.complete(2, 2, signal(Decision::RequestChanges))
1072            .unwrap();
1073        let again = run.initial_task(1);
1074        assert!(again.contains("Task:\nAdd a dark mode toggle"));
1075        assert!(!again.contains("ask them what they want done"));
1076        run.complete(1, 3, signal(Decision::Done)).unwrap();
1077        run.complete(2, 4, signal(Decision::Approve)).unwrap();
1078        assert_eq!(run.status, RunStatus::Completed);
1079    }
1080
1081    #[test]
1082    fn every_parallel_first_agent_reports_its_task_and_later_stages_get_them_all() {
1083        let mut definition = BuiltinType::Coordinator.legacy_definition();
1084        // Workers start the run in parallel, then the coordinator gathers.
1085        definition.stages.remove(0);
1086        definition.handoffs.remove(0);
1087        let mut run = run(definition);
1088        run.prompt = String::new();
1089        let workers: Vec<_> = run.active_agents().iter().map(|a| a.agent_id).collect();
1090        assert_eq!(workers.len(), 2);
1091        let gather = run
1092            .agents
1093            .iter()
1094            .find(|a| a.role == "coordinator")
1095            .unwrap()
1096            .agent_id;
1097
1098        let mut first = signal(Decision::Done);
1099        first.task = "Build the API".into();
1100        run.complete(workers[1], 1, first).unwrap();
1101        // Until the stage finishes, the other worker must report its task too.
1102        assert!(matches!(
1103            run.complete(workers[0], 1, signal(Decision::Done)),
1104            Err(RunError::Task)
1105        ));
1106        let mut second = signal(Decision::Done);
1107        second.task = "Build the UI".into();
1108        run.complete(workers[0], 1, second).unwrap();
1109        assert!(run.prompt.contains("Worker 1: Build the UI"));
1110        assert!(run.prompt.contains("Worker 2: Build the API"));
1111        assert!(run.initial_task(gather).contains("Worker 2: Build the API"));
1112    }
1113
1114    #[test]
1115    fn each_role_keeps_its_model_and_effort_and_invalid_ones_are_rejected() {
1116        let launch = |model: &str, effort: &str| {
1117            WorkflowRun::new(
1118                WorkflowType {
1119                    reference: WorkflowTypeRef::Builtin(BuiltinType::Adversarial),
1120                    definition: BuiltinType::Adversarial.legacy_definition(),
1121                },
1122                "Task".into(),
1123                &[
1124                    RoleLaunch {
1125                        role: "implementer".into(),
1126                        harness: HarnessId::Codex,
1127                        model: Some(model.into()),
1128                        effort: Some(effort.into()),
1129                        yolo: true,
1130                    },
1131                    RoleLaunch {
1132                        role: "reviewer".into(),
1133                        harness: HarnessId::Pi,
1134                        model: None,
1135                        effort: None,
1136                        yolo: false,
1137                    },
1138                ],
1139            )
1140        };
1141        let run = launch(" gpt-6 ", "xhigh").unwrap();
1142        assert_eq!(run.agents[0].model.as_deref(), Some("gpt-6"));
1143        assert_eq!(run.agents[0].effort.as_deref(), Some("xhigh"));
1144        assert!(run.agents[0].yolo && !run.agents[1].yolo);
1145        assert_eq!(run.agents[1].model, None);
1146        assert!(matches!(launch("-c", "high"), Err(RunError::Model)));
1147        assert!(matches!(launch("gpt-6", "x=1"), Err(RunError::Model)));
1148    }
1149
1150    #[test]
1151    fn coordinator_delivers_each_assignment_and_gathers_all_parallel_results() {
1152        let mut run = run(BuiltinType::Coordinator.legacy_definition());
1153        let setup = run.instructions(1, "complete");
1154        assert!(setup.contains("First assignment only"));
1155        assert!(setup.contains("Later Twine task messages supply the current stage"));
1156        assert!(run.complete(1, 1, signal(Decision::Done)).is_err());
1157        let mut split = signal(Decision::Done);
1158        split.assignments = (1..=2)
1159            .map(|instance| Assignment {
1160                role: "worker".into(),
1161                instance,
1162                task: format!("Task {instance}"),
1163                files: vec![format!("file-{instance}")],
1164            })
1165            .collect();
1166        run.complete(1, 1, split).unwrap();
1167        for (id, instance) in [(2, 1), (3, 2)] {
1168            let prompt = run.initial_task(id);
1169            assert!(prompt.contains(&format!("Task {instance}")));
1170            assert!(prompt.contains(&format!("file-{instance}")));
1171            assert!(!prompt.contains(&format!("Task {}", 3 - instance)));
1172        }
1173        assert!(!run.complete(2, 2, signal(Decision::Done)).unwrap());
1174        assert_eq!(run.stage_index, 1);
1175        run.complete(3, 2, signal(Decision::Done)).unwrap();
1176        assert!(run.incoming[&1].contains("Worker 1"));
1177        assert!(run.incoming[&1].contains("Worker 2"));
1178        let current = run.initial_task(1);
1179        assert!(current.contains("Continue with the Gather stage"));
1180        assert_eq!(run.completion_example(1).assignments, []);
1181        assert!(setup.contains("Omit assignments if that list is empty"));
1182        run.complete(1, 3, signal(Decision::Done)).unwrap();
1183        assert_eq!(run.status, RunStatus::Completed);
1184        assert_eq!(run.traces.iter().filter(|t| t.kind == "handoff").count(), 4);
1185    }
1186
1187    #[test]
1188    fn loop_stops_after_the_configured_number_of_returns() {
1189        let mut run = run(BuiltinType::Adversarial.legacy_definition());
1190        for round in 0..=3 {
1191            run.complete(1, run.generation, signal(Decision::Done))
1192                .unwrap();
1193            run.complete(2, run.generation, signal(Decision::RequestChanges))
1194                .unwrap();
1195            assert_eq!(
1196                run.status,
1197                if round == 3 {
1198                    RunStatus::LimitReached
1199                } else {
1200                    RunStatus::Running
1201                }
1202            );
1203        }
1204        assert_eq!(run.rounds["review"], 3);
1205        assert!(
1206            run.complete(1, run.generation, signal(Decision::Done))
1207                .is_err()
1208        );
1209    }
1210
1211    #[test]
1212    fn a_custom_review_loop_keeps_each_workers_assignment() {
1213        let mut definition = BuiltinType::Coordinator.legacy_definition();
1214        let adversarial = BuiltinType::Adversarial.legacy_definition();
1215        definition.roles.push(adversarial.roles[1].clone());
1216        definition.stages[2] = adversarial.stages[1].clone();
1217        definition.handoffs[1].to = crate::StageRole {
1218            stage: crate::StageId("review".into()),
1219            role: crate::RoleId("reviewer".into()),
1220        };
1221        let mut feedback = adversarial.handoffs[1].clone();
1222        feedback.to = crate::StageRole {
1223            stage: crate::StageId("work".into()),
1224            role: crate::RoleId("worker".into()),
1225        };
1226        definition.handoffs.push(feedback);
1227        definition.review_loops.push(crate::ReviewLoop {
1228            review_stage: crate::StageId("review".into()),
1229            back_to: crate::StageId("work".into()),
1230            max_rounds: 2,
1231        });
1232        let mut run = run(definition);
1233        let mut split = signal(Decision::Done);
1234        split.assignments = (1..=2)
1235            .map(|instance| Assignment {
1236                role: "worker".into(),
1237                instance,
1238                task: format!("Task {instance}"),
1239                files: vec![format!("file-{instance}")],
1240            })
1241            .collect();
1242        run.complete(1, 1, split).unwrap();
1243        run.complete(2, 2, signal(Decision::Done)).unwrap();
1244        run.complete(3, 2, signal(Decision::Done)).unwrap();
1245        run.complete(4, 3, signal(Decision::RequestChanges))
1246            .unwrap();
1247        for (id, instance) in [(2, 1), (3, 2)] {
1248            let prompt = run.initial_task(id);
1249            assert!(prompt.contains(&format!("Task {instance}")));
1250            assert!(prompt.contains(&format!("file-{instance}")));
1251            assert!(prompt.contains("Specific result or feedback"));
1252            assert!(
1253                !run.instructions(id, "complete")
1254                    .contains("Specific result or feedback")
1255            );
1256        }
1257    }
1258}