Skip to main content

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