1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, Instant};
5
6use super::run_inputs::AgentInput;
7use super::workflows::reject;
8use super::{Application, ApplicationError, CommandDisposition};
9use crate::event::{EventKind, StateEvent};
10use crate::harness::{HarnessId, LocatedHarness};
11use crate::terminal::{TerminalId, TerminalSize, TerminalStatus};
12use crate::workflow::{
13 Agent, AgentId, Workflow, WorkflowId, WorkflowKind, WorkflowStatus, timestamp,
14};
15use crate::workflow_run::completion::{CompletionCommand, CompletionInbox};
16use crate::workflow_run::{CompletionSignal, RoleLaunch, RunStatus, WorkflowRun};
17use crate::workflow_type::{WorkflowCatalog, WorkflowType, WorkflowTypeRef};
18
19pub(super) struct RunProcesses {
20 folder: PathBuf,
21 size: TerminalSize,
22 harnesses: HashMap<HarnessId, LocatedHarness>,
23 inboxes: HashMap<AgentId, (u64, CompletionInbox)>,
24 completion_commands: HashMap<AgentId, CompletionCommand>,
25 completion_routing: HashMap<AgentId, CompletionRouting>,
26 graph_submissions: HashMap<AgentId, (u64, Instant)>,
27 inputs: HashMap<TerminalId, Arc<Mutex<AgentInput>>>,
28 retired_inboxes: std::collections::VecDeque<(Instant, CompletionInbox)>,
30 resumed_sessions: HashMap<AgentId, String>,
31}
32
33const PROMPT_HOOK_TIMEOUT: Duration = Duration::from_secs(2);
34
35#[derive(Clone, Copy)]
36enum CompletionRouting {
37 AwaitingSubmission(u64),
38 AwaitingPrompt(u64, Instant),
39 Active(u64),
40 Private,
41}
42
43pub(super) struct WorkflowSubmission {
44 workflow: WorkflowId,
45 agent: AgentId,
46 generation: u64,
47 since: Instant,
48}
49
50type GraphSubmissions = HashMap<(WorkflowId, AgentId), (u64, Instant)>;
51
52impl CompletionRouting {
53 fn generation(&self) -> Option<u64> {
54 match self {
55 Self::AwaitingSubmission(generation)
56 | Self::AwaitingPrompt(generation, _)
57 | Self::Active(generation) => Some(*generation),
58 Self::Private => None,
59 }
60 }
61}
62
63impl RunProcesses {
64 fn finish_assignment(&mut self, agent: AgentId, advance: bool) {
65 if let Some(command) = self.completion_commands.get(&agent) {
66 command.deactivate();
67 }
68 if let Some((_, inbox)) = self.inboxes.remove(&agent) {
69 inbox.accept();
70 self.retire_inbox(inbox);
71 }
72 if advance {
73 for (_, (_, inbox)) in std::mem::take(&mut self.inboxes) {
74 inbox.reject(
75 "This assignment ended before completion. Wait for your next assignment.",
76 );
77 self.retire_inbox(inbox);
78 }
79 for command in self.completion_commands.values() {
80 command.deactivate();
81 }
82 }
83 }
84
85 fn continuation_prompt(
86 &mut self,
87 run: &WorkflowRun,
88 agent: u64,
89 inbox: &CompletionInbox,
90 user: bool,
91 previous_turn_finished: bool,
92 ) -> std::io::Result<String> {
93 if run.graph.is_some() {
94 let command = self.command_for(AgentId(agent), inbox)?;
95 self.completion_commands[&AgentId(agent)].deactivate();
96 self.completion_routing.insert(
97 AgentId(agent),
98 CompletionRouting::AwaitingSubmission(run.assignment_generation(agent)),
99 );
100 if user {
101 return Ok(String::new());
102 }
103 return Ok(format!(
104 "Use this completion command for this and later assignments: {command}\n{}",
105 run.follow_up_instructions(agent)
106 ));
107 }
108 if let Some(command) = self.completion_commands.get(&AgentId(agent)) {
109 command.deactivate();
112 if previous_turn_finished
113 && !matches!(
114 self.completion_routing.get(&AgentId(agent)),
115 Some(CompletionRouting::Private)
116 )
117 {
118 self.completion_routing.insert(
119 AgentId(agent),
120 CompletionRouting::AwaitingSubmission(run.assignment_generation(agent)),
121 );
122 return Ok(if user {
123 String::new()
124 } else {
125 run.follow_up_instructions(agent)
126 });
127 }
128 }
129 self.completion_routing
130 .insert(AgentId(agent), CompletionRouting::Private);
131 Ok(format!(
132 "Use this completion command for the current assignment: {}\n{}",
133 inbox.command(),
134 if user {
135 "Continue with the user's next message.".to_owned()
136 } else {
137 run.follow_up_instructions(agent)
138 }
139 ))
140 }
141
142 fn command_for(&mut self, agent: AgentId, inbox: &CompletionInbox) -> std::io::Result<String> {
143 let command = match self.completion_commands.entry(agent) {
144 std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
145 std::collections::hash_map::Entry::Vacant(entry) => {
146 entry.insert(CompletionCommand::new()?)
147 }
148 };
149 command.bind(inbox)?;
150 Ok(command.command())
151 }
152
153 fn retire_inbox(&mut self, inbox: CompletionInbox) {
154 self.reap_inboxes();
155 if inbox.receipt_pending() {
156 if self.retired_inboxes.len() == 64 {
158 self.retired_inboxes.pop_front();
159 }
160 self.retired_inboxes.push_back((Instant::now(), inbox));
161 }
162 }
163
164 fn reap_inboxes(&mut self) {
165 self.retired_inboxes.retain(|(accepted_at, inbox)| {
166 inbox.receipt_pending() && accepted_at.elapsed() < Duration::from_secs(15)
167 });
168 }
169}
170
171fn workflow_launch_options(agent: &crate::RunAgent) -> crate::harness::launch::LaunchOptions<'_> {
172 crate::harness::launch::validate_options(
173 agent.harness,
174 agent.model.as_deref(),
175 agent.effort.as_deref(),
176 agent.yolo,
177 )
178 .unwrap_or_else(|| {
179 tracing::warn!(
181 agent_id = agent.agent_id,
182 harness = ?agent.harness,
183 "stored model choice no longer validates; starting with the harness defaults"
184 );
185 crate::harness::launch::LaunchOptions::default()
186 })
187}
188
189fn final_review_feedback(
190 agents: &[Agent],
191 run: &WorkflowRun,
192 reviewer_id: u64,
193) -> Vec<(TerminalId, String)> {
194 let ending = if run.status == RunStatus::Completed {
195 "The review is complete. Wait for the user's next message."
196 } else {
197 "The review limit was reached. Discuss this feedback with the user before continuing."
198 };
199 run.final_review_messages(reviewer_id)
200 .into_iter()
201 .filter_map(|(target, message)| {
202 let terminal = agents
203 .iter()
204 .find(|agent| agent.agent_id.0 == target)?
205 .terminal_id;
206 Some((terminal, format!("{message}\n{ending}\n")))
207 })
208 .collect()
209}
210
211struct PreparedAgent {
212 agent: crate::RunAgent,
213 inbox: CompletionInbox,
214 reserved: TerminalId,
215 arguments: Vec<std::ffi::OsString>,
216 hooks: Option<crate::harness::steps::StepInbox>,
217 continuation: Option<String>,
219}
220
221impl Application {
222 pub fn workflow_types(&self) -> Result<Vec<WorkflowType>, ApplicationError> {
227 Ok(WorkflowCatalog::new(self.lock_inner()?.folders.store()).list()?)
228 }
229
230 pub(super) fn start_workflow_run(
231 &self,
232 workflow_id: WorkflowId,
233 reference: WorkflowTypeRef,
234 prompt: String,
235 choices: &[RoleLaunch],
236 size: TerminalSize,
237 ) -> Result<CommandDisposition, ApplicationError> {
238 let folder = match self.draft_folder(workflow_id)? {
239 Ok(folder) => folder,
240 Err(rejection) => return Ok(rejection),
241 };
242 let workflow_type =
243 match WorkflowCatalog::new(self.lock_inner()?.folders.store()).get(reference) {
244 Ok(workflow_type) => workflow_type,
245 Err(error) => return Ok(super::rejection("invalidWorkflowType", &error)),
246 };
247 let mut run = match WorkflowRun::new(workflow_type, prompt, choices) {
248 Ok(run) => run,
249 Err(error) => return Ok(super::rejection("invalidWorkflowRun", &error)),
250 };
251 let mut harnesses = HashMap::new();
252 for agent in &run.agents {
253 if let std::collections::hash_map::Entry::Vacant(entry) = harnesses.entry(agent.harness)
254 {
255 match self.locate_harness(agent.harness.definition()) {
256 Ok(located) => {
257 entry.insert(located);
258 }
259 Err(error) => return Ok(super::rejection("harnessNotFound", &error)),
260 }
261 }
262 }
263 let placeholder = {
264 let mut inner = self.lock_inner()?;
265 inner
266 .folders
267 .store()
268 .start_workflow_run(workflow_id, &mut run)?;
269 if run.graph.is_some() && !run.prompt.trim().is_empty() {
270 run.start_graph_task(run.prompt.clone())
271 .map_err(|_| ApplicationError::Poisoned)?;
272 }
273 let workflow = inner
274 .workflows
275 .workflows
276 .iter_mut()
277 .find(|w| w.workflow_id == workflow_id)
278 .expect("draft validated under command lock");
279 let placeholder = workflow.terminal_id;
280 workflow.kind = WorkflowKind::Agents;
281 workflow.restored = false;
282 workflow.terminal_history.clear();
283 workflow.name.clone_from(&run.workflow_type.definition.name);
284 workflow.terminal_id = TerminalId::from_value(0);
285 workflow.agents = run
286 .agents
287 .iter()
288 .map(|a| Agent {
289 agent_id: AgentId(a.agent_id),
290 role: a.label.clone(),
291 terminal_id: TerminalId::from_value(0),
292 })
293 .collect();
294 workflow.status = WorkflowStatus::Running;
295 workflow.started_at = timestamp();
296 workflow.ended_at = None;
297 workflow.run = Some(Box::new(run));
298 let changed = workflow.clone();
299 inner.terminals.remove(&placeholder);
300 inner
301 .events
302 .append(EventKind::State(StateEvent::WorkflowChanged(changed)))?;
303 if let Ok(observation) = self.terminals.observe(placeholder)
304 && let Err(error) = inner.stop_trace(
305 placeholder,
306 observation,
307 "Shell stopped when its workflow started.",
308 )
309 {
310 tracing::warn!(%error, "couldn't record the placeholder shell ending");
311 }
312 placeholder
313 };
314 if placeholder.value() != 0 {
315 let _ = self.terminals.close(placeholder);
316 }
317 self.run_processes
318 .lock()
319 .map_err(|_| ApplicationError::Poisoned)?
320 .insert(
321 workflow_id,
322 RunProcesses {
323 folder,
324 size,
325 harnesses,
326 inboxes: HashMap::new(),
327 completion_commands: HashMap::new(),
328 completion_routing: HashMap::new(),
329 graph_submissions: HashMap::new(),
330 inputs: HashMap::new(),
331 retired_inboxes: std::collections::VecDeque::new(),
332 resumed_sessions: HashMap::new(),
333 },
334 );
335 self.launch_workflow_stage(workflow_id, false)?;
336 Ok(CommandDisposition::Accepted)
337 }
338
339 #[expect(
342 clippy::too_many_lines,
343 reason = "resume identity and generation checks precede one launch"
344 )]
345 pub(super) fn resume_workflow_run(
346 &self,
347 saved: &Workflow,
348 folder: &std::path::Path,
349 ) -> Result<(), ApplicationError> {
350 let Some(saved_run) = &saved.run else {
351 return Ok(());
352 };
353 if saved_run.status != RunStatus::Interrupted {
354 return Ok(());
355 }
356 let mut sessions = HashMap::new();
357 {
358 let mut inner = self.lock_inner()?;
359 for agent in saved_run
360 .active_agents()
361 .into_iter()
362 .filter(|a| !saved_run.completions.contains_key(&a.agent_id))
363 {
364 let Some(tab) = saved.agents.iter().find(|a| a.agent_id.0 == agent.agent_id) else {
365 return Ok(());
366 };
367 let pending = saved_run
368 .graph
369 .as_ref()
370 .is_some_and(|graph| !graph.started || graph.ready.contains(&agent.agent_id));
371 let Some(session) = inner.folders.store().harness_session(tab.terminal_id)? else {
372 if pending {
373 continue;
374 }
375 return Ok(());
376 };
377 if crate::harness::resume::arguments(agent.harness, &session).is_none() {
378 if pending {
379 continue;
380 }
381 return Ok(());
382 }
383 sessions.insert(tab.agent_id, session);
384 }
385 }
386 if sessions.is_empty() && saved_run.graph.is_none() {
387 return Ok(());
388 }
389 let mut harnesses = HashMap::new();
390 for agent in &saved_run.agents {
391 if let std::collections::hash_map::Entry::Vacant(entry) = harnesses.entry(agent.harness)
392 {
393 match self.locate_harness(agent.harness.definition()) {
394 Ok(located) => {
395 entry.insert(located);
396 }
397 Err(error) => {
398 tracing::warn!(%error, "couldn't locate a restored workflow harness");
399 return Ok(());
400 }
401 }
402 }
403 }
404 let mut workflow = saved.clone();
405 let run = workflow.run.as_mut().expect("restored workflow has a run");
406 run.status = RunStatus::Running;
407 let Some(generation) = run.generation.checked_add(1) else {
408 tracing::warn!("couldn't advance the restored workflow generation");
409 return Ok(());
410 };
411 if let Err(error) = run.prepare_graph_resume() {
412 tracing::warn!(%error, "couldn't advance the restored assignment generation");
413 return Ok(());
414 }
415 run.generation = generation;
416 run.message = run
417 .graph_choice_ready()
418 .then(|| "All results are ready. Choose a result.".to_owned());
419 run.trace(
420 "workflowResumed",
421 None,
422 None,
423 "Resuming interrupted agent sessions",
424 );
425 workflow.status = WorkflowStatus::Running;
426 workflow.ended_at = None;
427 {
428 let mut inner = self.lock_inner()?;
429 let Some(index) = inner
430 .workflows
431 .workflows
432 .iter()
433 .position(|w| w.workflow_id == saved.workflow_id)
434 else {
435 return Ok(());
436 };
437 inner.publish_run(index, workflow)?;
438 }
439 self.run_processes
440 .lock()
441 .map_err(|_| ApplicationError::Poisoned)?
442 .insert(
443 saved.workflow_id,
444 RunProcesses {
445 folder: folder.to_owned(),
446 size: super::resume_agents::RESTORED_SIZE,
447 harnesses,
448 inboxes: HashMap::new(),
449 completion_commands: HashMap::new(),
450 completion_routing: HashMap::new(),
451 graph_submissions: HashMap::new(),
452 inputs: HashMap::new(),
453 retired_inboxes: std::collections::VecDeque::new(),
454 resumed_sessions: sessions,
455 },
456 );
457 self.launch_workflow_stage(saved.workflow_id, false)
458 }
459
460 #[expect(
461 clippy::too_many_lines,
462 reason = "terminal reservations and launch failure cleanup stay paired"
463 )]
464 fn prepare_workflow_stage(
465 &self,
466 run: &mut WorkflowRun,
467 processes: &mut RunProcesses,
468 live_terminals: &HashMap<AgentId, TerminalId>,
469 user_follow_up: bool,
470 ) -> Vec<PreparedAgent> {
471 let active: Vec<_> = run
472 .active_agents()
473 .into_iter()
474 .filter(|agent| {
475 !run.completions.contains_key(&agent.agent_id)
476 && (run.graph.is_none() || agent.status == crate::RunAgentStatus::Waiting)
477 })
478 .cloned()
479 .collect();
480 let mut prepared = Vec::new();
482 for agent in active {
483 processes.graph_submissions.remove(&AgentId(agent.agent_id));
484 let Ok(inbox) = CompletionInbox::new() else {
485 run.finish(RunStatus::Failed, "Couldn't create the completion command.");
486 break;
487 };
488 if let Some(&terminal) = live_terminals.get(&AgentId(agent.agent_id)) {
489 let finished = self.harness_turn_finished(terminal).unwrap_or(false);
490 let Ok(prompt) = processes.continuation_prompt(
491 run,
492 agent.agent_id,
493 &inbox,
494 user_follow_up,
495 finished,
496 ) else {
497 run.finish(RunStatus::Failed, "Could not prepare the next assignment.");
498 break;
499 };
500 prepared.push(PreparedAgent {
501 agent,
502 inbox,
503 reserved: terminal,
504 arguments: Vec::new(),
505 hooks: None,
506 continuation: Some(prompt),
507 });
508 continue;
509 }
510 match self.terminals.reserve_terminal() {
511 Ok(reserved) => {
512 let options = workflow_launch_options(&agent);
513 let (mut arguments, hooks) =
514 self.harness_arguments(agent.harness, reserved, options, "");
515 let command = if hooks.is_some() || run.graph.is_some() {
516 let Ok(command) = processes.command_for(AgentId(agent.agent_id), &inbox)
517 else {
518 let _ = self.terminals.cancel_reserved_terminal(reserved);
519 run.finish(
520 RunStatus::Failed,
521 "Couldn't prepare the agent's completion command.",
522 );
523 break;
524 };
525 if run.graph.is_some() && run.needs_task() {
526 processes.completion_commands[&AgentId(agent.agent_id)].deactivate();
527 processes.completion_routing.insert(
528 AgentId(agent.agent_id),
529 CompletionRouting::AwaitingSubmission(
530 run.assignment_generation(agent.agent_id),
531 ),
532 );
533 } else {
534 processes
535 .completion_routing
536 .remove(&AgentId(agent.agent_id));
537 }
538 command
539 } else {
540 processes
541 .completion_commands
542 .remove(&AgentId(agent.agent_id));
543 inbox.command()
544 };
545 if let Err(error) = crate::harness::instructions::append(
546 agent.harness,
547 &run.instructions(agent.agent_id, &command),
548 &mut arguments,
549 hooks.as_ref(),
550 &processes.harnesses[&agent.harness],
551 &processes.folder,
552 ) {
553 let _ = self.terminals.cancel_reserved_terminal(reserved);
554 run.finish(
555 RunStatus::Failed,
556 &format!("Could not set role instructions: {error}"),
557 );
558 break;
559 }
560 let session = processes.resumed_sessions.get(&AgentId(agent.agent_id));
561 if let Some(session) = session {
562 arguments.extend(
563 crate::harness::resume::arguments(agent.harness, session)
564 .expect("resume handles validated before restarting a workflow"),
565 );
566 }
567 let task = run.initial_task(agent.agent_id);
568 if !task.is_empty() {
569 arguments.extend(agent.harness.definition().arguments(&task));
570 }
571 prepared.push(PreparedAgent {
572 agent,
573 inbox,
574 reserved,
575 arguments,
576 hooks,
577 continuation: None,
578 });
579 }
580 Err(error) => {
581 run.finish(
582 RunStatus::Failed,
583 &format!("Couldn't reserve a terminal: {error}"),
584 );
585 break;
586 }
587 }
588 }
589 prepared
590 }
591
592 #[expect(
593 clippy::too_many_lines,
594 reason = "process, session, inbox, and trace cleanup stay paired during launch"
595 )]
596 pub(super) fn launch_workflow_stage(
597 &self,
598 workflow_id: WorkflowId,
599 user_follow_up: bool,
600 ) -> Result<(), ApplicationError> {
601 let mut process_guard = self
602 .run_processes
603 .lock()
604 .map_err(|_| ApplicationError::Poisoned)?;
605 let Some(processes) = process_guard.get_mut(&workflow_id) else {
606 return Ok(());
607 };
608 let inner = self.lock_inner()?;
609 let Some(index) = inner
610 .workflows
611 .workflows
612 .iter()
613 .position(|w| w.workflow_id == workflow_id)
614 else {
615 return Ok(());
616 };
617 let mut workflow = inner.workflows.workflows[index].clone();
618 let mut live_terminals: HashMap<_, _> = workflow
619 .agents
620 .iter()
621 .filter_map(|agent| {
622 (inner.terminals.get(&agent.terminal_id) == Some(&TerminalStatus::Running))
623 .then_some((agent.agent_id, agent.terminal_id))
624 })
625 .collect();
626 drop(inner);
627 live_terminals.retain(|_, terminal| self.terminals.is_running(*terminal));
628 let run = workflow.run.as_mut().expect("execution has a run");
629 run.trace("stageStarted", None, None, "Stage started");
630 let prepared = self.prepare_workflow_stage(run, processes, &live_terminals, user_follow_up);
631 let mut inner = self.lock_inner()?;
632 let mut replaced = Vec::new();
633 let mut unused = Vec::new();
634 let mut step_hooks = Vec::new();
635 let mut continuations = Vec::new();
636 for PreparedAgent {
637 agent,
638 inbox,
639 reserved,
640 arguments,
641 hooks,
642 continuation,
643 } in prepared
644 {
645 if run.status != RunStatus::Running {
646 if continuation.is_none() {
647 unused.push(reserved);
648 }
649 continue;
650 }
651 let located = &processes.harnesses[&agent.harness];
652 let continued = continuation.is_some();
653 let launched = if let Some(prompt) = continuation {
654 continuations.push((reserved, prompt));
655 Ok(reserved)
656 } else {
657 self.terminals.start_program(
658 reserved,
659 &processes.folder,
660 &located.program,
661 &arguments,
662 &located.path,
663 &crate::harness::launch::observed_environment(agent.harness, hooks.as_ref()),
664 processes.size,
665 Arc::new(self.exit_callback()),
666 )
667 };
668 match launched {
669 Ok(terminal_id) => {
670 processes.inputs.entry(terminal_id).or_default();
671 if let Some(hooks) = hooks {
672 step_hooks.push((terminal_id, hooks));
673 }
674 if let Some(previous) = inner.record_agent_launch(
675 &mut workflow.agents,
676 run,
677 agent.agent_id,
678 terminal_id,
679 ) {
680 replaced.push(previous);
681 }
682 processes.inboxes.insert(
683 AgentId(agent.agent_id),
684 (run.assignment_generation(agent.agent_id), inbox),
685 );
686 run.graph_agent_launched(agent.agent_id);
687 run.trace(
688 if continued {
689 "agentContinued"
690 } else {
691 "agentStarted"
692 },
693 Some(agent.agent_id),
694 None,
695 if continued {
696 "Assignment continued in the existing harness"
697 } else {
698 "Harness started"
699 },
700 );
701 if continued {
702 run.traces
703 .last_mut()
704 .expect("continuation was traced")
705 .anchor = self.terminals.observe(terminal_id).ok().map(|observation| {
706 crate::TraceAnchor {
707 terminal_id,
708 byte_offset: observation.byte_offset,
709 boundary_sizes: observation.boundary_sizes,
710 }
711 });
712 }
713 }
714 Err(error) => {
715 run.finish(
716 RunStatus::Failed,
717 &format!("Couldn't start {}: {error}", agent.label),
718 );
719 }
720 }
721 }
722 workflow.status = workflow_status(run.status);
723 if run.status != RunStatus::Running {
724 workflow.ended_at = Some(timestamp());
725 processes.inboxes.clear();
726 }
727 let resumed: Vec<_> = workflow
728 .agents
729 .iter()
730 .filter_map(|agent| {
731 processes
732 .resumed_sessions
733 .get(&agent.agent_id)
734 .map(|session| (agent.terminal_id, session.as_str()))
735 })
736 .collect();
737 let saved = inner.publish_launch(index, &mut workflow, &resumed);
738 processes.resumed_sessions.clear();
739 processes
740 .inputs
741 .retain(|terminal, _| workflow.terminal_ids().contains(terminal));
742 if workflow.status != WorkflowStatus::Running {
743 processes.inboxes.clear();
744 }
745 let failed_terminals = if workflow.status == WorkflowStatus::Failed {
746 workflow.terminal_ids()
747 } else {
748 Vec::new()
749 };
750 drop(inner);
751 drop(process_guard);
752 self.register_stage_steps(workflow_id, step_hooks);
753 self.stop_run_terminals(&failed_terminals, "Workflow launch failed; agent stopped.");
754 for id in unused {
755 let _ = self.terminals.cancel_reserved_terminal(id);
756 }
757 let replaced: Vec<_> = replaced
758 .into_iter()
759 .filter(|id| {
760 !workflow
761 .terminal_history
762 .iter()
763 .any(|entry| entry.terminal_id == *id)
764 })
765 .collect();
766 self.terminals.close_all(&replaced)?;
767 saved?;
768 if workflow.status == WorkflowStatus::Running {
770 for (terminal, prompt) in continuations {
771 if prompt.is_empty() {
772 continue;
773 }
774 if let Err(error) = self.notify_workflow_agent(terminal, &prompt) {
775 let mut inner = self.lock_inner()?;
776 let mut workflow = inner.workflows.workflows[index].clone();
777 workflow.run.as_mut().expect("workflow has a run").finish(
778 RunStatus::Failed,
779 "Couldn't send the next assignment to the agent.",
780 );
781 workflow.status = WorkflowStatus::Failed;
782 workflow.ended_at = Some(timestamp());
783 let terminals = workflow.terminal_ids();
784 inner.publish_run_best_effort(index, workflow)?;
785 drop(inner);
786 self.stop_run_terminals(
787 &terminals,
788 "Assignment delivery failed; agent stopped.",
789 );
790 return Err(error);
791 }
792 }
793 }
794 Ok(())
795 }
796
797 pub(super) fn submit_workflow_task(
798 &self,
799 workflow_id: WorkflowId,
800 generation: u64,
801 prompt: String,
802 ) -> Result<CommandDisposition, ApplicationError> {
803 {
804 let mut inner = self.lock_inner()?;
805 let Some(index) = inner
806 .workflows
807 .workflows
808 .iter()
809 .position(|workflow| workflow.workflow_id == workflow_id)
810 else {
811 return Ok(reject(
812 "workflowNotFound",
813 "The workflow is no longer open.",
814 ));
815 };
816 let mut workflow = inner.workflows.workflows[index].clone();
817 let Some(run) = workflow.run.as_mut() else {
818 return Ok(reject("notWorkflowRun", "This workflow has no graph."));
819 };
820 if run.generation != generation {
821 return Ok(reject("staleTask", "The workflow changed."));
822 }
823 if let Err(error) = run.start_graph_task(prompt) {
824 return Ok(super::rejection("invalidTask", &error));
825 }
826 inner.publish_run(index, workflow)?;
827 }
828 self.launch_workflow_stage(workflow_id, false)?;
829 Ok(CommandDisposition::Accepted)
830 }
831
832 pub(super) fn choose_workflow_result(
833 &self,
834 workflow_id: WorkflowId,
835 generation: u64,
836 agent_id: AgentId,
837 ) -> Result<CommandDisposition, ApplicationError> {
838 let mut inner = self.lock_inner()?;
839 let Some(index) = inner
840 .workflows
841 .workflows
842 .iter()
843 .position(|workflow| workflow.workflow_id == workflow_id)
844 else {
845 return Ok(reject(
846 "workflowNotFound",
847 "The workflow is no longer open.",
848 ));
849 };
850 let mut workflow = inner.workflows.workflows[index].clone();
851 let Some(run) = workflow.run.as_mut() else {
852 return Ok(reject("notWorkflowRun", "This workflow has no graph."));
853 };
854 if let Err(error) = run.choose_graph_result(agent_id.0, generation) {
855 return Ok(super::rejection("invalidResult", &error));
856 }
857 workflow.status = WorkflowStatus::Completed;
858 workflow.ended_at = Some(timestamp());
859 inner.publish_run(index, workflow)?;
860 Ok(CommandDisposition::Accepted)
861 }
862
863 #[expect(
864 clippy::too_many_lines,
865 reason = "completion persistence and mailbox receipts stay paired"
866 )]
867 pub(super) fn complete_workflow_role(
868 &self,
869 workflow_id: WorkflowId,
870 agent_id: AgentId,
871 generation: u64,
872 signal: CompletionSignal,
873 ) -> Result<CommandDisposition, ApplicationError> {
874 self.poll_harness_steps()?;
875 let piece = signal.decision == crate::Decision::PieceReady;
876 let replacement = if piece {
877 match CompletionInbox::new() {
878 Ok(inbox) => Some(inbox),
879 Err(error) => return Ok(super::rejection("completionInboxFailed", &error)),
880 }
881 } else {
882 None
883 };
884 let (
885 advance,
886 running,
887 feedback,
888 generation,
889 mode_revision,
890 graph_run,
891 assignment_generation,
892 ) = {
893 let mut inner = self.lock_inner()?;
894 let Some(index) = inner
895 .workflows
896 .workflows
897 .iter()
898 .position(|w| w.workflow_id == workflow_id)
899 else {
900 return Ok(reject(
901 "workflowNotFound",
902 "The workflow is no longer open.",
903 ));
904 };
905 let mut workflow = inner.workflows.workflows[index].clone();
906 let Some(run) = &mut workflow.run else {
907 return Ok(reject("notWorkflowRun", "This workflow has no stages."));
908 };
909 let previous_sequence = run.traces.last().map_or(0, |event| event.sequence);
910 let anchor = workflow
911 .agents
912 .iter()
913 .find(|agent| agent.agent_id == agent_id)
914 .and_then(|agent| match self.terminals.observe(agent.terminal_id) {
915 Ok(observation) => Some(crate::TraceAnchor {
916 terminal_id: agent.terminal_id,
917 byte_offset: observation.byte_offset,
918 boundary_sizes: observation.boundary_sizes,
919 }),
920 Err(error) => {
921 tracing::warn!(%error, "couldn't observe completion terminal boundary");
922 None
923 }
924 });
925 let advance = match run.complete(agent_id.0, generation, signal) {
926 Ok(advance) => advance,
927 Err(error) => return Ok(super::rejection("invalidCompletion", &error)),
928 };
929 let completion_anchors: HashMap<_, _> = run
930 .traces
931 .iter()
932 .filter(|event| event.generation == generation && event.kind == "roleCompleted")
933 .filter_map(|event| Some((event.agent_id?, event.anchor.clone()?)))
934 .collect();
935 for event in run
936 .traces
937 .iter_mut()
938 .filter(|event| event.sequence > previous_sequence)
939 {
940 event.anchor = match event.agent_id {
943 Some(source) if source != agent_id.0 => {
944 completion_anchors.get(&source).cloned()
945 }
946 _ => anchor.clone(),
947 };
948 }
949 let running = run.status == RunStatus::Running;
950 let feedback = if advance && !running && run.is_reviewer(agent_id.0) {
951 final_review_feedback(&workflow.agents, run, agent_id.0)
952 } else {
953 Vec::new()
954 };
955 let generation = run.generation;
956 let mode_revision = run.mode_revision;
957 let graph_run = run.graph.is_some();
958 let assignment_generation = run.assignment_generation(agent_id.0);
959 workflow.status = workflow_status(run.status);
960 if !running {
961 workflow.ended_at = Some(timestamp());
962 }
963 if let Err(error) = inner.publish_run(index, workflow) {
964 return Ok(super::rejection("completionStoreFailed", &error));
965 }
966 (
967 advance,
968 running,
969 feedback,
970 generation,
971 mode_revision,
972 graph_run,
973 assignment_generation,
974 )
975 };
976 let mut renewal_failed = false;
977 {
978 let mut processes = self
979 .run_processes
980 .lock()
981 .map_err(|_| ApplicationError::Poisoned)?;
982 if let Some(processes) = processes.get_mut(&workflow_id) {
983 processes.finish_assignment(agent_id, if graph_run { !running } else { advance });
984 if piece && running {
985 let inbox = replacement.expect("piece inbox prepared before persistence");
986 if processes.command_for(agent_id, &inbox).is_err() {
987 inbox.reject(
988 "The workflow stopped because its signal command could not be renewed.",
989 );
990 renewal_failed = true;
991 }
992 if !renewal_failed {
993 processes
994 .inboxes
995 .insert(agent_id, (assignment_generation, inbox));
996 }
997 }
998 }
999 }
1000 if renewal_failed {
1001 self.fail_graph_signals(workflow_id)?;
1002 return Ok(CommandDisposition::Accepted);
1003 }
1004 if advance && running {
1005 self.launch_workflow_stage(workflow_id, false)?;
1006 }
1007 self.deliver_review_feedback(workflow_id, generation, mode_revision, feedback);
1008 Ok(CommandDisposition::Accepted)
1009 }
1010
1011 fn fail_graph_signals(&self, workflow_id: WorkflowId) -> Result<(), ApplicationError> {
1012 let terminals = {
1013 let mut inner = self.lock_inner()?;
1014 let Some(index) = inner
1015 .workflows
1016 .workflows
1017 .iter()
1018 .position(|workflow| workflow.workflow_id == workflow_id)
1019 else {
1020 return Ok(());
1021 };
1022 let mut workflow = inner.workflows.workflows[index].clone();
1023 if let Some(run) = workflow.run.as_mut() {
1024 run.finish(
1025 RunStatus::Failed,
1026 "Could not renew the agent signal command.",
1027 );
1028 }
1029 workflow.status = WorkflowStatus::Failed;
1030 workflow.ended_at = Some(timestamp());
1031 let terminals = workflow.terminal_ids();
1032 inner.publish_run_best_effort(index, workflow)?;
1033 terminals
1034 };
1035 self.stop_run_terminals(&terminals, "Workflow signal command failed.");
1036 Ok(())
1037 }
1038
1039 fn deliver_review_feedback(
1040 &self,
1041 workflow: WorkflowId,
1042 generation: u64,
1043 mode_revision: u64,
1044 feedback: Vec<(TerminalId, String)>,
1045 ) {
1046 for (terminal, message) in feedback {
1047 if self.terminals.is_running(terminal)
1048 && let Err(error) = self.notify_final_review_feedback(
1049 workflow,
1050 generation,
1051 mode_revision,
1052 terminal,
1053 &message,
1054 )
1055 {
1056 tracing::warn!(%error, "couldn't deliver the final review feedback");
1057 }
1058 }
1059 }
1060
1061 fn notify_final_review_feedback(
1062 &self,
1063 workflow: WorkflowId,
1064 generation: u64,
1065 mode_revision: u64,
1066 terminal: TerminalId,
1067 message: &str,
1068 ) -> Result<(), ApplicationError> {
1069 let Some(input) = self.workflow_agent_input(terminal)? else {
1070 return Ok(());
1071 };
1072 let mut writer = input.lock().map_err(|_| ApplicationError::Poisoned)?;
1075 let registry = self
1076 .run_processes
1077 .lock()
1078 .map_err(|_| ApplicationError::Poisoned)?;
1079 let inner = self.lock_inner()?;
1080 let current = inner
1081 .workflows
1082 .workflows
1083 .iter()
1084 .find(|w| w.workflow_id == workflow)
1085 .and_then(|w| w.run.as_ref());
1086 if !current.is_some_and(|run| {
1087 !run.individual_mode
1088 && run.mode_revision == mode_revision
1089 && run.generation == generation
1090 && matches!(run.status, RunStatus::Completed | RunStatus::LimitReached)
1091 }) {
1092 return Ok(());
1093 }
1094 drop(inner);
1095 drop(registry);
1096 writer.notify(&self.terminals, terminal, message)?;
1097 Ok(())
1098 }
1099
1100 fn notify_workflow_agent(
1101 &self,
1102 terminal: TerminalId,
1103 message: &str,
1104 ) -> Result<(), ApplicationError> {
1105 if let Some(input) = self.workflow_agent_input(terminal)? {
1106 let mut input = input.lock().map_err(|_| ApplicationError::Poisoned)?;
1107 let submission = if input.notification_will_submit() {
1108 self.note_workflow_submission(terminal)?
1109 } else {
1110 None
1111 };
1112 if let Err(error) = input.notify(&self.terminals, terminal, message) {
1113 self.cancel_workflow_submission(submission)?;
1114 return Err(error.into());
1115 }
1116 }
1117 Ok(())
1118 }
1119
1120 pub(super) fn note_workflow_submission(
1121 &self,
1122 terminal: TerminalId,
1123 ) -> Result<Option<WorkflowSubmission>, ApplicationError> {
1124 let owner = {
1125 let inner = self.lock_inner()?;
1126 inner.workflows.workflows.iter().find_map(|workflow| {
1127 let run = workflow.run.as_ref()?;
1128 if run.status != RunStatus::Running {
1129 return None;
1130 }
1131 let agent = workflow
1132 .agents
1133 .iter()
1134 .find(|agent| agent.terminal_id == terminal)?;
1135 Some((
1136 workflow.workflow_id,
1137 agent.agent_id,
1138 run.assignment_generation(agent.agent_id.0),
1139 run.graph.is_some() && run.needs_task(),
1140 ))
1141 })
1142 };
1143 if let Some((workflow, agent, generation, idle_graph)) = owner {
1144 let mut processes = self
1145 .run_processes
1146 .lock()
1147 .map_err(|_| ApplicationError::Poisoned)?;
1148 if let Some(processes) = processes.get_mut(&workflow)
1149 && let Some(routing) = processes.completion_routing.get_mut(&agent)
1150 && matches!(routing, CompletionRouting::AwaitingSubmission(assigned) if *assigned == generation)
1151 {
1152 let since = Instant::now();
1153 *routing = CompletionRouting::AwaitingPrompt(generation, since);
1154 if idle_graph {
1155 processes
1156 .graph_submissions
1157 .insert(agent, (generation, since));
1158 }
1159 return Ok(Some(WorkflowSubmission {
1160 workflow,
1161 agent,
1162 generation,
1163 since,
1164 }));
1165 }
1166 }
1167 Ok(None)
1168 }
1169
1170 pub(super) fn cancel_workflow_submission(
1171 &self,
1172 submission: Option<WorkflowSubmission>,
1173 ) -> Result<(), ApplicationError> {
1174 let Some(submission) = submission else {
1175 return Ok(());
1176 };
1177 let mut registry = self
1178 .run_processes
1179 .lock()
1180 .map_err(|_| ApplicationError::Poisoned)?;
1181 if let Some(processes) = registry.get_mut(&submission.workflow) {
1182 if matches!(processes.completion_routing.get(&submission.agent),
1183 Some(CompletionRouting::AwaitingPrompt(generation, since))
1184 if *generation == submission.generation && *since == submission.since)
1185 {
1186 processes.completion_routing.insert(
1187 submission.agent,
1188 CompletionRouting::AwaitingSubmission(submission.generation),
1189 );
1190 }
1191 if processes.graph_submissions.get(&submission.agent)
1192 == Some(&(submission.generation, submission.since))
1193 {
1194 processes.graph_submissions.remove(&submission.agent);
1195 }
1196 }
1197 Ok(())
1198 }
1199
1200 pub(super) fn graph_submissions(&self) -> Result<Option<GraphSubmissions>, ApplicationError> {
1201 let registry = match self.run_processes.try_lock() {
1202 Ok(registry) => registry,
1203 Err(std::sync::TryLockError::WouldBlock) => return Ok(None),
1204 Err(std::sync::TryLockError::Poisoned(_)) => return Err(ApplicationError::Poisoned),
1205 };
1206 Ok(Some(
1207 registry
1208 .iter()
1209 .flat_map(|(&workflow, processes)| {
1210 processes
1211 .graph_submissions
1212 .iter()
1213 .map(move |(&agent, &submission)| ((workflow, agent), submission))
1214 })
1215 .collect(),
1216 ))
1217 }
1218
1219 pub(super) fn activate_workflow_completion(
1220 &self,
1221 workflow: WorkflowId,
1222 agent: AgentId,
1223 generation: u64,
1224 ready: bool,
1225 received_at: Option<Instant>,
1226 ) -> Result<(), ApplicationError> {
1227 let mut processes = self
1228 .run_processes
1229 .lock()
1230 .map_err(|_| ApplicationError::Poisoned)?;
1231 if let Some(processes) = processes.get_mut(&workflow)
1232 && let Some((assigned_generation, inbox)) = processes.inboxes.get(&agent)
1233 && *assigned_generation == generation
1234 && let Some(routing) = processes.completion_routing.get_mut(&agent)
1235 && routing.generation() == Some(generation)
1236 && let Some(command) = processes.completion_commands.get(&agent)
1237 {
1238 if matches!(routing, CompletionRouting::AwaitingSubmission(_)) {
1239 return Ok(());
1240 }
1241 if ready
1242 && !matches!(routing, CompletionRouting::AwaitingPrompt(_, since)
1243 if received_at.is_none_or(|received| received < *since))
1244 {
1245 if let Err(error) = command.bind(inbox) {
1246 tracing::warn!(%error, "couldn't activate the new assignment's completion command");
1247 } else {
1248 *routing = CompletionRouting::Active(generation);
1249 }
1250 } else {
1251 command.deactivate();
1252 if matches!(routing, CompletionRouting::Active(_)) {
1253 *routing = CompletionRouting::AwaitingPrompt(generation, Instant::now());
1254 }
1255 }
1256 }
1257 Ok(())
1258 }
1259
1260 pub(super) fn workflow_agent_input(
1261 &self,
1262 terminal: TerminalId,
1263 ) -> Result<Option<Arc<Mutex<AgentInput>>>, ApplicationError> {
1264 Ok(self
1265 .run_processes
1266 .lock()
1267 .map_err(|_| ApplicationError::Poisoned)?
1268 .values()
1269 .find_map(|processes| processes.inputs.get(&terminal).cloned()))
1270 }
1271
1272 pub(super) fn continue_workflow_run(
1273 &self,
1274 workflow_id: WorkflowId,
1275 agent_id: AgentId,
1276 generation: u64,
1277 mode_revision: u64,
1278 ) -> Result<CommandDisposition, ApplicationError> {
1279 {
1280 let mut inner = self.lock_inner()?;
1281 let Some(index) = inner
1282 .workflows
1283 .workflows
1284 .iter()
1285 .position(|w| w.workflow_id == workflow_id)
1286 else {
1287 return Ok(reject(
1288 "workflowNotFound",
1289 "The workflow is no longer open.",
1290 ));
1291 };
1292 let mut workflow = inner.workflows.workflows[index].clone();
1293 if !workflow.agents.iter().any(|agent| {
1294 agent.agent_id == agent_id
1295 && inner.terminals.get(&agent.terminal_id) == Some(&TerminalStatus::Running)
1296 }) {
1297 return Ok(reject(
1298 "agentNotRunning",
1299 "This agent's terminal is no longer running.",
1300 ));
1301 }
1302 let Some(run) = workflow.run.as_mut() else {
1303 return Ok(reject("notWorkflowRun", "This workflow has no stages."));
1304 };
1305 match run.continue_with(agent_id.0, generation, mode_revision) {
1306 Ok(false) => return Ok(CommandDisposition::Accepted),
1307 Ok(true) => {}
1308 Err(error) => return Ok(super::rejection("invalidContinuation", &error)),
1309 }
1310 workflow.status = WorkflowStatus::Running;
1311 workflow.ended_at = None;
1312 inner.publish_run(index, workflow)?;
1313 }
1314 self.launch_workflow_stage(workflow_id, true)?;
1315 Ok(CommandDisposition::Accepted)
1316 }
1317
1318 pub(super) fn set_workflow_individual_mode(
1319 &self,
1320 workflow_id: WorkflowId,
1321 generation: u64,
1322 mode_revision: u64,
1323 individual_mode: bool,
1324 ) -> Result<CommandDisposition, ApplicationError> {
1325 let mut writers = if individual_mode {
1326 let registry = self
1327 .run_processes
1328 .lock()
1329 .map_err(|_| ApplicationError::Poisoned)?;
1330 registry
1331 .get(&workflow_id)
1332 .map(|processes| {
1333 processes
1334 .inputs
1335 .iter()
1336 .map(|(&terminal, input)| (terminal, Arc::clone(input)))
1337 .collect::<Vec<_>>()
1338 })
1339 .unwrap_or_default()
1340 } else {
1341 Vec::new()
1342 };
1343 writers.sort_by_key(|(terminal, _)| terminal.value());
1344 let mut inputs = writers
1347 .iter()
1348 .map(|(_, input)| input.lock().map_err(|_| ApplicationError::Poisoned))
1349 .collect::<Result<Vec<_>, _>>()?;
1350 let mut registry = self
1353 .run_processes
1354 .lock()
1355 .map_err(|_| ApplicationError::Poisoned)?;
1356 let mut inner = self.lock_inner()?;
1357 let Some(index) = inner
1358 .workflows
1359 .workflows
1360 .iter()
1361 .position(|w| w.workflow_id == workflow_id)
1362 else {
1363 return Ok(reject(
1364 "workflowNotFound",
1365 "The workflow is no longer open.",
1366 ));
1367 };
1368 let mut workflow = inner.workflows.workflows[index].clone();
1369 let Some(run) = workflow.run.as_mut() else {
1370 return Ok(reject("notWorkflowRun", "This workflow has no stages."));
1371 };
1372 match run.set_individual_mode(individual_mode, generation, mode_revision) {
1373 Ok(false) => return Ok(CommandDisposition::Accepted),
1374 Ok(true) => {}
1375 Err(error) => return Ok(super::rejection("invalidIndividualMode", &error)),
1376 }
1377 inner.publish_run(index, workflow)?;
1380 for input in &mut inputs {
1381 input.clear_feedback();
1382 }
1383 drop(inputs);
1384 if let Some(processes) = registry.get_mut(&workflow_id) {
1385 for command in processes.completion_commands.values() {
1386 command.deactivate();
1387 }
1388 for routing in processes.completion_routing.values_mut() {
1389 *routing = CompletionRouting::Private;
1390 }
1391 }
1392 Ok(CommandDisposition::Accepted)
1393 }
1394
1395 pub(super) fn cancel_workflow_run(
1396 &self,
1397 workflow_id: WorkflowId,
1398 ) -> Result<CommandDisposition, ApplicationError> {
1399 let terminals = {
1400 let mut inner = self.lock_inner()?;
1401 let Some(index) = inner
1402 .workflows
1403 .workflows
1404 .iter()
1405 .position(|w| w.workflow_id == workflow_id)
1406 else {
1407 return Ok(reject(
1408 "workflowNotFound",
1409 "The workflow is no longer open.",
1410 ));
1411 };
1412 let mut workflow = inner.workflows.workflows[index].clone();
1413 let Some(run) = &mut workflow.run else {
1414 return Ok(reject("notWorkflowRun", "This workflow has no stages."));
1415 };
1416 if !matches!(
1417 run.status,
1418 RunStatus::Running | RunStatus::Completed | RunStatus::LimitReached
1419 ) {
1420 return Ok(reject("workflowNotRunning", "The workflow isn't running."));
1421 }
1422 run.finish(RunStatus::Cancelled, "Workflow cancelled");
1423 workflow.status = WorkflowStatus::Cancelled;
1424 workflow.ended_at = Some(timestamp());
1425 let terminals = workflow.terminal_ids();
1426 inner.publish_run_best_effort(index, workflow)?;
1427 terminals
1428 };
1429 self.run_processes
1430 .lock()
1431 .map_err(|_| ApplicationError::Poisoned)?
1432 .remove(&workflow_id);
1433 self.stop_run_terminals(&terminals, "Workflow cancelled; agent stopped.");
1434 Ok(CommandDisposition::Accepted)
1435 }
1436
1437 fn stop_run_terminals(&self, terminals: &[TerminalId], message: &str) {
1438 for &terminal in terminals {
1439 if let Ok(observation) = self.terminals.observe(terminal)
1440 && let Ok(mut inner) = self.lock_inner()
1441 && let Err(error) = inner.stop_trace(terminal, observation, message)
1442 {
1443 tracing::warn!(%error, "couldn't record a workflow agent ending");
1444 }
1445 let _ = self.terminals.terminate(terminal);
1446 }
1447 }
1448
1449 pub(super) fn poll_workflow_signals(&self) -> Result<(), ApplicationError> {
1452 let _guard = match self.commands.try_lock() {
1455 Ok(guard) => guard,
1456 Err(std::sync::TryLockError::WouldBlock) => return Ok(()),
1457 Err(std::sync::TryLockError::Poisoned(_)) => return Err(ApplicationError::Poisoned),
1458 };
1459 self.poll_harness_steps()?;
1460 self.prune_run_processes()?;
1461 let ready: Vec<_> = {
1462 let inner = self.lock_inner()?;
1463 inner
1464 .workflows
1465 .workflows
1466 .iter()
1467 .filter(|workflow| {
1468 workflow.run.as_ref().is_some_and(|run| {
1469 run.status == RunStatus::Running
1470 && run
1471 .graph
1472 .as_ref()
1473 .is_some_and(|graph| !graph.ready.is_empty())
1474 })
1475 })
1476 .map(|workflow| workflow.workflow_id)
1477 .collect()
1478 };
1479 for workflow in ready {
1480 self.launch_workflow_stage(workflow, false)?;
1481 }
1482 let submissions: Vec<_> = {
1483 let processes = self
1484 .run_processes
1485 .lock()
1486 .map_err(|_| ApplicationError::Poisoned)?;
1487 processes
1488 .iter()
1489 .flat_map(|(&id, processes)| {
1490 processes
1491 .inboxes
1492 .iter()
1493 .filter_map(move |(&agent, (generation, inbox))| {
1494 inbox.take().map(|signal| (id, agent, *generation, signal))
1495 })
1496 })
1497 .collect()
1498 };
1499 for (workflow, agent, generation, submission) in submissions {
1500 let disposition = match submission {
1501 Ok(signal) => self.complete_workflow_role(workflow, agent, generation, signal)?,
1502 Err(message) => reject("invalidCompletion", &message),
1503 };
1504 if let CommandDisposition::Rejected { message, .. } = disposition {
1505 let processes = self
1506 .run_processes
1507 .lock()
1508 .map_err(|_| ApplicationError::Poisoned)?;
1509 let Some((current_generation, inbox)) =
1510 processes.get(&workflow).and_then(|p| p.inboxes.get(&agent))
1511 else {
1512 continue;
1513 };
1514 if *current_generation != generation {
1517 continue;
1518 }
1519 inbox.reject(&message);
1520 drop(processes);
1521 let mut inner = self.lock_inner()?;
1522 if let Some(index) = inner
1523 .workflows
1524 .workflows
1525 .iter()
1526 .position(|w| w.workflow_id == workflow)
1527 {
1528 let mut workflow = inner.workflows.workflows[index].clone();
1529 if let Some(run) = &mut workflow.run {
1530 run.message = Some(message);
1531 run.trace(
1532 "completionRejected",
1533 Some(agent.0),
1534 None,
1535 "Completion rejected; use Mark done or correct the submission",
1536 );
1537 }
1538 inner.publish_run_best_effort(index, workflow)?;
1539 }
1540 }
1541 }
1542 self.recover_completion_routes(Instant::now())?;
1543 Ok(())
1544 }
1545
1546 #[expect(
1547 clippy::too_many_lines,
1548 reason = "route recovery and timeout notification stay paired"
1549 )]
1550 fn recover_completion_routes(&self, now: Instant) -> Result<(), ApplicationError> {
1551 let candidates: Vec<_> = {
1552 let registry = self
1553 .run_processes
1554 .lock()
1555 .map_err(|_| ApplicationError::Poisoned)?;
1556 registry
1557 .iter()
1558 .flat_map(|(&workflow, processes)| {
1559 processes
1560 .completion_routing
1561 .iter()
1562 .filter_map(move |(&agent, routing)| {
1563 if let CompletionRouting::AwaitingPrompt(generation, since) = routing
1564 && now.saturating_duration_since(*since) >= PROMPT_HOOK_TIMEOUT
1565 {
1566 Some((workflow, agent, *generation))
1567 } else {
1568 None
1569 }
1570 })
1571 })
1572 .collect()
1573 };
1574 if candidates.is_empty() {
1575 return Ok(());
1576 }
1577 let assignments: Vec<_> = {
1578 let inner = self.lock_inner()?;
1579 candidates
1580 .into_iter()
1581 .filter_map(|(workflow, agent, generation)| {
1582 let saved = inner
1583 .workflows
1584 .workflows
1585 .iter()
1586 .find(|w| w.workflow_id == workflow)?;
1587 let run = saved.run.as_ref()?;
1588 if run.status != RunStatus::Running
1589 || run.assignment_generation(agent.0) != generation
1590 {
1591 return None;
1592 }
1593 let terminal = saved
1594 .agents
1595 .iter()
1596 .find(|a| a.agent_id == agent)?
1597 .terminal_id;
1598 Some((
1599 workflow,
1600 agent,
1601 generation,
1602 terminal,
1603 run.follow_up_instructions(agent.0),
1604 run.graph.is_some(),
1605 ))
1606 })
1607 .collect()
1608 };
1609 let notifications = {
1610 let mut registry = self
1611 .run_processes
1612 .lock()
1613 .map_err(|_| ApplicationError::Poisoned)?;
1614 let mut notifications = Vec::new();
1615 for (workflow, agent, generation, terminal, instructions, graph) in assignments {
1616 let Some(processes) = registry.get_mut(&workflow) else {
1617 continue;
1618 };
1619 let Some(routing) = processes.completion_routing.get_mut(&agent) else {
1620 continue;
1621 };
1622 if !matches!(routing, CompletionRouting::AwaitingPrompt(assigned, since)
1623 if *assigned == generation && now.saturating_duration_since(*since) >= PROMPT_HOOK_TIMEOUT)
1624 {
1625 continue;
1626 }
1627 let Some((assigned, inbox)) = processes.inboxes.get(&agent) else {
1628 continue;
1629 };
1630 if *assigned != generation {
1631 continue;
1632 }
1633 *routing = CompletionRouting::Private;
1635 let command = if graph {
1636 let command = processes
1637 .completion_commands
1638 .get(&agent)
1639 .ok_or(ApplicationError::Poisoned)?;
1640 command
1641 .bind(inbox)
1642 .map_err(ApplicationError::CompletionInbox)?;
1643 command.command()
1644 } else {
1645 if let Some(command) = processes.completion_commands.get(&agent) {
1646 command.deactivate();
1647 }
1648 inbox.command()
1649 };
1650 notifications.push((terminal, format!("Use this completion command for the current assignment: {command}\n{instructions}")));
1651 }
1652 notifications
1653 };
1654 for (terminal, message) in notifications {
1655 if self.terminals.is_running(terminal) {
1656 self.notify_workflow_agent(terminal, &message)?;
1657 }
1658 }
1659 Ok(())
1660 }
1661
1662 pub(super) fn prune_run_processes(&self) -> Result<(), ApplicationError> {
1663 let inner = self.lock_inner()?;
1664 let mut processes = self
1665 .run_processes
1666 .lock()
1667 .map_err(|_| ApplicationError::Poisoned)?;
1668 for processes in processes.values_mut() {
1669 processes.reap_inboxes();
1670 }
1671 processes.retain(|id, _| {
1672 inner.workflows.workflows.iter().any(|w| {
1673 w.workflow_id == *id
1674 && w.run.as_ref().is_some_and(|run| {
1675 matches!(
1676 run.status,
1677 RunStatus::Running | RunStatus::Completed | RunStatus::LimitReached
1678 )
1679 })
1680 })
1681 });
1682 Ok(())
1683 }
1684}
1685
1686impl super::Inner {
1687 pub(super) fn adopt_graph_prompt(
1688 &mut self,
1689 workflow_id: WorkflowId,
1690 terminal: TerminalId,
1691 prompt: String,
1692 ) -> Result<(), ApplicationError> {
1693 let Some(index) = self
1694 .workflows
1695 .workflows
1696 .iter()
1697 .position(|w| w.workflow_id == workflow_id)
1698 else {
1699 return Ok(());
1700 };
1701 let mut workflow = self.workflows.workflows[index].clone();
1702 let Some(agent) = workflow
1703 .agents
1704 .iter()
1705 .find(|agent| agent.terminal_id == terminal)
1706 else {
1707 return Ok(());
1708 };
1709 let Some(run) = workflow.run.as_mut() else {
1710 return Ok(());
1711 };
1712 match run.adopt_graph_task(agent.agent_id.0, prompt) {
1713 Ok(true) => self.publish_run(index, workflow),
1714 Ok(false) => Ok(()),
1715 Err(error) => {
1716 tracing::warn!(%error, "couldn't start the graph from its native prompt");
1717 Ok(())
1718 }
1719 }
1720 }
1721
1722 fn record_agent_launch(
1723 &mut self,
1724 tabs: &mut [Agent],
1725 run: &mut WorkflowRun,
1726 agent_id: u64,
1727 terminal_id: TerminalId,
1728 ) -> Option<TerminalId> {
1729 self.terminals.insert(terminal_id, TerminalStatus::Running);
1730 let tab = tabs
1731 .iter_mut()
1732 .find(|tab| tab.agent_id.0 == agent_id)
1733 .expect("agent tab exists");
1734 let previous = (tab.terminal_id.value() != 0 && tab.terminal_id != terminal_id)
1735 .then_some(tab.terminal_id);
1736 if let Some(previous) = previous {
1737 self.terminals.remove(&previous);
1738 }
1739 tab.terminal_id = terminal_id;
1740 run.agents
1741 .iter_mut()
1742 .find(|agent| agent.agent_id == agent_id)
1743 .expect("launched agent belongs to run")
1744 .status = crate::RunAgentStatus::Running;
1745 previous
1746 }
1747
1748 fn publish_launch(
1749 &mut self,
1750 index: usize,
1751 workflow: &mut Workflow,
1752 sessions: &[(TerminalId, &str)],
1753 ) -> Result<(), ApplicationError> {
1754 match self.publish_run_with_sessions(index, workflow.clone(), sessions) {
1755 Ok(()) => Ok(()),
1756 Err(error) => {
1757 tracing::warn!(%error, "failed to record stage launch; stopping its agents");
1758 workflow.run.as_mut().expect("run exists").finish(
1759 RunStatus::Failed,
1760 "Couldn't save the stage launch. Agents have been stopped.",
1761 );
1762 workflow.status = WorkflowStatus::Failed;
1763 workflow.ended_at = Some(timestamp());
1764 self.record_run(index, workflow.clone())
1765 }
1766 }
1767 }
1768
1769 fn publish_run(&mut self, index: usize, workflow: Workflow) -> Result<(), ApplicationError> {
1770 self.publish_run_with_sessions(index, workflow, &[])
1771 }
1772
1773 fn publish_run_with_sessions(
1774 &mut self,
1775 index: usize,
1776 workflow: Workflow,
1777 sessions: &[(TerminalId, &str)],
1778 ) -> Result<(), ApplicationError> {
1779 let spans = self
1780 .folders
1781 .store()
1782 .save_workflow_run_with_sessions(&workflow, sessions)?;
1783 self.trace_spans.extend(spans);
1784 self.publish_trace(workflow.workflow_id)?;
1785 self.record_run(index, workflow)
1786 }
1787
1788 pub(super) fn publish_run_best_effort(
1789 &mut self,
1790 index: usize,
1791 workflow: Workflow,
1792 ) -> Result<(), ApplicationError> {
1793 match self.folders.store().save_workflow_run(&workflow) {
1794 Ok(spans) => {
1795 self.trace_spans.extend(spans);
1796 self.publish_trace(workflow.workflow_id)?;
1797 }
1798 Err(error) => tracing::warn!(%error, "couldn't persist workflow state"),
1799 }
1800 self.record_run(index, workflow)
1801 }
1802
1803 fn record_run(&mut self, index: usize, workflow: Workflow) -> Result<(), ApplicationError> {
1804 self.workflows.workflows[index] = workflow.clone();
1805 self.events
1806 .append(EventKind::State(StateEvent::WorkflowChanged(workflow)))?;
1807 Ok(())
1808 }
1809}
1810
1811pub(super) fn workflow_status(status: RunStatus) -> WorkflowStatus {
1812 match status {
1813 RunStatus::Running => WorkflowStatus::Running,
1814 RunStatus::Completed => WorkflowStatus::Completed,
1815 RunStatus::Failed | RunStatus::LimitReached => WorkflowStatus::Failed,
1816 RunStatus::Cancelled => WorkflowStatus::Cancelled,
1817 RunStatus::Interrupted => WorkflowStatus::Interrupted,
1818 }
1819}
1820
1821#[cfg(test)]
1822mod tests {
1823 use std::ffi::OsString;
1824 use std::os::unix::fs::PermissionsExt;
1825 use std::path::Path;
1826 use std::thread;
1827 use std::time::{Duration, Instant};
1828
1829 use super::*;
1830 use crate::config::Config;
1831 use crate::{BuiltinType, Command, Decision, RequestId};
1832
1833 const SIZE: TerminalSize = TerminalSize {
1834 rows: 24,
1835 columns: 100,
1836 pixel_width: 800,
1837 pixel_height: 480,
1838 };
1839
1840 fn application(folder: &Path, bin: &Path, script: &str) -> Application {
1841 let binary = bin.join("pi");
1842 std::fs::write(&binary, format!("#!/bin/sh\n{script}\n")).unwrap();
1843 std::fs::set_permissions(binary, std::fs::Permissions::from_mode(0o755)).unwrap();
1844 let mut app = Application::with_event_capacity(4096).unwrap();
1845 app.harness_path = Some(OsString::from(format!("{}:/bin:/usr/bin", bin.display())));
1846 accepted(
1847 &app,
1848 Command::OpenFolder {
1849 path: folder.to_owned(),
1850 },
1851 );
1852 app
1853 }
1854
1855 fn accepted(app: &Application, command: Command) {
1856 assert_eq!(
1857 app.handle_command(RequestId(1), command)
1858 .unwrap()
1859 .disposition,
1860 CommandDisposition::Accepted
1861 );
1862 }
1863
1864 fn launch_legacy(app: &Application, folder: &Path, builtin: BuiltinType) -> WorkflowId {
1865 launch_legacy_with_size(app, folder, builtin, SIZE, "Test the workflow")
1866 }
1867
1868 fn launch_legacy_with_size(
1869 app: &Application,
1870 folder: &Path,
1871 builtin: BuiltinType,
1872 run_size: TerminalSize,
1873 prompt: &str,
1874 ) -> WorkflowId {
1875 launch_legacy_with_harness(app, folder, builtin, run_size, prompt, HarnessId::Pi)
1876 }
1877
1878 fn launch_legacy_with_harness(
1879 app: &Application,
1880 folder: &Path,
1881 builtin: BuiltinType,
1882 run_size: TerminalSize,
1883 prompt: &str,
1884 harness: HarnessId,
1885 ) -> WorkflowId {
1886 accepted(
1887 app,
1888 Command::CreateWorkflow {
1889 folder: folder.to_owned(),
1890 session_id: None,
1891 kind: WorkflowKind::Draft,
1892 roles: vec![],
1893 size: SIZE,
1894 },
1895 );
1896 let id = app
1897 .snapshot()
1898 .unwrap()
1899 .workflows
1900 .workflows
1901 .last()
1902 .unwrap()
1903 .workflow_id;
1904 let definition = builtin.legacy_definition();
1905 let type_id = match builtin {
1906 BuiltinType::Adversarial => 1,
1907 BuiltinType::Coordinator => 2,
1908 };
1909 let directory = app
1910 .lock_inner()
1911 .unwrap()
1912 .folders
1913 .store()
1914 .workflow_directory
1915 .clone();
1916 std::fs::create_dir_all(&directory).unwrap();
1917 let mut file = toml::Table::new();
1918 file.insert("schema_version".into(), toml::Value::Integer(1));
1919 file.insert("id".into(), toml::Value::Integer(type_id));
1920 file.insert("version".into(), toml::Value::Integer(1));
1921 file.insert(
1922 "definition".into(),
1923 toml::Value::try_from(&definition).unwrap(),
1924 );
1925 std::fs::write(
1926 directory.join(format!("legacy-{type_id}.toml")),
1927 toml::to_string(&file).unwrap(),
1928 )
1929 .unwrap();
1930 let type_id = u64::try_from(type_id).unwrap();
1931 let roles = definition
1932 .roles
1933 .iter()
1934 .flat_map(|r| {
1935 (0..r.instances.min).map(|_| RoleLaunch {
1936 model: None,
1937 effort: None,
1938 yolo: false,
1939 role: r.id.0.clone(),
1940 harness,
1941 })
1942 })
1943 .collect();
1944 accepted(
1945 app,
1946 Command::StartWorkflowRun {
1947 workflow_id: id,
1948 workflow_type: WorkflowTypeRef::User {
1949 type_id,
1950 version: 1,
1951 },
1952 prompt: prompt.into(),
1953 roles,
1954 size: run_size,
1955 },
1956 );
1957 id
1958 }
1959
1960 fn wait_for(
1961 app: &Application,
1962 id: WorkflowId,
1963 mut check: impl FnMut(&Workflow) -> bool,
1964 ) -> Workflow {
1965 let deadline = Instant::now() + Duration::from_secs(15);
1966 loop {
1967 let workflow = app
1968 .snapshot()
1969 .unwrap()
1970 .workflows
1971 .workflows
1972 .into_iter()
1973 .find(|w| w.workflow_id == id)
1974 .unwrap();
1975 if check(&workflow) {
1976 return workflow;
1977 }
1978 assert!(
1979 Instant::now() < deadline,
1980 "workflow didn't reach expected state: {:?}",
1981 workflow.run
1982 );
1983 thread::sleep(Duration::from_millis(10));
1984 }
1985 }
1986
1987 fn graph_definition(
1988 edges: &[(&str, &str, crate::GraphEvent)],
1989 finish: crate::GraphFinish,
1990 ) -> crate::WorkflowTypeDefinition {
1991 let mut definition = BuiltinType::Adversarial.definition();
1992 definition.stages.clear();
1993 definition.handoffs.clear();
1994 definition.review_loops.clear();
1995 definition.graph = Some(crate::AgentGraph {
1996 edges: edges
1997 .iter()
1998 .enumerate()
1999 .map(|(index, (from, to, event))| crate::GraphEdge {
2000 id: index.to_string(),
2001 from: (*from).into(),
2002 to: (*to).into(),
2003 event: *event,
2004 })
2005 .collect(),
2006 receive: std::collections::BTreeMap::default(),
2007 review_agents: std::collections::BTreeSet::default(),
2008 positions: std::collections::BTreeMap::default(),
2009 finish,
2010 max_activations: 32,
2011 });
2012 definition
2013 }
2014
2015 fn launch_graph(
2016 app: &Application,
2017 folder: &Path,
2018 definition: &crate::WorkflowTypeDefinition,
2019 prompt: &str,
2020 ) -> WorkflowId {
2021 let reference = WorkflowCatalog::new(app.lock_inner().unwrap().folders.store())
2022 .create(definition)
2023 .unwrap();
2024 accepted(
2025 app,
2026 Command::CreateWorkflow {
2027 folder: folder.into(),
2028 session_id: None,
2029 kind: WorkflowKind::Draft,
2030 roles: vec![],
2031 size: SIZE,
2032 },
2033 );
2034 let id = app.snapshot().unwrap().workflows.workflows[0].workflow_id;
2035 accepted(
2036 app,
2037 Command::StartWorkflowRun {
2038 workflow_id: id,
2039 workflow_type: reference,
2040 prompt: prompt.into(),
2041 roles: definition
2042 .roles
2043 .iter()
2044 .map(|role| RoleLaunch {
2045 role: role.id.0.clone(),
2046 harness: HarnessId::Pi,
2047 model: None,
2048 effort: None,
2049 yolo: false,
2050 })
2051 .collect(),
2052 size: SIZE,
2053 },
2054 );
2055 id
2056 }
2057
2058 fn recover_graph(app: &Application, folder: &Path, saved: &Workflow) -> Workflow {
2059 app.stop_run_terminals(&saved.terminal_ids(), "Simulate quit");
2060 app.run_processes.lock().unwrap().remove(&saved.workflow_id);
2061 let mut interrupted = saved.clone();
2062 interrupted.status = WorkflowStatus::Interrupted;
2063 interrupted.run.as_mut().unwrap().status = RunStatus::Interrupted;
2064 app.lock_inner()
2065 .unwrap()
2066 .publish_run(0, interrupted.clone())
2067 .unwrap();
2068 app.resume_workflow_run(&interrupted, folder).unwrap();
2069 app.snapshot().unwrap().workflows.workflows[0].clone()
2070 }
2071
2072 #[test]
2073 fn a_recovered_choose_graph_keeps_both_outputs_and_accepts_result_selection() {
2074 use crate::{GraphEvent, GraphFinish};
2075 let folder = tempfile::tempdir().unwrap();
2076 let bin = tempfile::tempdir().unwrap();
2077 let app = application(
2078 folder.path(),
2079 bin.path(),
2080 "stty -echo; while read line; do :; done",
2081 );
2082 let definition = graph_definition(
2083 &[
2084 ("task", "implementer", GraphEvent::Task),
2085 ("task", "reviewer", GraphEvent::Task),
2086 ("implementer", "finish", GraphEvent::Done),
2087 ("reviewer", "finish", GraphEvent::Done),
2088 ],
2089 GraphFinish::Choose,
2090 );
2091 let id = launch_graph(&app, folder.path(), &definition, "The same task");
2092 let current = app.snapshot().unwrap().workflows.workflows[0].clone();
2093 for agent in ¤t.agents {
2094 accepted(
2095 &app,
2096 Command::CompleteWorkflowRole {
2097 workflow_id: id,
2098 agent_id: agent.agent_id,
2099 generation: 1,
2100 signal: CompletionSignal {
2101 decision: Decision::Done,
2102 summary: agent.role.clone(),
2103 assignments: vec![],
2104 task: String::new(),
2105 },
2106 },
2107 );
2108 }
2109 let saved = app.snapshot().unwrap().workflows.workflows[0].clone();
2110 assert!(saved.run.as_ref().unwrap().graph_choice_ready());
2111 let resumed = recover_graph(&app, folder.path(), &saved);
2112 let run = resumed.run.as_ref().unwrap();
2113 assert!(run.graph_choice_ready());
2114 assert_eq!(run.graph.as_ref().unwrap().outputs.len(), 2);
2115 accepted(
2116 &app,
2117 Command::ChooseWorkflowResult {
2118 workflow_id: id,
2119 generation: run.generation,
2120 agent_id: saved.agents[1].agent_id,
2121 },
2122 );
2123 let selected = app.snapshot().unwrap().workflows.workflows[0].clone();
2124 assert_eq!(selected.status, WorkflowStatus::Completed);
2125 assert_eq!(
2126 selected
2127 .run
2128 .as_ref()
2129 .unwrap()
2130 .graph
2131 .as_ref()
2132 .unwrap()
2133 .outputs
2134 .len(),
2135 2
2136 );
2137 }
2138
2139 fn submit_completion_command(
2140 app: &Application,
2141 id: WorkflowId,
2142 command: &str,
2143 signal: &CompletionSignal,
2144 ) {
2145 use std::io::Write;
2146 use std::process::{Command as ProcessCommand, Stdio};
2147 let mut child = ProcessCommand::new("/bin/sh")
2148 .args(["-c", command])
2149 .stdin(Stdio::piped())
2150 .stdout(Stdio::null())
2151 .stderr(Stdio::null())
2152 .spawn()
2153 .unwrap();
2154 child
2155 .stdin
2156 .take()
2157 .unwrap()
2158 .write_all(&serde_json::to_vec(signal).unwrap())
2159 .unwrap();
2160 wait_for(app, id, |_| {
2161 app.poll_workflow_signals().unwrap();
2162 child.try_wait().unwrap().is_some()
2163 });
2164 assert!(child.wait().unwrap().success());
2165 }
2166
2167 #[test]
2168 fn an_idle_graph_streams_pieces_with_one_command_and_keeps_its_producer_live() {
2169 use crate::{GraphEvent, GraphFinish};
2170 let folder = tempfile::tempdir().unwrap();
2171 let bin = tempfile::tempdir().unwrap();
2172 let app = application(
2173 folder.path(),
2174 bin.path(),
2175 "stty -echo; while read line; do :; done",
2176 );
2177 let definition = graph_definition(
2178 &[
2179 ("task", "implementer", GraphEvent::Task),
2180 ("implementer", "reviewer", GraphEvent::PieceReady),
2181 ("reviewer", "finish", GraphEvent::Done),
2182 ],
2183 GraphFinish::All,
2184 );
2185 let id = launch_graph(&app, folder.path(), &definition, "");
2186 let idle = app.snapshot().unwrap().workflows.workflows[0].clone();
2187 assert_eq!(idle.terminal_ids().len(), 1);
2188 assert!(idle.run.as_ref().unwrap().needs_task());
2189 let resumed = recover_graph(&app, folder.path(), &idle);
2190 assert_eq!(resumed.run.as_ref().unwrap().status, RunStatus::Running);
2191 accepted(
2192 &app,
2193 Command::SubmitWorkflowTask {
2194 workflow_id: id,
2195 generation: 2,
2196 prompt: "Process these pieces".into(),
2197 },
2198 );
2199 let running = app.snapshot().unwrap().workflows.workflows[0].clone();
2200 app.recover_completion_routes(Instant::now() + PROMPT_HOOK_TIMEOUT)
2201 .unwrap();
2202 let producer = running.agents[0].agent_id;
2203 let receiver = running.agents[1].agent_id;
2204 let producer_terminal = running.agents[0].terminal_id;
2205 let command =
2206 app.run_processes.lock().unwrap()[&id].completion_commands[&producer].command();
2207 for (decision, summary) in [
2208 (Decision::PieceReady, "Piece one"),
2209 (Decision::PieceReady, "Piece two"),
2210 (Decision::Done, "Producer done"),
2211 ] {
2212 submit_completion_command(
2213 &app,
2214 id,
2215 &command,
2216 &CompletionSignal {
2217 decision,
2218 summary: summary.into(),
2219 assignments: vec![],
2220 task: String::new(),
2221 },
2222 );
2223 assert!(app.terminals.is_running(producer_terminal));
2224 }
2225 let streamed = app.snapshot().unwrap().workflows.workflows[0].clone();
2226 let run = streamed.run.as_ref().unwrap();
2227 assert_eq!(
2228 run.graph.as_ref().unwrap().queues[&receiver.0]
2229 .values()
2230 .map(std::collections::VecDeque::len)
2231 .sum::<usize>(),
2232 1
2233 );
2234 assert!(run.incoming[&receiver.0].contains("Piece one"));
2235 for generation in [1, 2] {
2236 accepted(
2237 &app,
2238 Command::CompleteWorkflowRole {
2239 workflow_id: id,
2240 agent_id: receiver,
2241 generation,
2242 signal: CompletionSignal {
2243 decision: Decision::Done,
2244 summary: format!("Result {generation}"),
2245 assignments: vec![],
2246 task: String::new(),
2247 },
2248 },
2249 );
2250 }
2251 let completed = app.snapshot().unwrap().workflows.workflows[0].clone();
2252 assert_eq!(completed.status, WorkflowStatus::Completed);
2253 assert_eq!(completed.agents[0].terminal_id, producer_terminal);
2254 assert!(app.terminals.is_running(producer_terminal));
2255 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
2256 }
2257
2258 const FAKE: &str = r#"
2260 extension=''
2261 instructions=''
2262 while [ "$#" -gt 0 ] && [ "$1" != -- ]; do
2263 if [ "$1" = --extension ]; then shift; extension=$1;
2264 elif [ "$1" = --append-system-prompt ]; then shift; instructions=$1; fi
2265 shift
2266 done
2267 prompt=$2
2268 command=$(printf '%s\n' "$instructions" | sed -n "s/^'\(.*\/complete\)' <<.*/\1/p")
2269 socket=$(sed -n 's/.*const socketPath = "\(.*\)";/\1/p' "$extension")
2270 if [ -n "$prompt" ]; then
2271 case "$instructions" in 'You are Worker '*|*'Outgoing links:'*) prompt="$instructions
2272$prompt" ;; esac
2273 fi
2274 round=0
2275 paste_start=$(printf '\033[200~')
2276 paste_end=$(printf '\033[201~')
2277 while :; do
2278 replacement=$(printf '%s\n' "$prompt" | sed -n "s/^Use this completion command for the current assignment: '\(.*\)'/\1/p")
2279 if [ -n "$replacement" ]; then command=$replacement; fi
2280 prompt=$(printf '%s\n' "$prompt" | sed '/^Use this completion command for the current assignment:/d')
2281 round=$((round + 1))
2282 turn="turn-$$-$round"
2283 completion=''
2284 decision=''
2285 case "$prompt" in
2286 'You are Implementer '*|'Continue with the Implement stage.'*) echo implement >> order; decision=done ;;
2287 'You are Reviewer '*|'Continue with the Review stage.'*) echo review >> order; decision=approve
2288 if [ -f request-changes ]; then decision=requestChanges; fi ;;
2289 'You are Coordinator in the Split '*|'Continue with the Split stage.'*)
2290 echo split >> order
2291 completion='{"decision":"done","summary":"Result or feedback","assignments":[{"role":"worker","instance":1,"task":"First sub-task","files":["first.txt"]},{"role":"worker","instance":2,"task":"Second sub-task","files":["second.txt"]}]}' ;;
2292 'You are Worker 1 '*) echo worker1 >> order; printf '%s' "$prompt" > worker1-prompt
2293 while [ ! -f worker2-prompt ]; do sleep 0.01; done; decision=done ;;
2294 'You are Worker 2 '*) echo worker2 >> order; printf '%s' "$prompt" > worker2-prompt
2295 while [ ! -f worker1-prompt ]; do sleep 0.01; done; decision=done ;;
2296 'You are Coordinator in the Gather '*|'Continue with the Gather stage.'*) echo gather >> order; printf '%s' "$prompt" > gather-prompt; decision=done ;;
2297 *) printf '%s' "$prompt" > "feedback-$$" ;;
2298 esac
2299 if [ -n "$completion" ] || [ -n "$decision" ]; then
2300 curl --silent --unix-socket "$socket" --data-binary "{\"type\":\"prompt\",\"turn_id\":\"$turn\",\"detail\":\"Assignment\"}" http://localhost/
2301 case "$command" in *twine-agent-completion-*)
2302 while [ ! -f "$(dirname "$command")/current" ]; do sleep 0.01; done ;;
2303 esac
2304 if [ -z "$completion" ]; then
2305 completion=$(printf '{"decision":"%s","summary":"Result or feedback"}' "$decision")
2306 fi
2307 printf '%s' "$completion" | "$command"
2308 curl --silent --unix-socket "$socket" --data-binary "{\"type\":\"response\",\"turn_id\":\"$turn\",\"detail\":\"Finished\"}" http://localhost/
2309 fi
2310 prompt=''
2311 while IFS= read -r line; do
2312 last=false
2313 case "$line" in *"$paste_end") line=${line%"$paste_end"}; last=true ;; esac
2314 line=${line#"$paste_start"}
2315 prompt="$prompt$line
2316"
2317 if [ "$last" = true ]; then break; fi
2318 done
2319 [ -n "$prompt" ] || exit
2320 done
2321 "#;
2322
2323 #[test]
2324 fn interrupted_workflow_resumes_its_assignment_and_accepts_fresh_completion() {
2325 let folder = tempfile::tempdir().unwrap();
2326 let bin = tempfile::tempdir().unwrap();
2327 let app = application(
2328 folder.path(),
2329 bin.path(),
2330 "while read line; do echo \"$line\"; done",
2331 );
2332 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2333 let original = wait_for(&app, id, |w| !w.terminal_ids().is_empty());
2334 let old_terminal = original.agents[0].terminal_id;
2335 app.lock_inner()
2336 .unwrap()
2337 .folders
2338 .store()
2339 .remember_harness_session(old_terminal, "/tmp/implementer.jsonl")
2340 .unwrap();
2341 accepted(&app, Command::CloseFolder);
2342 accepted(
2343 &app,
2344 Command::OpenFolder {
2345 path: folder.path().to_owned(),
2346 },
2347 );
2348 let restored = wait_for(&app, id, |w| w.status == WorkflowStatus::Running);
2349 let run = restored.run.as_ref().unwrap();
2350 assert_eq!(run.stage_index, original.run.as_ref().unwrap().stage_index);
2351 assert!(run.generation > original.run.as_ref().unwrap().generation);
2352 assert_ne!(restored.agents[0].terminal_id, old_terminal);
2353 assert!(
2354 restored
2355 .terminal_history
2356 .iter()
2357 .any(|entry| entry.terminal_id == old_terminal)
2358 );
2359 accepted(
2360 &app,
2361 Command::CompleteWorkflowRole {
2362 workflow_id: id,
2363 agent_id: restored.agents[0].agent_id,
2364 generation: run.generation,
2365 signal: CompletionSignal {
2366 decision: Decision::Done,
2367 summary: "Resumed assignment finished".into(),
2368 assignments: vec![],
2369 task: String::new(),
2370 },
2371 },
2372 );
2373 let advanced = wait_for(&app, id, |w| {
2374 w.run.as_ref().unwrap().stage_index != run.stage_index
2375 });
2376 assert_eq!(advanced.status, WorkflowStatus::Running);
2377 assert_ne!(advanced.agents[1].terminal_id.value(), 0);
2378 }
2379
2380 #[test]
2381 fn fake_harness_completes_adversarial_and_hits_review_limit() {
2382 for limited in [false, true] {
2383 let folder = tempfile::tempdir().unwrap();
2384 let bin = tempfile::tempdir().unwrap();
2385 if limited {
2386 std::fs::write(folder.path().join("request-changes"), "").unwrap();
2387 }
2388 let app = application(folder.path(), bin.path(), FAKE);
2389 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2390 let result = wait_for(&app, id, |w| w.status != WorkflowStatus::Running);
2391 assert_eq!(
2392 result.run.as_ref().unwrap().status,
2393 if limited {
2394 RunStatus::LimitReached
2395 } else {
2396 RunStatus::Completed
2397 }
2398 );
2399 assert_eq!(
2400 std::fs::read_to_string(folder.path().join("order")).unwrap(),
2401 "implement\nreview\n".repeat(if limited { 4 } else { 1 })
2402 );
2403 assert_run_trace_history(&app, &result, if limited { 8 } else { 2 });
2404 }
2405 }
2406
2407 fn finish_active_stage(app: &Application, workflow: &Workflow, decision: Decision, task: &str) {
2408 let run = workflow.run.as_ref().unwrap();
2409 for agent in run.active_agents() {
2410 accepted(
2411 app,
2412 Command::CompleteWorkflowRole {
2413 workflow_id: workflow.workflow_id,
2414 agent_id: AgentId(agent.agent_id),
2415 generation: run.generation,
2416 signal: CompletionSignal {
2417 decision: if run.is_reviewer(agent.agent_id) {
2418 decision
2419 } else {
2420 Decision::Done
2421 },
2422 summary: "Result or feedback".into(),
2423 task: task.into(),
2424 assignments: run
2425 .assignment_targets(agent.agent_id)
2426 .iter()
2427 .map(|target| crate::Assignment {
2428 role: target.role.clone(),
2429 instance: target.instance,
2430 task: format!("Task {}", target.instance),
2431 files: vec![format!("{}.txt", target.instance)],
2432 })
2433 .collect(),
2434 },
2435 },
2436 );
2437 }
2438 }
2439
2440 fn finish_remaining_stages(app: &Application, id: WorkflowId) {
2441 loop {
2442 let workflow = app.snapshot().unwrap().workflows.workflows[0].clone();
2443 assert_eq!(workflow.workflow_id, id);
2444 if workflow.status != WorkflowStatus::Running {
2445 break;
2446 }
2447 finish_active_stage(app, &workflow, Decision::Approve, "");
2448 }
2449 }
2450
2451 const RECORD_INPUT: &str = r#"
2452 extension=''
2453 instructions=''
2454 while [ "$#" -gt 0 ] && [ "$1" != -- ]; do
2455 if [ "$1" = --extension ]; then shift; extension=$1;
2456 elif [ "$1" = --append-system-prompt ]; then shift; instructions=$1; fi
2457 shift
2458 done
2459 prompt=$2
2460 case "$instructions" in 'You are Implementer '*) role=implement ;; *) role=review ;; esac
2461 printf '%s' "$prompt" > "$role-launch"
2462 printf '%s\n' "$instructions" | sed -n "s/^'\(.*\/complete\)' <<.*/\1/p" > "$role-command"
2463 if [ -n "$extension" ]; then
2464 sed -n 's/.*const socketPath = "\(.*\)";/\1/p' "$extension" > "$role-socket"
2465 fi
2466 stty -echo
2467 touch "$role-ready"
2468 while IFS= read -r line; do printf '%s\n' "$line" >> "$role-input"; done
2469 "#;
2470
2471 fn send_implementer_hook(app: &Application, folder: &Path, kind: &str, turn: &str) {
2472 let socket = std::fs::read_to_string(folder.join("implement-socket")).unwrap();
2473 let payload = serde_json::json!({"type":kind,"turn_id":turn,"detail":"Assignment"});
2474 assert!(
2475 std::process::Command::new("/usr/bin/curl")
2476 .args([
2477 "--silent",
2478 "--max-time",
2479 "1",
2480 "--unix-socket",
2481 socket.trim(),
2482 "--data-binary",
2483 &payload.to_string(),
2484 "http://localhost/"
2485 ])
2486 .output()
2487 .unwrap()
2488 .status
2489 .success()
2490 );
2491 app.poll_harness_steps().unwrap();
2492 if kind == "response" {
2493 thread::sleep(Duration::from_millis(60));
2494 app.poll_harness_steps().unwrap();
2495 }
2496 }
2497
2498 #[test]
2499 fn previous_turn_cannot_complete_a_reused_assignment_after_manual_handoff() {
2500 use std::io::Write;
2501 use std::process::{Command as ProcessCommand, Stdio};
2502
2503 let folder = tempfile::tempdir().unwrap();
2504 let bin = tempfile::tempdir().unwrap();
2505 let app = application(folder.path(), bin.path(), RECORD_INPUT);
2506 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2507 let initial = wait_for(&app, id, |_| folder.path().join("implement-ready").exists());
2508 let terminal = initial.agents[0].terminal_id;
2509 let command = std::fs::read_to_string(folder.path().join("implement-command")).unwrap();
2510 send_implementer_hook(&app, folder.path(), "prompt", "old");
2511 finish_active_stage(&app, &initial, Decision::Done, "");
2512 let review = app.snapshot().unwrap().workflows.workflows[0].clone();
2513 finish_active_stage(&app, &review, Decision::RequestChanges, "");
2514 let reused = app.snapshot().unwrap().workflows.workflows[0].clone();
2515 assert_eq!(reused.agents[0].terminal_id, terminal);
2516 for turn in [None, Some("old"), Some("new")] {
2517 if let Some(turn) = turn {
2518 send_implementer_hook(&app, folder.path(), "prompt", turn);
2519 }
2520 let output = ProcessCommand::new(command.trim()).output().unwrap();
2521 assert!(
2522 !output.status.success(),
2523 "an old turn must not reach the new inbox"
2524 );
2525 assert!(
2526 app.snapshot().unwrap().workflows.workflows[0]
2527 .run
2528 .as_ref()
2529 .unwrap()
2530 .completions
2531 .is_empty()
2532 );
2533 }
2534 let fresh = app.run_processes.lock().unwrap()[&id].inboxes[&reused.agents[0].agent_id]
2536 .1
2537 .command();
2538 let mut child = ProcessCommand::new("/bin/sh")
2539 .args(["-c", &fresh])
2540 .stdin(Stdio::piped())
2541 .stdout(Stdio::null())
2542 .stderr(Stdio::null())
2543 .spawn()
2544 .unwrap();
2545 child
2546 .stdin
2547 .take()
2548 .unwrap()
2549 .write_all(br#"{"decision":"done","summary":"Fresh result"}"#)
2550 .unwrap();
2551 let advanced = wait_for(&app, id, |w| {
2552 w.run.as_ref().unwrap().generation > reused.run.as_ref().unwrap().generation
2553 });
2554 assert!(child.wait().unwrap().success());
2555 assert_eq!(advanced.agents[0].terminal_id, terminal);
2556 assert!(
2557 advanced.run.unwrap().incoming[&advanced.agents[1].agent_id.0].contains("Fresh result")
2558 );
2559 }
2560
2561 fn reuse_after_identified_response(
2562 app: &Application,
2563 folder: &Path,
2564 id: WorkflowId,
2565 draft: bool,
2566 ) -> Workflow {
2567 let initial = wait_for(app, id, |_| folder.join("implement-ready").exists());
2568 send_implementer_hook(app, folder, "prompt", "old");
2569 send_implementer_hook(app, folder, "response", "old");
2570 finish_active_stage(app, &initial, Decision::Done, "");
2571 let review = app.snapshot().unwrap().workflows.workflows[0].clone();
2572 if draft {
2573 app.write_terminal_input(initial.agents[0].terminal_id, b"Unfinished user draft")
2574 .unwrap();
2575 }
2576 finish_active_stage(app, &review, Decision::RequestChanges, "");
2577 app.snapshot().unwrap().workflows.workflows[0].clone()
2578 }
2579
2580 fn private_command(app: &Application, workflow: &Workflow) -> String {
2581 app.run_processes.lock().unwrap()[&workflow.workflow_id].inboxes
2582 [&workflow.agents[0].agent_id]
2583 .1
2584 .command()
2585 }
2586
2587 fn submit_private_result(app: &Application, workflow: &Workflow) -> Workflow {
2588 submit_result(app, workflow, &private_command(app, workflow), "")
2589 }
2590
2591 fn submit_result(
2592 app: &Application,
2593 workflow: &Workflow,
2594 command: &str,
2595 task: &str,
2596 ) -> Workflow {
2597 use std::io::Write;
2598 use std::process::{Command as ProcessCommand, Stdio};
2599 let mut child = ProcessCommand::new("/bin/sh")
2600 .args(["-c", command])
2601 .stdin(Stdio::piped())
2602 .stdout(Stdio::null())
2603 .stderr(Stdio::null())
2604 .spawn()
2605 .unwrap();
2606 child
2607 .stdin
2608 .take()
2609 .unwrap()
2610 .write_all(
2611 &serde_json::to_vec(&CompletionSignal {
2612 decision: Decision::Done,
2613 summary: "Fresh result".into(),
2614 assignments: vec![],
2615 task: task.into(),
2616 })
2617 .unwrap(),
2618 )
2619 .unwrap();
2620 let advanced = wait_for(app, workflow.workflow_id, |w| {
2621 w.run.as_ref().unwrap().generation > workflow.run.as_ref().unwrap().generation
2622 });
2623 assert!(child.wait().unwrap().success());
2624 advanced
2625 }
2626
2627 #[test]
2628 fn slow_user_follow_up_waits_for_submission_and_a_prompt_avoids_fallback() {
2629 let folder = tempfile::tempdir().unwrap();
2630 let bin = tempfile::tempdir().unwrap();
2631 let app = application(folder.path(), bin.path(), RECORD_INPUT);
2632 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2633 let first = wait_for(&app, id, |_| folder.path().join("implement-ready").exists());
2634 let terminal = first.agents[0].terminal_id;
2635 let remembered = std::fs::read_to_string(folder.path().join("implement-command")).unwrap();
2636 send_implementer_hook(&app, folder.path(), "prompt", "old");
2637 send_implementer_hook(&app, folder.path(), "response", "old");
2638 finish_active_stage(&app, &first, Decision::Done, "");
2639 let review = app.snapshot().unwrap().workflows.workflows[0].clone();
2640 finish_active_stage(&app, &review, Decision::Approve, "");
2641 let completed = wait_for(&app, id, |_| {
2642 std::fs::read_to_string(folder.path().join("implement-input")).is_ok_and(|text| {
2643 text.contains("review is complete") && text.ends_with("\x1b[201~\n")
2644 })
2645 });
2646 let before = std::fs::read_to_string(folder.path().join("implement-input")).unwrap();
2647 accepted(
2648 &app,
2649 Command::ContinueWorkflowRun {
2650 workflow_id: id,
2651 agent_id: completed.agents[0].agent_id,
2652 generation: completed.run.unwrap().generation,
2653 mode_revision: 0,
2654 },
2655 );
2656 app.write_terminal_input(terminal, b"My next task").unwrap();
2657 app.recover_completion_routes(Instant::now() + PROMPT_HOOK_TIMEOUT * 10)
2658 .unwrap();
2659 assert!(matches!(
2660 app.run_processes.lock().unwrap()[&id].completion_routing[&first.agents[0].agent_id],
2661 CompletionRouting::AwaitingSubmission(3)
2662 ));
2663 assert_eq!(
2664 std::fs::read_to_string(folder.path().join("implement-input")).unwrap(),
2665 before
2666 );
2667 app.write_terminal_input(terminal, b"\r").unwrap();
2668 let continued = app.snapshot().unwrap().workflows.workflows[0].clone();
2669 send_implementer_hook(&app, folder.path(), "prompt", "new");
2670 app.recover_completion_routes(Instant::now() + PROMPT_HOOK_TIMEOUT * 10)
2671 .unwrap();
2672 assert!(matches!(
2673 app.run_processes.lock().unwrap()[&id].completion_routing[&first.agents[0].agent_id],
2674 CompletionRouting::Active(3)
2675 ));
2676 let command = format!("'{}'", remembered.trim());
2677 let review = submit_result(&app, &continued, &command, "My next task");
2678 assert_eq!(review.run.unwrap().prompt, "My next task");
2679 assert_eq!(review.agents[0].terminal_id, terminal);
2680 }
2681
2682 #[test]
2683 fn missing_current_prompt_recovers_privately_and_keeps_later_reuse_private() {
2684 let folder = tempfile::tempdir().unwrap();
2685 let bin = tempfile::tempdir().unwrap();
2686 let app = application(folder.path(), bin.path(), RECORD_INPUT);
2687 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2688 let reused = reuse_after_identified_response(&app, folder.path(), id, false);
2689 let terminal = reused.agents[0].terminal_id;
2690 let old = std::fs::read_to_string(folder.path().join("implement-command")).unwrap();
2691 assert!(matches!(
2692 app.run_processes.lock().unwrap()[&id].completion_routing[&reused.agents[0].agent_id],
2693 CompletionRouting::AwaitingPrompt(3, _)
2694 ));
2695 let command = private_command(&app, &reused);
2696 app.recover_completion_routes(Instant::now() + PROMPT_HOOK_TIMEOUT)
2697 .unwrap();
2698 wait_for(&app, id, |_| {
2699 std::fs::read_to_string(folder.path().join("implement-input"))
2700 .is_ok_and(|text| text.contains(&command))
2701 });
2702 send_implementer_hook(&app, folder.path(), "prompt", "late");
2703 assert!(
2704 !std::process::Command::new(old.trim())
2705 .output()
2706 .unwrap()
2707 .status
2708 .success()
2709 );
2710 let review = submit_private_result(&app, &reused);
2711 send_implementer_hook(&app, folder.path(), "response", "late");
2712 assert!(app.harness_turn_finished(terminal).unwrap());
2713 finish_active_stage(&app, &review, Decision::RequestChanges, "");
2714 let next = app.snapshot().unwrap().workflows.workflows[0].clone();
2715 assert_eq!(next.agents[0].terminal_id, terminal);
2716 assert!(matches!(
2717 app.run_processes.lock().unwrap()[&id].completion_routing[&next.agents[0].agent_id],
2718 CompletionRouting::Private
2719 ));
2720 let next_command = private_command(&app, &next);
2721 assert_ne!(next_command, command);
2722 wait_for(&app, id, |_| {
2723 std::fs::read_to_string(folder.path().join("implement-input"))
2724 .is_ok_and(|text| text.contains(&next_command))
2725 });
2726 assert!(
2727 !std::process::Command::new(old.trim())
2728 .output()
2729 .unwrap()
2730 .status
2731 .success()
2732 );
2733 assert_eq!(
2734 submit_private_result(&app, &next).run.unwrap().generation,
2735 6
2736 );
2737 }
2738
2739 #[test]
2740 fn deferred_stage_message_starts_recovery_only_after_the_user_submits() {
2741 let folder = tempfile::tempdir().unwrap();
2742 let bin = tempfile::tempdir().unwrap();
2743 let app = application(folder.path(), bin.path(), RECORD_INPUT);
2744 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2745 let reused = reuse_after_identified_response(&app, folder.path(), id, true);
2746 let terminal = reused.agents[0].terminal_id;
2747 let command = private_command(&app, &reused);
2748 app.recover_completion_routes(Instant::now() + PROMPT_HOOK_TIMEOUT * 10)
2749 .unwrap();
2750 assert!(matches!(
2751 app.run_processes.lock().unwrap()[&id].completion_routing[&reused.agents[0].agent_id],
2752 CompletionRouting::AwaitingSubmission(3)
2753 ));
2754 assert!(
2755 !std::fs::read_to_string(folder.path().join("implement-input"))
2756 .unwrap_or_default()
2757 .contains(&command)
2758 );
2759 app.write_terminal_input(terminal, b"\r").unwrap();
2760 assert!(matches!(
2761 app.run_processes.lock().unwrap()[&id].completion_routing[&reused.agents[0].agent_id],
2762 CompletionRouting::AwaitingPrompt(3, _)
2763 ));
2764 app.recover_completion_routes(Instant::now() + PROMPT_HOOK_TIMEOUT)
2765 .unwrap();
2766 wait_for(&app, id, |_| {
2767 std::fs::read_to_string(folder.path().join("implement-input"))
2768 .is_ok_and(|text| text.contains(&command))
2769 });
2770 let input = std::fs::read_to_string(folder.path().join("implement-input")).unwrap();
2771 assert!(input.contains("Unfinished user draft"));
2772 assert!(input.contains("Result or feedback"));
2773 assert_eq!(
2774 submit_private_result(&app, &reused).run.unwrap().generation,
2775 4
2776 );
2777 }
2778
2779 #[test]
2780 fn recovery_does_not_send_an_obsolete_command_after_completion_or_cancellation() {
2781 for cancelled in [false, true] {
2782 let folder = tempfile::tempdir().unwrap();
2783 let bin = tempfile::tempdir().unwrap();
2784 let app = application(folder.path(), bin.path(), RECORD_INPUT);
2785 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2786 let reused = reuse_after_identified_response(&app, folder.path(), id, false);
2787 let command = private_command(&app, &reused);
2788 if cancelled {
2789 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
2790 } else {
2791 finish_active_stage(&app, &reused, Decision::Done, "");
2792 }
2793 app.recover_completion_routes(Instant::now() + PROMPT_HOOK_TIMEOUT * 10)
2794 .unwrap();
2795 assert!(
2796 !std::fs::read_to_string(folder.path().join("implement-input"))
2797 .unwrap_or_default()
2798 .contains(&command)
2799 );
2800 }
2801 }
2802
2803 #[test]
2804 fn unavailable_observer_receives_a_new_private_command_without_repeated_setup() {
2805 let folder = tempfile::tempdir().unwrap();
2806 let bin = tempfile::tempdir().unwrap();
2807 let app = application(folder.path(), bin.path(), RECORD_INPUT);
2808 let wrapper = bin.path().join("opencode");
2809 std::fs::write(
2810 &wrapper,
2811 r#"#!/usr/bin/python3
2812import json, os, re, sys
2813config = json.loads(os.environ['OPENCODE_CONFIG_CONTENT'])
2814plugin = next(path.removeprefix('file://') for path in config['plugin'] if path.endswith('twine-observer.mjs'))
2815with open(plugin) as source:
2816 match = re.search(r'output.system.push\(("(?:\\.|[^"\\])*")\)', source.read())
2817instructions = json.loads(match.group(1))
2818prompt = next((arg.removeprefix('--prompt=') for arg in sys.argv if arg.startswith('--prompt=')), '')
2819os.execvp('pi', ['pi', '--append-system-prompt', instructions, '--', prompt])
2820"#,
2821 )
2822 .unwrap();
2823 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2824 let id = launch_legacy_with_harness(
2825 &app,
2826 folder.path(),
2827 BuiltinType::Adversarial,
2828 SIZE,
2829 "Test",
2830 HarnessId::Opencode,
2831 );
2832 let initial = wait_for(&app, id, |_| folder.path().join("implement-ready").exists());
2833 let old = std::fs::read_to_string(folder.path().join("implement-command")).unwrap();
2834 app.harness_steps
2837 .lock()
2838 .unwrap()
2839 .remove(&initial.agents[0].terminal_id);
2840 app.run_processes
2841 .lock()
2842 .unwrap()
2843 .get_mut(&id)
2844 .unwrap()
2845 .completion_commands
2846 .remove(&initial.agents[0].agent_id);
2847 finish_active_stage(&app, &initial, Decision::Done, "");
2848 let review = app.snapshot().unwrap().workflows.workflows[0].clone();
2849 finish_active_stage(&app, &review, Decision::RequestChanges, "");
2850 let reused = wait_for(&app, id, |_| {
2851 std::fs::read_to_string(folder.path().join("implement-input")).is_ok_and(|text| {
2852 text.contains("Use this completion command")
2853 && text.contains("Continue with the Implement stage.")
2854 })
2855 });
2856 assert_eq!(reused.agents[0].terminal_id, initial.agents[0].terminal_id);
2857 assert!(std::process::Command::new(old.trim()).output().is_err());
2858 let text = std::fs::read_to_string(folder.path().join("implement-input")).unwrap();
2859 assert!(text.contains("Continue with the Implement stage."));
2860 assert!(!text.contains(old.trim()));
2861 assert!(!text.contains("You are Implementer"));
2862 let processes = app.run_processes.lock().unwrap();
2863 assert!(
2864 !processes[&id]
2865 .completion_commands
2866 .contains_key(&reused.agents[0].agent_id)
2867 );
2868 assert!(
2869 text.contains(
2870 &processes[&id].inboxes[&reused.agents[0].agent_id]
2871 .1
2872 .command()
2873 )
2874 );
2875 }
2876
2877 fn change_individual_mode(app: &Application, id: WorkflowId, individual_mode: bool) {
2878 let workflow = app.snapshot().unwrap().workflows.workflows[0].clone();
2879 let run = workflow.run.as_ref().unwrap();
2880 accepted(
2881 app,
2882 Command::SetWorkflowIndividualMode {
2883 workflow_id: id,
2884 generation: run.generation,
2885 mode_revision: run.mode_revision,
2886 individual_mode,
2887 },
2888 );
2889 }
2890
2891 #[test]
2892 fn waiting_for_an_agent_writer_does_not_lock_application_state() {
2893 for changing_mode in [true, false] {
2894 let folder = tempfile::tempdir().unwrap();
2895 let bin = tempfile::tempdir().unwrap();
2896 let app = Arc::new(application(folder.path(), bin.path(), RECORD_INPUT));
2897 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
2898 finish_remaining_stages(&app, id);
2899 let completed = app.snapshot().unwrap().workflows.workflows[0].clone();
2900 let generation = completed.run.as_ref().unwrap().generation;
2901 let terminal = completed.agents[0].terminal_id;
2902 let input = app.workflow_agent_input(terminal).unwrap().unwrap();
2903 let writer = input.lock().unwrap();
2905 let references = Arc::strong_count(&input);
2906 let worker = Arc::clone(&app);
2907 let pending = thread::spawn(move || {
2908 if changing_mode {
2909 worker
2910 .set_workflow_individual_mode(id, generation, 0, true)
2911 .unwrap();
2912 } else {
2913 worker
2914 .notify_final_review_feedback(id, generation, 0, terminal, "Final feedback")
2915 .unwrap();
2916 }
2917 });
2918 let deadline = Instant::now() + Duration::from_secs(2);
2919 while Arc::strong_count(&input) == references && Instant::now() < deadline {
2920 thread::sleep(Duration::from_millis(1));
2921 }
2922 let waiting = Arc::strong_count(&input) > references;
2923 let state_available = app.inner.try_lock().is_ok();
2924 drop(writer);
2925 pending.join().unwrap();
2926 assert!(waiting && state_available);
2927 }
2928 }
2929
2930 fn submit_individual_follow_ups(
2931 app: &Application,
2932 folder: &Path,
2933 id: WorkflowId,
2934 builtin: BuiltinType,
2935 completed: &Workflow,
2936 ) {
2937 let generation = completed.run.as_ref().unwrap().generation;
2938 let input = app
2939 .workflow_agent_input(completed.agents[0].terminal_id)
2940 .unwrap()
2941 .unwrap();
2942 app.write_terminal_input(completed.agents[0].terminal_id, b"My draft")
2945 .unwrap();
2946 input
2947 .lock()
2948 .unwrap()
2949 .notify(
2950 &app.terminals,
2951 completed.agents[0].terminal_id,
2952 "Initial feedback",
2953 )
2954 .unwrap();
2955 change_individual_mode(app, id, true);
2956 app.notify_final_review_feedback(
2957 id,
2958 generation,
2959 0,
2960 completed.agents[0].terminal_id,
2961 "Obsolete feedback",
2962 )
2963 .unwrap();
2964 for agent in &completed.agents {
2965 app.write_terminal_input(agent.terminal_id, b"Private question\r")
2966 .unwrap();
2967 }
2968 accepted(
2969 app,
2970 Command::ContinueWorkflowRun {
2971 workflow_id: id,
2972 agent_id: completed.agents[0].agent_id,
2973 generation,
2974 mode_revision: 1,
2975 },
2976 );
2977 if builtin == BuiltinType::Adversarial {
2978 wait_for(app, id, |_| {
2979 std::fs::read_to_string(folder.join("implement-input"))
2980 .is_ok_and(|text| text.contains("My draftPrivate question"))
2981 });
2982 let private = std::fs::read_to_string(folder.join("implement-input")).unwrap();
2983 assert!(
2984 !private.contains("Initial feedback") && !private.contains("Obsolete feedback")
2985 );
2986 }
2987 }
2988
2989 #[test]
2990 fn individual_mode_keeps_agents_live_and_rejoining_waits_for_fresh_input() {
2991 for builtin in BuiltinType::ALL {
2992 let folder = tempfile::tempdir().unwrap();
2993 let bin = tempfile::tempdir().unwrap();
2994 let app = application(folder.path(), bin.path(), RECORD_INPUT);
2995 let id = launch_legacy(&app, folder.path(), builtin);
2996 finish_remaining_stages(&app, id);
2997 let completed = app.snapshot().unwrap().workflows.workflows[0].clone();
2998 let generation = completed.run.as_ref().unwrap().generation;
2999 let spans = app.workflow_trace(id, None, 100).unwrap().spans.len();
3000 submit_individual_follow_ups(&app, folder.path(), id, builtin, &completed);
3001 let individual = app.snapshot().unwrap().workflows.workflows[0].clone();
3002 let stored = app
3003 .lock_inner()
3004 .unwrap()
3005 .folders
3006 .store()
3007 .workflows(folder.path())
3008 .unwrap();
3009 assert!(
3010 stored
3011 .iter()
3012 .find(|w| w.workflow_id == id)
3013 .unwrap()
3014 .run
3015 .as_ref()
3016 .unwrap()
3017 .individual_mode
3018 );
3019 assert_eq!(individual.status, WorkflowStatus::Completed);
3020 assert!(individual.run.as_ref().unwrap().individual_mode);
3021 assert_eq!(
3022 individual.run.as_ref().unwrap().completions,
3023 completed.run.as_ref().unwrap().completions
3024 );
3025 assert_eq!(individual.terminal_ids(), completed.terminal_ids());
3026 assert_eq!(
3027 app.workflow_trace(id, None, 100).unwrap().spans.len(),
3028 spans
3029 );
3030 for terminal in individual.terminal_ids() {
3031 assert!(app.terminals.is_running(terminal));
3032 }
3033 change_individual_mode(&app, id, false);
3034 accepted(
3035 &app,
3036 Command::ContinueWorkflowRun {
3037 workflow_id: id,
3038 agent_id: completed.agents[0].agent_id,
3039 generation,
3040 mode_revision: 0,
3041 },
3042 );
3043 assert_eq!(
3044 app.snapshot().unwrap().workflows.workflows[0].status,
3045 WorkflowStatus::Completed
3046 );
3047 accepted(
3048 &app,
3049 Command::ContinueWorkflowRun {
3050 workflow_id: id,
3051 agent_id: completed.agents[0].agent_id,
3052 generation,
3053 mode_revision: 2,
3054 },
3055 );
3056 let continued = app.snapshot().unwrap().workflows.workflows[0].clone();
3057 assert_eq!(continued.status, WorkflowStatus::Running);
3058 assert_eq!(continued.terminal_ids(), completed.terminal_ids());
3059 assert_eq!(continued.run.as_ref().unwrap().generation, generation + 1);
3060 }
3061 }
3062
3063 #[test]
3064 fn final_feedback_preserves_a_user_draft_and_continuation_sends_no_setup_prompt() {
3065 for limited in [false, true] {
3066 let folder = tempfile::tempdir().unwrap();
3067 let bin = tempfile::tempdir().unwrap();
3068 let app = application(folder.path(), bin.path(), RECORD_INPUT);
3069 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
3070 let initial = wait_for(&app, id, |_| folder.path().join("implement-ready").exists());
3071 let terminal = initial.agents[0].terminal_id;
3072 let mut current = initial;
3073 loop {
3074 finish_active_stage(&app, ¤t, Decision::Done, "");
3075 let review = app.snapshot().unwrap().workflows.workflows[0].clone();
3076 let run = review.run.as_ref().unwrap();
3077 let last = !limited
3078 || run.rounds.get("review").copied().unwrap_or(0)
3079 >= run.workflow_type.definition.review_loops[0].max_rounds;
3080 if last {
3081 app.write_terminal_input(terminal, b"My unfinished")
3082 .unwrap();
3083 }
3084 finish_active_stage(
3085 &app,
3086 &review,
3087 if limited {
3088 Decision::RequestChanges
3089 } else {
3090 Decision::Approve
3091 },
3092 "",
3093 );
3094 current = app.snapshot().unwrap().workflows.workflows[0].clone();
3095 if last {
3096 break;
3097 }
3098 }
3099 let before = app.workflow_trace(id, None, 100).unwrap().spans.len();
3100 assert_eq!(
3101 current.run.as_ref().unwrap().status,
3102 if limited {
3103 RunStatus::LimitReached
3104 } else {
3105 RunStatus::Completed
3106 }
3107 );
3108 thread::sleep(Duration::from_millis(30));
3109 let input_path = folder.path().join("implement-input");
3110 assert!(
3111 !std::fs::read_to_string(&input_path)
3112 .unwrap_or_default()
3113 .contains("My unfinished")
3114 );
3115 accepted(
3116 &app,
3117 Command::ContinueWorkflowRun {
3118 workflow_id: id,
3119 agent_id: current.agents[0].agent_id,
3120 generation: current.run.as_ref().unwrap().generation,
3121 mode_revision: 0,
3122 },
3123 );
3124 app.write_terminal_input(terminal, b" message\r").unwrap();
3125 let continued = wait_for(&app, id, |_| {
3126 std::fs::read_to_string(&input_path)
3127 .is_ok_and(|text| text.contains("Continue with the user's message above."))
3128 });
3129 let input = std::fs::read_to_string(&input_path).unwrap();
3130 assert!(input.contains("My unfinished message"));
3131 assert!(input.contains("Review feedback received while you were typing:"));
3132 assert!(input.contains(if limited {
3133 "review limit was reached"
3134 } else {
3135 "review is complete"
3136 }));
3137 assert!(
3138 !input.contains("You are Implementer")
3139 && !input.contains("You are part of a Twine")
3140 );
3141 assert_eq!(continued.agents[0].terminal_id, terminal);
3142 assert_eq!(
3143 app.workflow_trace(id, None, 100).unwrap().spans.len(),
3144 before + 1
3145 );
3146 assert!(continued.run.as_ref().unwrap().needs_task());
3147 finish_active_stage(&app, &continued, Decision::Done, "My unfinished message");
3148 let review = app.snapshot().unwrap().workflows.workflows[0].clone();
3149 assert_eq!(review.run.as_ref().unwrap().prompt, "My unfinished message");
3150 assert!(
3151 review
3152 .run
3153 .as_ref()
3154 .unwrap()
3155 .follow_up_instructions(review.agents[1].agent_id.0)
3156 .contains("My unfinished message")
3157 );
3158 }
3159 }
3160
3161 #[test]
3162 fn agents_stay_interactive_and_keep_their_processes_across_handoffs_and_follow_ups() {
3163 for builtin in BuiltinType::ALL {
3164 let folder = tempfile::tempdir().unwrap();
3165 let bin = tempfile::tempdir().unwrap();
3166 let app = application(
3167 folder.path(),
3168 bin.path(),
3169 r#"
3170 stty -echo
3171 echo $$ >> processes
3172 while IFS= read -r line; do printf '%s\n' "$line" >> "input-$$"; done
3173 "#,
3174 );
3175 let id = launch_legacy(&app, folder.path(), builtin);
3176 let first = wait_for(&app, id, |w| !w.terminal_ids().is_empty());
3177 let first_agent = first.agents[0].agent_id;
3178 let first_terminal = first.agents[0].terminal_id;
3179 finish_active_stage(&app, &first, Decision::Approve, "");
3180 app.write_terminal_input(first_terminal, b"still interactive after handing off\n")
3181 .unwrap();
3182 if builtin == BuiltinType::Adversarial {
3183 let review = app.snapshot().unwrap().workflows.workflows[0].clone();
3184 finish_active_stage(&app, &review, Decision::RequestChanges, "");
3185 let implement = app.snapshot().unwrap().workflows.workflows[0].clone();
3186 assert_eq!(implement.agents[0].terminal_id, first_terminal);
3187 finish_active_stage(&app, &implement, Decision::Approve, "");
3188 }
3189 finish_remaining_stages(&app, id);
3190 let completed = app.snapshot().unwrap().workflows.workflows[0].clone();
3191 assert_eq!(completed.status, WorkflowStatus::Completed);
3192 let terminals = completed.terminal_ids();
3193 assert_eq!(completed.agents[0].terminal_id, first_terminal);
3194 for terminal in &terminals {
3195 assert_eq!(
3196 app.lock_inner().unwrap().terminals[terminal],
3197 TerminalStatus::Running
3198 );
3199 app.write_terminal_input(*terminal, b"still interactive after completing\n")
3200 .unwrap();
3201 }
3202 for _ in 0..2 {
3203 accepted(
3204 &app,
3205 Command::ContinueWorkflowRun {
3206 workflow_id: id,
3207 agent_id: first_agent,
3208 generation: completed.run.as_ref().unwrap().generation,
3209 mode_revision: 0,
3210 },
3211 );
3212 }
3213 let continued = app.snapshot().unwrap().workflows.workflows[0].clone();
3214 assert!(continued.run.as_ref().unwrap().needs_task());
3215 assert_eq!(continued.terminal_ids(), terminals);
3216 app.write_terminal_input(first_terminal, b"The follow-up task\n")
3217 .unwrap();
3218 finish_active_stage(&app, &continued, Decision::Approve, "The follow-up task");
3219 finish_remaining_stages(&app, id);
3220 let finished = app.snapshot().unwrap().workflows.workflows[0].clone();
3221 assert_eq!(finished.status, WorkflowStatus::Completed);
3222 assert_eq!(finished.terminal_ids(), terminals);
3223 assert_eq!(finished.terminal_history, []);
3224 assert_eq!(finished.run.as_ref().unwrap().prompt, "The follow-up task");
3225 wait_for(&app, id, |_| {
3226 std::fs::read_to_string(folder.path().join("processes"))
3227 .is_ok_and(|pids| pids.lines().count() == terminals.len())
3228 && std::fs::read_dir(folder.path())
3229 .unwrap()
3230 .filter_map(Result::ok)
3231 .filter(|entry| entry.file_name().to_string_lossy().starts_with("input-"))
3232 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
3233 .any(|text| text.contains("The follow-up task"))
3234 });
3235 let processes = std::fs::read_to_string(folder.path().join("processes")).unwrap();
3236 assert_eq!(processes.lines().count(), terminals.len());
3237 assert_live_assignment_history(
3238 &app,
3239 id,
3240 &terminals,
3241 if builtin == BuiltinType::Adversarial {
3242 6
3243 } else {
3244 8
3245 },
3246 );
3247 accepted(&app, Command::CloseWorkflow { workflow_id: id });
3248 for terminal in terminals {
3249 assert!(
3250 app.write_terminal_input(terminal, b"after closing\n")
3251 .is_err()
3252 );
3253 }
3254 }
3255 }
3256
3257 fn assert_live_assignment_history(
3258 app: &Application,
3259 id: WorkflowId,
3260 terminals: &[TerminalId],
3261 count: usize,
3262 ) {
3263 let page = app.workflow_trace(id, None, 200).unwrap();
3264 let assignments: Vec<_> = page
3265 .spans
3266 .iter()
3267 .filter(|span| span.title != "Shell")
3268 .collect();
3269 assert_eq!(assignments.len(), count);
3270 for span in assignments {
3271 assert!(terminals.contains(&span.terminal_id.expect("each assignment has a terminal")));
3272 assert_eq!(span.status, crate::TraceSpanStatus::Completed);
3273 let events = app.trace_events(span.span_id, None, 200).unwrap().events;
3274 if let Some(continuation) = events.iter().find(|event| {
3275 event
3276 .message
3277 .contains("Assignment continued in the existing harness")
3278 }) {
3279 let anchor = continuation
3280 .anchor
3281 .as_ref()
3282 .expect("continuation has a terminal boundary");
3283 assert_eq!(anchor.terminal_id, span.terminal_id.unwrap());
3284 assert!(
3285 events
3286 .iter()
3287 .all(|event| event.kind != crate::TraceEventKind::ProcessStarted)
3288 );
3289 let completion = events
3290 .iter()
3291 .find(|event| {
3292 event.message.contains("Marked done") || event.message.contains("Approved:")
3293 })
3294 .unwrap();
3295 assert!(anchor.byte_offset <= completion.anchor.as_ref().unwrap().byte_offset);
3296 }
3297 assert!(
3298 events
3299 .iter()
3300 .all(|event| event.kind != crate::TraceEventKind::ProcessStopped)
3301 );
3302 }
3303 }
3304
3305 #[test]
3306 fn reaching_the_review_limit_keeps_agents_live_for_a_fresh_cycle_and_explicit_cancel() {
3307 let folder = tempfile::tempdir().unwrap();
3308 let bin = tempfile::tempdir().unwrap();
3309 let app = application(
3310 folder.path(),
3311 bin.path(),
3312 "while read line; do echo \"$line\"; done",
3313 );
3314 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
3315 loop {
3316 let workflow = app.snapshot().unwrap().workflows.workflows[0].clone();
3317 if workflow.status != WorkflowStatus::Running {
3318 break;
3319 }
3320 finish_active_stage(&app, &workflow, Decision::RequestChanges, "");
3321 }
3322 let limited = app.snapshot().unwrap().workflows.workflows[0].clone();
3323 assert_eq!(
3324 limited.run.as_ref().unwrap().status,
3325 RunStatus::LimitReached
3326 );
3327 let terminals = limited.terminal_ids();
3328 for terminal in &terminals {
3329 assert!(app.terminals.is_running(*terminal));
3330 }
3331 accepted(
3332 &app,
3333 Command::ContinueWorkflowRun {
3334 workflow_id: id,
3335 agent_id: limited.agents[0].agent_id,
3336 generation: limited.run.as_ref().unwrap().generation,
3337 mode_revision: 0,
3338 },
3339 );
3340 let continued = app.snapshot().unwrap().workflows.workflows[0].clone();
3341 assert_eq!(continued.terminal_ids(), terminals);
3342 assert!(continued.run.as_ref().unwrap().rounds.is_empty());
3343 finish_active_stage(
3344 &app,
3345 &continued,
3346 Decision::Approve,
3347 "A follow-up after the review limit",
3348 );
3349 finish_remaining_stages(&app, id);
3350 assert_eq!(
3351 app.snapshot().unwrap().workflows.workflows[0].status,
3352 WorkflowStatus::Completed
3353 );
3354 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
3355 for terminal in terminals {
3356 assert!(!app.terminals.is_running(terminal));
3357 }
3358 assert!(app.run_processes.lock().unwrap().is_empty());
3359 }
3360
3361 fn assert_run_trace_history(app: &Application, workflow: &Workflow, invocations: u64) {
3362 let page = app.workflow_trace(workflow.workflow_id, None, 200).unwrap();
3363 assert_eq!(page.summary.span_count, invocations);
3364 assert!(page.lanes.iter().all(|lane| lane.is_agent));
3365 assert_eq!(page.summary.agent_count, 2);
3366 for span in page.spans.iter().filter(|span| span.title != "Shell") {
3367 let lane = page
3368 .lanes
3369 .iter()
3370 .find(|lane| lane.lane_id == span.lane_id)
3371 .unwrap();
3372 assert_eq!(lane.harness.as_deref(), Some("pi"));
3373 let events = app.trace_events(span.span_id, None, 200).unwrap().events;
3374 let events: Vec<_> = events
3375 .iter()
3376 .filter(|event| event.kind == crate::TraceEventKind::WorkflowEvent)
3377 .collect();
3378 assert!(
3379 events
3380 .iter()
3381 .any(|event| event.message.contains("Stage started"))
3382 );
3383 assert!(
3384 events
3385 .iter()
3386 .any(|event| event.message.contains("Stage completed"))
3387 );
3388 let completion = events
3389 .iter()
3390 .find(|event| {
3391 event.message.contains("Marked done:")
3392 || event.message.contains("Approved:")
3393 || event.message.contains("Requested changes:")
3394 })
3395 .unwrap();
3396 assert!(completion.message.contains("Result or feedback"));
3397 assert_eq!(span.status, crate::TraceSpanStatus::Completed);
3398 assert_eq!(
3399 span.ended_at,
3400 Some(completion.timestamp.max(span.started_at))
3401 );
3402 assert!(completion.anchor.is_some());
3403 if let Some(handoff) = events
3404 .iter()
3405 .find(|event| event.message.contains("Handoff delivered"))
3406 {
3407 assert_eq!(span.started_at, handoff.timestamp);
3408 assert!(handoff.message.contains("Result or feedback"));
3409 assert!(handoff.anchor.is_some());
3410 } else {
3411 let start = events
3412 .iter()
3413 .find(|event| event.message.contains("Stage started"))
3414 .unwrap();
3415 assert_eq!(span.started_at, start.timestamp);
3416 }
3417 }
3418 for stage in ["Implement", "Review"] {
3419 assert_eq!(
3420 page.spans.iter().filter(|span| span.title == stage).count(),
3421 usize::try_from(invocations / 2).unwrap()
3422 );
3423 }
3424 let mut inner = app.lock_inner().unwrap();
3427 let store = inner.folders.store();
3428 let before = store
3429 .workflow_trace(workflow.workflow_id, None, 200)
3430 .unwrap()
3431 .summary
3432 .revision;
3433 assert_eq!(store.save_workflow_run(workflow).unwrap(), []);
3434 assert_eq!(
3435 store
3436 .workflow_trace(workflow.workflow_id, None, 200)
3437 .unwrap()
3438 .summary
3439 .revision,
3440 before
3441 );
3442 }
3443
3444 #[test]
3445 fn fake_coordinator_sends_worker_tasks_and_files_then_gathers_results() {
3446 let folder = tempfile::tempdir().unwrap();
3447 let bin = tempfile::tempdir().unwrap();
3448 let app = application(folder.path(), bin.path(), FAKE);
3449 let id = launch_legacy(&app, folder.path(), BuiltinType::Coordinator);
3450 let result = wait_for(&app, id, |w| w.status == WorkflowStatus::Completed);
3451 let order = std::fs::read_to_string(folder.path().join("order")).unwrap();
3452 let order: Vec<_> = order.lines().collect();
3453 assert_eq!(order.first(), Some(&"split"));
3454 assert_eq!(order.last(), Some(&"gather"));
3455 assert!(order[1..3].contains(&"worker1") && order[1..3].contains(&"worker2"));
3456 for (worker, task, file) in [
3457 (1, "First sub-task", "first.txt"),
3458 (2, "Second sub-task", "second.txt"),
3459 ] {
3460 let prompt =
3461 std::fs::read_to_string(folder.path().join(format!("worker{worker}-prompt")))
3462 .unwrap();
3463 assert!(prompt.contains(task) && prompt.contains(file));
3464 }
3465 let gather = std::fs::read_to_string(folder.path().join("gather-prompt")).unwrap();
3466 assert!(gather.contains("Worker 1") && gather.contains("Worker 2"));
3467 let traces = result.run.unwrap().traces;
3468 assert_eq!(
3469 traces.iter().filter(|t| t.kind == "stageStarted").count(),
3470 3
3471 );
3472 assert_eq!(traces.iter().filter(|t| t.kind == "handoff").count(), 4);
3473 let page = app.workflow_trace(id, None, 200).unwrap();
3474 assert_eq!(page.summary.span_count, 4);
3475 for (task, label) in [
3476 ("First sub-task", "Worker 1"),
3477 ("Second sub-task", "Worker 2"),
3478 ] {
3479 let lane = page.lanes.iter().find(|lane| lane.name == label).unwrap();
3480 let spans: Vec<_> = page
3481 .spans
3482 .iter()
3483 .filter(|span| span.lane_id == lane.lane_id)
3484 .collect();
3485 assert_eq!(spans.len(), 1);
3486 let span = spans[0];
3487 assert_eq!(span.title, task);
3488 assert_eq!(span.status, crate::TraceSpanStatus::Completed);
3489 let events = app.trace_events(span.span_id, None, 200).unwrap().events;
3490 let handoff = events
3491 .iter()
3492 .find(|event| event.message.contains("Handoff delivered"))
3493 .unwrap();
3494 assert!(handoff.message.contains(task));
3495 assert_eq!(span.started_at, handoff.timestamp);
3496 let completion = events
3497 .iter()
3498 .find(|event| event.message.contains("Marked done: Result or feedback"))
3499 .unwrap();
3500 assert_eq!(
3501 span.ended_at,
3502 Some(completion.timestamp.max(span.started_at))
3503 );
3504 assert!(handoff.anchor.is_some() && completion.anchor.is_some());
3505 assert_eq!(
3506 completion.anchor.as_ref().unwrap().terminal_id,
3507 span.terminal_id.unwrap()
3508 );
3509 assert!(
3510 events
3511 .iter()
3512 .any(|event| event.kind == crate::TraceEventKind::ProcessStarted)
3513 );
3514 assert!(app.terminals.is_running(span.terminal_id.unwrap()));
3515 }
3516 let handoffs = page
3517 .spans
3518 .iter()
3519 .flat_map(|span| app.trace_events(span.span_id, None, 200).unwrap().events)
3520 .filter(|event| event.message.contains("Handoff delivered"))
3521 .count();
3522 assert_eq!(handoffs, 4);
3523 assert_gather_anchors(&app, &page);
3524 accepted(&app, Command::CloseWorkflow { workflow_id: id });
3525 assert_eq!(
3526 app.workflow_trace(id, None, 200)
3527 .unwrap()
3528 .summary
3529 .span_count,
3530 4
3531 );
3532 }
3533
3534 fn assert_gather_anchors(app: &Application, page: &crate::WorkflowTracePage) {
3535 let gather = page
3536 .spans
3537 .iter()
3538 .find(|span| span.title == "Gather")
3539 .unwrap();
3540 let gathered = app.trace_events(gather.span_id, None, 200).unwrap().events;
3541 for worker in ["Worker 1", "Worker 2"] {
3542 let lane = page.lanes.iter().find(|lane| lane.name == worker).unwrap();
3543 let span = page
3544 .spans
3545 .iter()
3546 .find(|span| span.lane_id == lane.lane_id)
3547 .unwrap();
3548 let events = app.trace_events(span.span_id, None, 200).unwrap().events;
3549 let completion = events
3550 .iter()
3551 .find(|event| event.message.contains("Marked done:"))
3552 .unwrap();
3553 let handoff = gathered
3554 .iter()
3555 .find(|event| event.message.contains(&format!("{worker} → Coordinator")))
3556 .unwrap();
3557 assert_eq!(handoff.anchor, completion.anchor);
3558 assert_eq!(
3559 handoff.anchor.as_ref().unwrap().terminal_id,
3560 span.terminal_id.unwrap()
3561 );
3562 }
3563 }
3564
3565 fn launch_coordinator_workers(app: &Application, folder: &Path) -> WorkflowId {
3566 let id = launch_legacy(app, folder, BuiltinType::Coordinator);
3567 let workflow = app.snapshot().unwrap().workflows.workflows[0].clone();
3568 accepted(
3569 app,
3570 Command::CompleteWorkflowRole {
3571 workflow_id: id,
3572 agent_id: workflow.agents[0].agent_id,
3573 generation: 1,
3574 signal: CompletionSignal {
3575 task: String::new(),
3576 decision: Decision::Done,
3577 summary: "Split the task".into(),
3578 assignments: (1..=2)
3579 .map(|instance| crate::Assignment {
3580 role: "worker".into(),
3581 instance,
3582 task: format!("Task {instance}"),
3583 files: vec![],
3584 })
3585 .collect(),
3586 },
3587 },
3588 );
3589 id
3590 }
3591
3592 fn done_signal(summary: &str) -> CompletionSignal {
3593 CompletionSignal {
3594 decision: Decision::Done,
3595 summary: summary.into(),
3596 assignments: vec![],
3597 task: String::new(),
3598 }
3599 }
3600
3601 #[test]
3602 fn coordinator_assignments_finish_independently_and_cancellation_stops_only_unfinished_work() {
3603 let folder = tempfile::tempdir().unwrap();
3604 let bin = tempfile::tempdir().unwrap();
3605 let app = application(folder.path(), bin.path(), "echo ready; sleep 60");
3606 let id = launch_coordinator_workers(&app, folder.path());
3607 let page = app.workflow_trace(id, None, 200).unwrap();
3608 let workers: Vec<_> = page
3609 .spans
3610 .iter()
3611 .filter(|span| span.title.starts_with("Task "))
3612 .collect();
3613 assert_eq!(workers.len(), 2);
3614 assert!(
3615 workers
3616 .iter()
3617 .all(|span| span.is_live && span.ended_at.is_none())
3618 );
3619 let workflow = app.snapshot().unwrap().workflows.workflows[0].clone();
3620 let first = workers.iter().find(|span| span.title == "Task 1").unwrap();
3621 let terminal = first.terminal_id.unwrap();
3622 wait_for(&app, id, |_| {
3623 app.terminals.observe(terminal).unwrap().byte_offset > 0
3624 });
3625 let size = TerminalSize {
3626 columns: 120,
3627 ..SIZE
3628 };
3629 app.terminals.resize(terminal, size).unwrap();
3630 let boundary = app.terminals.observe(terminal).unwrap();
3631 accepted(
3632 &app,
3633 Command::CompleteWorkflowRole {
3634 workflow_id: id,
3635 agent_id: workflow.agents[1].agent_id,
3636 generation: 2,
3637 signal: done_signal("Finished Task 1"),
3638 },
3639 );
3640 let page = app.workflow_trace(id, None, 200).unwrap();
3641 let finished = page
3642 .spans
3643 .iter()
3644 .find(|span| span.title == "Task 1")
3645 .unwrap();
3646 assert_eq!(finished.status, crate::TraceSpanStatus::Completed);
3647 let events = app
3648 .trace_events(finished.span_id, None, 200)
3649 .unwrap()
3650 .events;
3651 let completion = events
3652 .iter()
3653 .find(|event| event.message.contains("Marked done: Finished Task 1"))
3654 .unwrap();
3655 let anchor = completion.anchor.as_ref().unwrap();
3656 assert_eq!(anchor.byte_offset, boundary.byte_offset);
3658 assert_eq!(anchor.boundary_sizes, Some(vec![size]));
3659 assert_eq!(anchor.terminal_id, terminal);
3660 assert!(
3661 events
3662 .iter()
3663 .all(|event| event.kind != crate::TraceEventKind::ProcessStopped)
3664 );
3665 assert!(app.terminals.is_running(terminal));
3666 let unfinished = page
3667 .spans
3668 .iter()
3669 .find(|span| span.title == "Task 2")
3670 .unwrap();
3671 assert!(unfinished.is_live && unfinished.ended_at.is_none());
3672 let finished = finished.clone();
3673 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
3674 assert!(!app.terminals.is_running(terminal));
3675 let page = app.workflow_trace(id, None, 200).unwrap();
3676 assert_eq!(
3677 page.spans
3678 .iter()
3679 .find(|span| span.title == "Task 1")
3680 .unwrap(),
3681 &finished
3682 );
3683 let stopped = page
3684 .spans
3685 .iter()
3686 .find(|span| span.title == "Task 2")
3687 .unwrap();
3688 assert_eq!(stopped.status, crate::TraceSpanStatus::Stopped);
3689 assert!(!stopped.is_live && stopped.ended_at.is_some());
3690 let events = app.trace_events(stopped.span_id, None, 200).unwrap().events;
3691 assert!(
3692 events
3693 .iter()
3694 .any(|event| event.kind == crate::TraceEventKind::ProcessStopped
3695 && event.anchor.as_ref().unwrap().terminal_id == stopped.terminal_id.unwrap())
3696 );
3697 assert!(
3698 !events
3699 .iter()
3700 .any(|event| event.message.contains("Marked done"))
3701 );
3702 }
3703
3704 #[test]
3705 fn a_process_failure_does_not_end_an_assignment_before_accepted_completion() {
3706 let folder = tempfile::tempdir().unwrap();
3707 let bin = tempfile::tempdir().unwrap();
3708 let app = application(folder.path(), bin.path(), "sleep 60");
3709 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
3710 let workflow = app.snapshot().unwrap().workflows.workflows[0].clone();
3711 let agent = &workflow.agents[0];
3712 app.lock_inner()
3713 .unwrap()
3714 .record_terminal_exit(agent.terminal_id, Err("Injected process failure".into()));
3715 let span = app.workflow_trace(id, None, 200).unwrap().spans[0].clone();
3716 assert_eq!(span.status, crate::TraceSpanStatus::Running);
3717 assert!(span.is_live && span.ended_at.is_none());
3718 let events = app.trace_events(span.span_id, None, 200).unwrap().events;
3719 assert!(
3720 events
3721 .iter()
3722 .any(|event| event.kind == crate::TraceEventKind::ProcessFailed
3723 && event.anchor.is_some())
3724 );
3725 accepted(
3726 &app,
3727 Command::CompleteWorkflowRole {
3728 workflow_id: id,
3729 agent_id: agent.agent_id,
3730 generation: 1,
3731 signal: CompletionSignal {
3732 task: String::new(),
3733 decision: Decision::Done,
3734 summary: "Recovered the result".into(),
3735 assignments: vec![],
3736 },
3737 },
3738 );
3739 let page = app.workflow_trace(id, None, 200).unwrap();
3740 let completed = page
3741 .spans
3742 .iter()
3743 .find(|candidate| candidate.span_id == span.span_id)
3744 .unwrap();
3745 assert_eq!(completed.status, crate::TraceSpanStatus::Completed);
3746 let events = app.trace_events(span.span_id, None, 200).unwrap().events;
3747 let completion = events
3748 .iter()
3749 .find(|event| event.message.contains("Marked done: Recovered the result"))
3750 .unwrap();
3751 assert_eq!(
3752 completed.ended_at,
3753 Some(completion.timestamp.max(completed.started_at))
3754 );
3755 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
3756 }
3757
3758 #[test]
3759 fn closing_an_exited_agent_stops_its_unfinished_assignment() {
3760 let folder = tempfile::tempdir().unwrap();
3761 let bin = tempfile::tempdir().unwrap();
3762 let app = application(folder.path(), bin.path(), "exit 0");
3763 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
3764 wait_for(&app, id, |_| {
3765 app.lock_inner()
3766 .unwrap()
3767 .terminals
3768 .values()
3769 .all(|status| !matches!(status, TerminalStatus::Running))
3770 });
3771 let span = app.workflow_trace(id, None, 200).unwrap().spans[0].clone();
3772 assert_eq!(span.status, crate::TraceSpanStatus::Running);
3773 assert!(span.is_live && span.ended_at.is_none());
3774 accepted(&app, Command::CloseWorkflow { workflow_id: id });
3775 let closed = app.workflow_trace(id, None, 200).unwrap().spans[0].clone();
3776 assert_eq!(closed.span_id, span.span_id);
3777 assert_eq!(closed.status, crate::TraceSpanStatus::Stopped);
3778 let events = app.trace_events(closed.span_id, None, 200).unwrap().events;
3779 assert!(
3780 events
3781 .iter()
3782 .any(|event| event.kind == crate::TraceEventKind::ProcessExited)
3783 );
3784 assert!(
3785 events
3786 .iter()
3787 .any(|event| event.message.contains("Assignment stopped"))
3788 );
3789 }
3790
3791 #[test]
3792 fn prose_and_process_exit_do_not_advance_but_user_mark_done_does() {
3793 let folder = tempfile::tempdir().unwrap();
3794 let bin = tempfile::tempdir().unwrap();
3795 let app = application(
3796 folder.path(),
3797 bin.path(),
3798 "echo 'done approved requestChanges'; exit 0",
3799 );
3800 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
3801 let first = wait_for(&app, id, |w| {
3802 app.lock_inner()
3803 .unwrap()
3804 .terminals
3805 .values()
3806 .all(|s| !matches!(s, TerminalStatus::Running))
3807 && w.run.is_some()
3808 });
3809 let run = first.run.unwrap();
3810 assert_eq!(run.stage_index, 0);
3811 assert_eq!(first.status, WorkflowStatus::Running);
3812 let assignment = app.workflow_trace(id, None, 200).unwrap().spans[0].clone();
3813 assert_eq!(assignment.status, crate::TraceSpanStatus::Running);
3814 assert!(assignment.is_live && assignment.ended_at.is_none());
3815 let events = app
3816 .trace_events(assignment.span_id, None, 200)
3817 .unwrap()
3818 .events;
3819 assert!(
3820 events
3821 .iter()
3822 .any(|event| event.kind == crate::TraceEventKind::ProcessExited
3823 && event.anchor.is_some())
3824 );
3825 accepted(
3826 &app,
3827 Command::CompleteWorkflowRole {
3828 workflow_id: id,
3829 agent_id: first.agents[0].agent_id,
3830 generation: run.generation,
3831 signal: CompletionSignal {
3832 task: String::new(),
3833 decision: Decision::Done,
3834 summary: "User result".into(),
3835 assignments: vec![],
3836 },
3837 },
3838 );
3839 let page = app.workflow_trace(id, None, 200).unwrap();
3840 let completed = page
3841 .spans
3842 .iter()
3843 .find(|span| span.span_id == assignment.span_id)
3844 .unwrap();
3845 assert_eq!(completed.status, crate::TraceSpanStatus::Completed);
3846 let events = app
3847 .trace_events(completed.span_id, None, 200)
3848 .unwrap()
3849 .events;
3850 let completion = events
3851 .iter()
3852 .find(|event| event.message.contains("Marked done: User result"))
3853 .unwrap();
3854 assert_eq!(
3855 completed.ended_at,
3856 Some(completion.timestamp.max(completed.started_at))
3857 );
3858 assert_eq!(
3859 app.snapshot().unwrap().workflows.workflows[0]
3860 .run
3861 .as_ref()
3862 .unwrap()
3863 .stage_index,
3864 1
3865 );
3866 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
3867 assert_eq!(
3868 app.snapshot().unwrap().workflows.workflows[0].status,
3869 WorkflowStatus::Cancelled
3870 );
3871 assert!(app.run_processes.lock().unwrap().is_empty());
3872 }
3873
3874 #[test]
3875 fn running_workflows_restore_as_interrupted_without_launching_agents() {
3876 let folder = tempfile::tempdir().unwrap();
3877 let bin = tempfile::tempdir().unwrap();
3878 let data = tempfile::tempdir().unwrap();
3879 let app = application(folder.path(), bin.path(), "sleep 60");
3880 let mut persistent = Application::with_config(data.path(), Config::default()).unwrap();
3882 persistent.harness_path.clone_from(&app.harness_path);
3883 accepted(
3884 &persistent,
3885 Command::OpenFolder {
3886 path: folder.path().to_owned(),
3887 },
3888 );
3889 let id = launch_legacy(&persistent, folder.path(), BuiltinType::Adversarial);
3890 let marker = persistent.terminals.reserve_terminal().unwrap();
3891 persistent
3892 .terminals
3893 .cancel_reserved_terminal(marker)
3894 .unwrap();
3895 drop(persistent);
3896 let restored = Application::with_config(data.path(), Config::default()).unwrap();
3897 let workflow = wait_for(&restored, id, |w| w.restored);
3898 assert_eq!(workflow.status, WorkflowStatus::Interrupted);
3899 assert_eq!(workflow.terminal_ids(), []);
3900 assert_eq!(workflow.run.unwrap().status, RunStatus::Interrupted);
3901 let next = restored.terminals.reserve_terminal().unwrap();
3902 restored.terminals.cancel_reserved_terminal(next).unwrap();
3903 assert_eq!(next.value(), marker.value() + 1);
3904 }
3905
3906 #[test]
3907 fn an_incoming_assignment_is_live_while_its_process_launch_waits_on_storage() {
3908 let folder = tempfile::tempdir().unwrap();
3909 let bin = tempfile::tempdir().unwrap();
3910 let app = Arc::new(application(folder.path(), bin.path(), "sleep 60"));
3911 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
3912 let workflow = app.snapshot().unwrap().workflows.workflows[0].clone();
3913 let stalled = app
3914 .terminal_output
3915 .stall_recording_worker(workflow.agents[0].terminal_id);
3916 let completing = Arc::clone(&app);
3917 let completion = thread::spawn(move || {
3918 accepted(
3919 &completing,
3920 Command::CompleteWorkflowRole {
3921 workflow_id: id,
3922 agent_id: workflow.agents[0].agent_id,
3923 generation: 1,
3924 signal: CompletionSignal {
3925 task: String::new(),
3926 decision: Decision::Done,
3927 summary: "Ready for review".into(),
3928 assignments: vec![],
3929 },
3930 },
3931 );
3932 });
3933 let deadline = Instant::now() + Duration::from_secs(3);
3934 while !app.terminal_output.allocation_is_pending() {
3935 assert!(
3936 Instant::now() < deadline,
3937 "review launch did not reach transcript allocation"
3938 );
3939 thread::yield_now();
3940 }
3941 let (sent, received) = std::sync::mpsc::sync_channel(1);
3942 let observing = Arc::clone(&app);
3943 let observer = thread::spawn(move || {
3944 sent.send(observing.workflow_trace(id, None, 200).unwrap())
3945 .unwrap();
3946 });
3947 let page = received
3948 .recv_timeout(Duration::from_secs(1))
3949 .expect("traces remain readable during launch");
3950 let span = page
3951 .spans
3952 .iter()
3953 .find(|span| span.title == "Review")
3954 .unwrap();
3955 assert!(span.is_live && span.terminal_id.is_none() && span.ended_at.is_none());
3956 let span_id = span.span_id;
3957 let events = app.trace_events(span_id, None, 200).unwrap().events;
3958 assert_eq!(span.started_at, events[0].timestamp);
3959 assert!(events[0].message.contains("Ready for review"));
3960 drop(stalled);
3961 observer.join().unwrap();
3962 completion.join().unwrap();
3963 let page = app.workflow_trace(id, None, 200).unwrap();
3964 let span = page
3965 .spans
3966 .iter()
3967 .find(|span| span.span_id == span_id)
3968 .unwrap();
3969 assert!(span.is_live && span.terminal_id.is_some());
3970 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
3971 }
3972
3973 #[test]
3974 fn stalled_run_launch_keeps_snapshots_available_and_consistent_with_events() {
3975 let folder = tempfile::tempdir().unwrap();
3976 let bin = tempfile::tempdir().unwrap();
3977 let app = Arc::new(application(folder.path(), bin.path(), "sleep 60"));
3978 accepted(
3979 &app,
3980 Command::CreateWorkflow {
3981 folder: folder.path().into(),
3982 session_id: None,
3983 kind: WorkflowKind::Draft,
3984 roles: vec![],
3985 size: SIZE,
3986 },
3987 );
3988 let before = app.snapshot().unwrap();
3989 let draft = &before.workflows.workflows[0];
3990 let id = draft.workflow_id;
3991 let stalled = app
3992 .terminal_output
3993 .stall_recording_worker(draft.terminal_id);
3994 let starting = Arc::clone(&app);
3995 let starter = thread::spawn(move || {
3996 accepted(
3997 &starting,
3998 Command::StartWorkflowRun {
3999 workflow_id: id,
4000 workflow_type: WorkflowTypeRef::Builtin(BuiltinType::Adversarial),
4001 prompt: "Test a stalled launch".into(),
4002 roles: ["implementer", "reviewer"]
4003 .map(|role| RoleLaunch {
4004 model: None,
4005 effort: None,
4006 yolo: false,
4007 role: role.into(),
4008 harness: HarnessId::Pi,
4009 })
4010 .into(),
4011 size: SIZE,
4012 },
4013 );
4014 });
4015 let deadline = Instant::now() + Duration::from_secs(3);
4016 while !app.terminal_output.allocation_is_pending() {
4017 assert!(
4018 Instant::now() < deadline,
4019 "launch did not reach transcript allocation"
4020 );
4021 thread::yield_now();
4022 }
4023 let (sent, received) = std::sync::mpsc::sync_channel(1);
4024 let observing = Arc::clone(&app);
4025 let observer = thread::spawn(move || {
4026 let snapshot = observing.snapshot().unwrap();
4027 let events = observing.events_after(before.sequence, 128).unwrap();
4028 sent.send((snapshot, events)).unwrap();
4029 });
4030 let (snapshot, events) = received
4031 .recv_timeout(Duration::from_secs(1))
4032 .expect("state must remain available while launch waits on storage");
4033 let changed = events
4034 .iter()
4035 .rev()
4036 .find_map(|event| match &event.kind {
4037 EventKind::State(StateEvent::WorkflowChanged(workflow))
4038 if workflow.workflow_id == id =>
4039 {
4040 Some(workflow)
4041 }
4042 _ => None,
4043 })
4044 .expect("the newly visible run must already have an event");
4045 assert_eq!(changed, &snapshot.workflows.workflows[0]);
4046 assert!(changed.run.is_some());
4047 assert_eq!(events.last().unwrap().sequence, snapshot.sequence);
4048 drop(stalled);
4049 observer.join().unwrap();
4050 starter.join().unwrap();
4051 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
4052 }
4053
4054 fn fail_run_updates(data: &Path) -> rusqlite::Connection {
4055 let connection = rusqlite::Connection::open(data.join("twine.db")).unwrap();
4056 connection
4057 .execute_batch(
4058 "CREATE TRIGGER reject_run_update BEFORE UPDATE ON workflow_runs
4059 BEGIN SELECT RAISE(ABORT, 'injected run write failure'); END",
4060 )
4061 .unwrap();
4062 connection
4063 }
4064
4065 #[test]
4066 fn a_failed_first_invocation_keeps_its_failure_in_visible_trace_history() {
4067 let folder = tempfile::tempdir().unwrap();
4068 let bin = tempfile::tempdir().unwrap();
4069 let app = application(folder.path(), bin.path(), "exit 0");
4070 let id = launch_legacy_with_size(
4072 &app,
4073 folder.path(),
4074 BuiltinType::Adversarial,
4075 TerminalSize { rows: 0, ..SIZE },
4076 "Test the workflow",
4077 );
4078 let workflow = wait_for(&app, id, |w| w.status == WorkflowStatus::Failed);
4079 assert_eq!(workflow.terminal_ids(), []);
4080 let page = app.workflow_trace(id, None, 200).unwrap();
4081 assert_eq!(page.summary.span_count, 1);
4082 assert_eq!(page.summary.agent_count, 0);
4083 let events = app
4084 .trace_events(page.spans[0].span_id, None, 200)
4085 .unwrap()
4086 .events;
4087 assert!(
4088 events
4089 .iter()
4090 .any(|event| event.message.contains("Couldn't start Implementer"))
4091 );
4092 assert_eq!(
4093 events
4094 .iter()
4095 .filter(|event| event.kind == crate::TraceEventKind::ProcessStarted)
4096 .count(),
4097 0
4098 );
4099 accepted(&app, Command::CloseWorkflow { workflow_id: id });
4100 assert!(
4101 app.trace_events(page.spans[0].span_id, None, 200)
4102 .unwrap()
4103 .events
4104 .iter()
4105 .any(|event| event.message.contains("Couldn't start Implementer"))
4106 );
4107 }
4108
4109 #[test]
4110 fn failed_launch_publication_stops_and_tracks_every_spawned_terminal() {
4111 let folder = tempfile::tempdir().unwrap();
4112 let bin = tempfile::tempdir().unwrap();
4113 let data = tempfile::tempdir().unwrap();
4114 let fixture = application(folder.path(), bin.path(), "sleep 60");
4115 let mut app = Application::with_config(data.path(), Config::default()).unwrap();
4116 app.harness_path.clone_from(&fixture.harness_path);
4117 accepted(
4118 &app,
4119 Command::OpenFolder {
4120 path: folder.path().to_owned(),
4121 },
4122 );
4123 let _failure = fail_run_updates(data.path());
4124 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
4125 let workflow = wait_for(&app, id, |w| {
4126 w.status == WorkflowStatus::Failed
4127 && app
4128 .lock_inner()
4129 .unwrap()
4130 .terminals
4131 .values()
4132 .all(|t| !matches!(t, TerminalStatus::Running))
4133 });
4134 assert!(
4135 !workflow.terminal_ids().is_empty(),
4136 "stopped terminals remain owned by the failed workflow"
4137 );
4138 assert!(app.run_processes.lock().unwrap().is_empty());
4139 accepted(&app, Command::CloseWorkflow { workflow_id: id });
4140 for terminal in workflow.terminal_ids() {
4141 assert!(app.terminals.size(terminal).is_none());
4142 }
4143 }
4144
4145 #[test]
4146 fn a_run_without_a_prompt_waits_for_input_and_passes_the_reported_task_on() {
4147 let folder = tempfile::tempdir().unwrap();
4148 let bin = tempfile::tempdir().unwrap();
4149 let app = application(
4151 folder.path(),
4152 bin.path(),
4153 r#"for last; do :; done; printf '%s' "$last" > "p-$$.tmp"; mv "p-$$.tmp" "prompt-$$.txt"; sleep 60"#,
4154 );
4155 let id = launch_legacy_with_size(&app, folder.path(), BuiltinType::Adversarial, SIZE, "");
4156 let prompts = |count: usize| {
4157 let deadline = Instant::now() + Duration::from_secs(15);
4158 loop {
4159 let found: Vec<String> = std::fs::read_dir(folder.path())
4160 .unwrap()
4161 .filter_map(Result::ok)
4162 .filter(|entry| entry.file_name().to_string_lossy().starts_with("prompt-"))
4163 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
4164 .filter(|text| !text.is_empty())
4165 .collect();
4166 if found.len() >= count {
4167 return found;
4168 }
4169 assert!(Instant::now() < deadline, "agent prompts timed out");
4170 thread::sleep(Duration::from_millis(10));
4171 }
4172 };
4173 assert!(prompts(1)[0].contains("Wait for the user to give you a task"));
4174
4175 submit_helper(&app, id);
4177 let run = app.snapshot().unwrap().workflows.workflows[0]
4178 .run
4179 .clone()
4180 .unwrap();
4181 assert_eq!(run.generation, 1);
4182 let response = app.run_processes.lock().unwrap()[&id]
4183 .inboxes
4184 .values()
4185 .next()
4186 .unwrap()
4187 .1
4188 .response();
4189 assert!(response.contains("task"), "{response}");
4190
4191 submit_signal(
4192 &app,
4193 id,
4194 br#"{"decision":"done","summary":"Done","task":"Add a toggle"}"#,
4195 );
4196 wait_for(&app, id, |w| {
4197 w.run.as_ref().is_some_and(|run| run.generation == 2)
4198 });
4199 assert!(
4200 prompts(2)
4201 .iter()
4202 .any(|prompt| prompt.contains("Task:\nAdd a toggle"))
4203 );
4204 }
4205
4206 #[test]
4207 fn each_role_starts_with_its_chosen_model_and_effort() {
4208 let folder = tempfile::tempdir().unwrap();
4209 let bin = tempfile::tempdir().unwrap();
4210 let app = application(
4212 folder.path(),
4213 bin.path(),
4214 r#"printf '%s ' "$@" > "a-$$.tmp"; mv "a-$$.tmp" "args-$$.txt"; sleep 60"#,
4215 );
4216 accepted(
4217 &app,
4218 Command::CreateWorkflow {
4219 folder: folder.path().to_owned(),
4220 session_id: None,
4221 kind: WorkflowKind::Draft,
4222 roles: vec![],
4223 size: SIZE,
4224 },
4225 );
4226 let id = app.snapshot().unwrap().workflows.workflows[0].workflow_id;
4227 let role = |role: &str, model: Option<&str>| RoleLaunch {
4228 role: role.into(),
4229 harness: HarnessId::Pi,
4230 model: model.map(str::to_owned),
4231 effort: model.map(|_| "high".to_owned()),
4232 yolo: true,
4233 };
4234 accepted(
4235 &app,
4236 Command::StartWorkflowRun {
4237 workflow_id: id,
4238 workflow_type: WorkflowTypeRef::Builtin(BuiltinType::Adversarial),
4239 prompt: "Task".into(),
4240 roles: vec![
4241 role("implementer", Some("local/m1")),
4242 role("reviewer", None),
4243 ],
4244 size: SIZE,
4245 },
4246 );
4247 let deadline = Instant::now() + Duration::from_secs(15);
4248 let arguments = loop {
4249 let found: Vec<String> = std::fs::read_dir(folder.path())
4250 .unwrap()
4251 .filter_map(Result::ok)
4252 .filter(|entry| entry.file_name().to_string_lossy().starts_with("args-"))
4253 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
4254 .collect();
4255 if let Some(found) = found.into_iter().next() {
4256 break found;
4257 }
4258 assert!(Instant::now() < deadline, "the implementer never started");
4259 thread::sleep(Duration::from_millis(10));
4260 };
4261 assert!(
4262 arguments.contains("--model local/m1 --thinking high "),
4263 "{arguments}"
4264 );
4265 }
4266
4267 fn submit_helper(app: &Application, id: WorkflowId) {
4268 submit_signal(app, id, br#"{"decision":"done","summary":"Finished"}"#);
4269 }
4270
4271 fn submit_signal(app: &Application, id: WorkflowId, signal: &[u8]) {
4272 use std::io::Write;
4273 use std::process::{Command as Process, Stdio};
4274 let command = app.run_processes.lock().unwrap()[&id]
4275 .inboxes
4276 .values()
4277 .next()
4278 .unwrap()
4279 .1
4280 .command();
4281 let mut child = Process::new("/bin/sh")
4282 .args(["-c", &command])
4283 .stdin(Stdio::piped())
4284 .stdout(Stdio::null())
4285 .stderr(Stdio::null())
4286 .spawn()
4287 .unwrap();
4288 child.stdin.take().unwrap().write_all(signal).unwrap();
4289 let deadline = Instant::now() + Duration::from_secs(2);
4290 loop {
4291 app.poll_workflow_signals().unwrap();
4292 let remains = app
4293 .run_processes
4294 .lock()
4295 .unwrap()
4296 .get(&id)
4297 .is_some_and(|processes| {
4298 processes
4299 .inboxes
4300 .values()
4301 .any(|(_, inbox)| inbox.command() == command)
4302 });
4303 if let Some(status) = child.try_wait().unwrap() {
4304 assert_eq!(status.success(), !remains);
4305 return;
4306 }
4307 assert!(Instant::now() < deadline, "completion wasn't processed");
4308 thread::sleep(Duration::from_millis(5));
4309 }
4310 }
4311
4312 #[test]
4313 fn a_completion_write_failure_reports_retry_and_cancellation_still_stops_agents() {
4314 let folder = tempfile::tempdir().unwrap();
4315 let bin = tempfile::tempdir().unwrap();
4316 let data = tempfile::tempdir().unwrap();
4317 let fixture = application(folder.path(), bin.path(), "sleep 60");
4318 let mut app = Application::with_config(data.path(), Config::default()).unwrap();
4319 app.harness_path.clone_from(&fixture.harness_path);
4320 accepted(
4321 &app,
4322 Command::OpenFolder {
4323 path: folder.path().to_owned(),
4324 },
4325 );
4326 let id = launch_legacy(&app, folder.path(), BuiltinType::Adversarial);
4327 let failure = fail_run_updates(data.path());
4328 let before = app.workflow_trace(id, None, 200).unwrap();
4329 submit_helper(&app, id);
4330 let snapshot = app.snapshot().unwrap();
4331 assert_eq!(app.workflow_trace(id, None, 200).unwrap(), before);
4332 assert_eq!(
4333 snapshot.workflows.workflows[0]
4334 .run
4335 .as_ref()
4336 .unwrap()
4337 .generation,
4338 1
4339 );
4340 assert_ne!(
4341 app.run_processes.lock().unwrap()[&id]
4342 .inboxes
4343 .values()
4344 .next()
4345 .unwrap()
4346 .1
4347 .response(),
4348 ""
4349 );
4350 failure
4351 .execute_batch("DROP TRIGGER reject_run_update")
4352 .unwrap();
4353 submit_helper(&app, id);
4354 let snapshot = app.snapshot().unwrap();
4355 let page = app.workflow_trace(id, None, 200).unwrap();
4356 assert_eq!(page.summary.span_count, before.summary.span_count + 1);
4357 let first = page
4358 .spans
4359 .iter()
4360 .find(|span| span.span_id == before.spans[0].span_id)
4361 .unwrap();
4362 assert_eq!(first.status, crate::TraceSpanStatus::Completed);
4363 let events = app.trace_events(first.span_id, None, 200).unwrap().events;
4364 assert_eq!(
4365 events
4366 .iter()
4367 .filter(|event| event.message.contains("Marked done:"))
4368 .count(),
4369 1
4370 );
4371 assert_eq!(
4372 snapshot.workflows.workflows[0]
4373 .run
4374 .as_ref()
4375 .unwrap()
4376 .generation,
4377 2
4378 );
4379 let _failure = fail_run_updates(data.path());
4380 accepted(&app, Command::CancelWorkflowRun { workflow_id: id });
4381 wait_for(&app, id, |w| {
4382 w.status == WorkflowStatus::Cancelled
4383 && app
4384 .lock_inner()
4385 .unwrap()
4386 .terminals
4387 .values()
4388 .all(|t| !matches!(t, TerminalStatus::Running))
4389 });
4390 }
4391
4392 #[test]
4393 fn a_custom_type_launch_pins_its_version_and_definition() {
4394 let folder = tempfile::tempdir().unwrap();
4395 let bin = tempfile::tempdir().unwrap();
4396 let app = application(folder.path(), bin.path(), FAKE);
4397 let original = BuiltinType::Adversarial.definition();
4398 let reference = WorkflowCatalog::new(app.lock_inner().unwrap().folders.store())
4399 .create(&original)
4400 .unwrap();
4401 let WorkflowTypeRef::User { type_id, .. } = reference else {
4402 panic!("custom reference")
4403 };
4404 let mut edited = original.clone();
4405 edited.name = "Edited type".into();
4406 WorkflowCatalog::new(app.lock_inner().unwrap().folders.store())
4407 .edit(type_id, &edited)
4408 .unwrap();
4409 accepted(
4410 &app,
4411 Command::CreateWorkflow {
4412 folder: folder.path().to_owned(),
4413 session_id: None,
4414 kind: WorkflowKind::Draft,
4415 roles: vec![],
4416 size: SIZE,
4417 },
4418 );
4419 let id = app.snapshot().unwrap().workflows.workflows[0].workflow_id;
4420 accepted(
4421 &app,
4422 Command::StartWorkflowRun {
4423 workflow_id: id,
4424 workflow_type: reference,
4425 prompt: "Task".into(),
4426 roles: original
4427 .roles
4428 .iter()
4429 .map(|role| RoleLaunch {
4430 model: None,
4431 effort: None,
4432 yolo: false,
4433 role: role.id.0.clone(),
4434 harness: HarnessId::Pi,
4435 })
4436 .collect(),
4437 size: SIZE,
4438 },
4439 );
4440 let workflow = wait_for(&app, id, |w| w.status == WorkflowStatus::Completed);
4441 let run = workflow.run.unwrap();
4442 assert_eq!(run.reference(), reference);
4443 assert_eq!(run.workflow_type.definition, original);
4444 }
4445
4446 #[test]
4449 #[ignore = "requires a real authenticated Codex; set TWINE_REAL_CODEX to its absolute path"]
4450 fn real_adversarial_workflow_completes() {
4451 let codex = std::env::var("TWINE_REAL_CODEX").expect("set TWINE_REAL_CODEX");
4452 let folder = tempfile::tempdir().unwrap();
4453 let bin = tempfile::tempdir().unwrap();
4454 let wrapper = bin.path().join("codex");
4455 let quoted = codex.replace('\'', "'\\''");
4456 std::fs::write(&wrapper, format!(
4457 "#!/bin/sh\nexec '{quoted}' exec --ephemeral --skip-git-repo-check --sandbox workspace-write --ignore-user-config \"$@\"\n"
4458 )).unwrap();
4459 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
4460 let app = application(folder.path(), bin.path(), "exit 1");
4461 accepted(
4462 &app,
4463 Command::CreateWorkflow {
4464 folder: folder.path().to_owned(),
4465 session_id: None,
4466 kind: WorkflowKind::Draft,
4467 roles: vec![],
4468 size: SIZE,
4469 },
4470 );
4471 let id = app.snapshot().unwrap().workflows.workflows[0].workflow_id;
4472 accepted(&app, Command::StartWorkflowRun { workflow_id: id,
4473 workflow_type: WorkflowTypeRef::Builtin(BuiltinType::Adversarial),
4474 prompt: "This is a small smoke test. Implementer: create proof.txt containing exactly 'Twine workflow works' followed by a newline, then submit completion using the supplied command. Reviewer: read proof.txt, approve if correct, and submit the explicit review decision. Do not use git, install tools, or change anything else. Execute the completion command; do not merely describe it.".into(),
4475 roles: vec![RoleLaunch { role: "implementer".into(), harness: HarnessId::Codex, model: None, effort: None, yolo: false },
4476 RoleLaunch { role: "reviewer".into(), harness: HarnessId::Codex, model: None, effort: None, yolo: false }], size: SIZE });
4477 let deadline = Instant::now() + Duration::from_secs(240);
4478 let mut terminal_output = Vec::new();
4479 loop {
4480 while let Some(chunk) = app.next_terminal_chunk().unwrap() {
4481 terminal_output.extend(chunk.bytes);
4482 }
4483 let snapshot = app.snapshot().unwrap();
4484 let workflow = &snapshot.workflows.workflows[0];
4485 if workflow.status == WorkflowStatus::Completed {
4486 break;
4487 }
4488 if Instant::now() >= deadline || workflow.status != WorkflowStatus::Running {
4489 let log = std::env::temp_dir().join("twine-real-adversarial-output.txt");
4490 std::fs::write(&log, terminal_output).unwrap();
4491 panic!("real run didn't complete; inspect {}", log.display());
4492 }
4493 thread::sleep(Duration::from_millis(50));
4494 }
4495 assert_eq!(
4496 std::fs::read_to_string(folder.path().join("proof.txt")).unwrap(),
4497 "Twine workflow works\n"
4498 );
4499 }
4500}