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