Skip to main content

handshake_core/application/
runs.rs

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    /// Accepted helpers keep their receipt until they consume it; their harness remains alive.
29    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            // Manual completion can advance while the old turn is still working. A prompt
110            // attributed to this assignment activates its helper after older turns finish.
111            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            // Normally one receipt per live agent is outstanding. Bound abandoned helpers too.
157            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        // A stored choice that no longer validates starts with the harness's defaults.
180        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    /// Existing interactive agents receive the next assignment in their terminal.
218    continuation: Option<String>,
219    memory_environment: Vec<(String, String)>,
220    memory_lease: Option<std::fs::File>,
221}
222
223impl Application {
224    /// Lists built-in and stored custom types for launch forms.
225    ///
226    /// # Errors
227    /// Returns an error when the catalog cannot be read.
228    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    /// Resume only a fully identified interrupted stage. Finished roles keep their completion,
342    /// while every unfinished role receives a fresh completion inbox and the same conversation.
343    #[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        // Reserve every transcript outside application state locks; storage can wait on disk.
498        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                        // Task history may quote another role's command. Like continuations,
643                        // initial assignments end with the recipient's current command.
644                        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        // PTY writes can wait for the harness to read. Keep them outside application state locks.
859        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                // Parallel senders can finish before this completion advances the stage. Their
1031                // handoffs retain each sender's own completion boundary, even after it exits.
1032                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        // A harness can block its writer. Wait without holding application or registry locks,
1163        // then revalidate the revision while owning the writer used by mode cleanup.
1164        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        // Wait for slow PTY writes without holding application state. Sorting provides a
1435        // consistent order for concurrent changes; revalidate only after owning the writers.
1436        let mut inputs = writers
1437            .iter()
1438            .map(|(_, input)| input.lock().map_err(|_| ApplicationError::Poisoned))
1439            .collect::<Result<Vec<_>, _>>()?;
1440        // Match stage launch's lock order. Hold the registry through persistence and cleanup so
1441        // a new continuation cannot install routing that this mode change then deactivates.
1442        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        // Synchronize with user writes before publishing the new mode. Clearing review feedback
1468        // preserves any unfinished user draft and prevents old feedback reaching a private turn.
1469        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    /// The bridge polls events off the UI thread. Polling also services bounded completion
1540    /// mailboxes, so terminal output (including an idle or exited process) cannot starve signals.
1541    pub(super) fn poll_workflow_signals(&self) -> Result<(), ApplicationError> {
1542        // Snapshot/event reads must stay available while a lifetime command waits on transcript
1543        // storage. The next idle poll will service these mailboxes.
1544        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                // A review decision can finish a stage while another participant's submission
1605                // is already in this batch. Never deliver its stale rejection to a new invocation.
1606                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                // Commit sticky private routing before any late hook can reactivate the wrapper.
1724                *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 &current.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    // A real PTY harness process invokes the same completion helper an installed harness uses.
2349    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        // The missing old Stop must not stall the new assignment: its private path is safe.
2692        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        // Exercise fallback when an observer cannot be installed/retained, independently of
2992        // which built-in harness currently supports observation.
2993        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            // A blocked PTY write owns this same mutex. No state lock may be held while waiting.
3061            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        // Final review feedback deferred behind an unfinished draft must not leak into a
3100        // private turn, including delivery queued just before the mode change.
3101        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, &current, 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        // Saving the same accepted state again never duplicates events or invocation spans.
3683        // Hold the store lock so late harness hooks cannot change the revision between reads.
3684        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        // Observation includes all output accepted before completion and the resize boundary.
3915        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        // Use its test harness with a persistent database.
4139        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        // A rejected PTY size fails before the launcher can create a process.
4329        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        // Each agent records its launch prompt, the last argument, in its own file, written whole.
4408        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        // A completion without the task goes back to the agent to correct.
4434        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        // Each agent records its arguments, written whole, in its own file.
4469        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    /// Opt-in smoke test: uses an authenticated, installed Codex in a disposable folder. The wrapper
4705    /// selects its noninteractive mode so unattended verification doesn't need a trust-dialog click.
4706    #[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}