1use 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub model: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub effort: Option<String>,
30 #[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 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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub model: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub effort: Option<String>,
92 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
94 pub yolo: bool,
95 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub anchor: Option<crate::TraceAnchor>,
127}
128
129pub(crate) const MAX_TASK_BYTES: usize = 32 * 1024;
131
132#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
136#[serde(rename_all = "camelCase")]
137pub struct WorkflowRun {
138 pub workflow_type: WorkflowType,
139 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 pub generation: u64,
147 #[serde(default)]
149 pub individual_mode: bool,
150 #[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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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}