1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use tracing::{debug, warn};
6
7use super::{Application, ApplicationError, CommandDisposition, Inner, RequestId, rejection};
8use crate::event::{CommandResult, EventKind, StateEvent};
9use crate::terminal::{
10 TerminalChunk, TerminalExit, TerminalId, TerminalObservation, TerminalSize, TerminalStatus,
11 TranscriptRead,
12};
13use crate::workflow::{Workflow, WorkflowKind, WorkflowStatus};
14
15impl Application {
16 pub(super) fn start_terminal_command(
18 &self,
19 request_id: RequestId,
20 working_directory: &Path,
21 size: TerminalSize,
22 ) -> Result<CommandDisposition, ApplicationError> {
23 let reserved = match self.terminals.reserve_terminal() {
24 Ok(id) => id,
25 Err(error) => return Ok(rejection("terminalStartFailed", &error)),
26 };
27 let mut inner = self.lock_inner()?;
30 let terminal_id = match self.start_terminal(reserved, working_directory, size, false) {
31 Ok(terminal_id) => terminal_id,
32 Err(error) => return Ok(rejection("terminalStartFailed", &error)),
33 };
34 inner.terminals.insert(terminal_id, TerminalStatus::Running);
35 if let Err(error) = inner.events.append(EventKind::CommandCompleted {
36 request_id,
37 result: CommandResult::TerminalStarted { terminal_id },
38 }) {
39 inner.terminals.remove(&terminal_id);
40 drop(inner);
41 let _ = self.terminals.close(terminal_id);
42 return Err(error.into());
43 }
44 debug!(
45 request_id = request_id.0,
46 terminal_id = terminal_id.value(),
47 "terminal started"
48 );
49 Ok(CommandDisposition::Accepted)
50 }
51
52 pub(super) fn close_terminal_command(
54 &self,
55 request_id: RequestId,
56 terminal_id: TerminalId,
57 ) -> Result<CommandDisposition, ApplicationError> {
58 {
59 let mut inner = self.lock_inner()?;
60 if inner
61 .workflows
62 .workflows
63 .iter()
64 .any(|workflow| workflow.terminal_ids().contains(&terminal_id))
65 {
66 return Ok(CommandDisposition::Rejected {
67 code: "terminalOwnedByWorkflow".to_owned(),
68 message: "Close the workflow to close its terminal.".to_owned(),
69 });
70 }
71 inner.terminals.remove(&terminal_id);
72 }
73 if let Err(error) = self.terminals.close(terminal_id) {
74 return Ok(rejection("terminalCloseFailed", &error));
75 }
76 let mut inner = self.lock_inner()?;
77 inner.events.append(EventKind::CommandCompleted {
78 request_id,
79 result: CommandResult::TerminalClosed { terminal_id },
80 })?;
81 debug!(
82 request_id = request_id.0,
83 terminal_id = terminal_id.value(),
84 "terminal closed"
85 );
86 Ok(CommandDisposition::Accepted)
87 }
88
89 pub fn next_terminal_chunk(&self) -> Result<Option<TerminalChunk>, ApplicationError> {
95 Ok(self.terminal_output.next_chunk()?)
96 }
97
98 pub fn read_terminal_transcript(
110 &self,
111 terminal_id: TerminalId,
112 offset: u64,
113 limit: usize,
114 ) -> Result<TranscriptRead, ApplicationError> {
115 Ok(self
116 .terminal_output
117 .read_transcript(terminal_id, offset, limit)?)
118 }
119
120 pub fn request_terminal_transcript(
126 &self,
127 terminal_id: TerminalId,
128 offset: u64,
129 limit: usize,
130 ) -> Result<Option<crate::TranscriptRequest>, ApplicationError> {
131 Ok(self
132 .terminal_output
133 .request_transcript(terminal_id, offset, limit)?)
134 }
135
136 pub fn write_terminal_input(
142 &self,
143 terminal_id: TerminalId,
144 bytes: &[u8],
145 ) -> Result<(), ApplicationError> {
146 if let Some(input) = self.workflow_agent_input(terminal_id)? {
147 let mut input = input.lock().map_err(|_| ApplicationError::Poisoned)?;
148 let submission = if input.will_submit(bytes) {
149 self.note_workflow_submission(terminal_id)?
150 } else {
151 None
152 };
153 if let Err(error) = input.write(&self.terminals, terminal_id, bytes) {
154 self.cancel_workflow_submission(submission)?;
155 return Err(error.into());
156 }
157 return Ok(());
158 }
159 Ok(self.terminals.write_input(terminal_id, bytes)?)
160 }
161
162 pub fn write_terminal_response(
167 &self,
168 terminal_id: TerminalId,
169 bytes: &[u8],
170 ) -> Result<(), ApplicationError> {
171 Ok(self.terminals.write_input(terminal_id, bytes)?)
172 }
173
174 pub fn resize_terminal(
180 &self,
181 terminal_id: TerminalId,
182 size: TerminalSize,
183 ) -> Result<(), ApplicationError> {
184 Ok(self.terminals.resize(terminal_id, size)?)
185 }
186
187 pub(super) fn start_terminal(
188 &self,
189 terminal_id: TerminalId,
190 working_directory: &Path,
191 size: TerminalSize,
192 integrate: bool,
193 ) -> Result<TerminalId, ApplicationError> {
194 Ok(self.terminals.start_default_shell(
195 terminal_id,
196 working_directory,
197 size,
198 Arc::new(self.exit_callback()),
199 integrate,
200 )?)
201 }
202
203 pub(super) fn exit_callback(
205 &self,
206 ) -> impl Fn(TerminalId, Result<TerminalExit, String>, TerminalObservation) + Send + Sync + 'static
207 {
208 let inner = Arc::downgrade(&self.inner);
209 let output = Arc::clone(&self.terminal_output);
210 move |terminal_id, result, observation| {
211 let Some(inner) = inner.upgrade() else { return };
212 let Ok(mut inner) = inner.lock() else {
213 warn!(
214 terminal_id = terminal_id.value(),
215 "application state lock poisoned after terminal exit"
216 );
217 return;
218 };
219 inner.record_shell_observations(Vec::new());
220 if inner.pending_shell_marks.is_empty()
221 && let Ok(observations) = output.take_shell_observations()
222 {
223 inner.record_shell_observations(observations);
224 }
225 inner.record_terminal_exit_at(terminal_id, result, observation);
226 inner.finish_shell_process_endings(&output);
227 }
228 }
229}
230
231impl Inner {
232 #[cfg(test)]
235 pub(super) fn record_terminal_exit(
236 &mut self,
237 terminal_id: TerminalId,
238 result: Result<TerminalExit, String>,
239 ) {
240 self.record_terminal_exit_at(
241 terminal_id,
242 result,
243 TerminalObservation {
244 integrated_shell: false,
245 observed_at: crate::workflow::timestamp(),
246 byte_offset: 0,
247 boundary_sizes: Some(Vec::new()),
248 },
249 );
250 }
251
252 pub(super) fn record_terminal_exit_at(
253 &mut self,
254 terminal_id: TerminalId,
255 result: Result<TerminalExit, String>,
256 observation: TerminalObservation,
257 ) {
258 if self.terminals.get(&terminal_id) != Some(&TerminalStatus::Running) {
259 return;
260 }
261 let (status, event) = match result {
262 Ok(exit) => (
263 TerminalStatus::Exited(exit.clone()),
264 StateEvent::TerminalExited { terminal_id, exit },
265 ),
266 Err(message) => (
267 TerminalStatus::Failed {
268 message: message.clone(),
269 },
270 StateEvent::TerminalFailed {
271 terminal_id,
272 message,
273 },
274 ),
275 };
276 let (trace_status, trace_kind, trace_message) = match &status {
277 TerminalStatus::Exited(exit) => (
278 crate::TraceSpanStatus::Exited,
279 crate::TraceEventKind::ProcessExited,
280 match &exit.signal {
281 Some(signal) => format!("Process exited with signal {signal}."),
282 None => format!("Process exited with code {}.", exit.exit_code),
283 },
284 ),
285 TerminalStatus::Failed { message } => (
286 crate::TraceSpanStatus::Failed,
287 crate::TraceEventKind::ProcessFailed,
288 format!("Process failed: {message}"),
289 ),
290 TerminalStatus::Running => return,
291 };
292 let observed_at = observation.observed_at;
293 if !self.defer_shell_process_ending(
294 terminal_id,
295 &observation,
296 trace_status,
297 trace_kind,
298 &trace_message,
299 ) && let Err(error) = self.end_trace(
300 terminal_id,
301 observation,
302 trace_status,
303 trace_kind,
304 &trace_message,
305 ) {
306 warn!(%error, "failed to persist process trace");
307 }
308 let agent_status = match &status {
309 TerminalStatus::Failed { .. } => crate::RunAgentStatus::Failed,
310 _ => crate::RunAgentStatus::Exited,
311 };
312 self.terminals.insert(terminal_id, status);
313 self.record_run_agent_exit(terminal_id, agent_status);
314 if let Some(workflow) = self.workflows.workflows.iter_mut().find(|workflow| {
315 workflow.status == WorkflowStatus::Running
316 && workflow.terminal_ids().contains(&terminal_id)
317 }) && let Some(status) = ended_status(workflow, &self.terminals)
318 {
319 workflow.status = status;
322 workflow.ended_at = Some(observed_at.max(workflow.started_at));
323 if workflow.kind == WorkflowKind::SingleAgent {
324 if let Err(error) = self
325 .folders
326 .store()
327 .update_agent_status(workflow.workflow_id, workflow.status)
328 {
329 warn!(%error, "failed to record the agent's exit");
330 }
331 tracing::info!(
332 workflow_id = workflow.workflow_id.0,
333 status = ?workflow.status,
334 "agent exited"
335 );
336 }
337 let workflow = workflow.clone();
338 if let Err(error) = self
339 .events
340 .append(EventKind::State(StateEvent::WorkflowChanged(workflow)))
341 {
342 warn!(%error, "failed to record workflow exit");
343 }
344 }
345 if let Err(error) = self.events.append(EventKind::State(event)) {
346 warn!(terminal_id = terminal_id.value(), %error, "failed to record terminal exit");
347 }
348 }
349
350 fn record_run_agent_exit(
351 &mut self,
352 terminal_id: TerminalId,
353 agent_status: crate::RunAgentStatus,
354 ) {
355 if let Some(index) = self.workflows.workflows.iter().position(|workflow| {
356 workflow.run.is_some() && workflow.agents.iter().any(|a| a.terminal_id == terminal_id)
357 }) {
358 let mut workflow = self.workflows.workflows[index].clone();
359 let id = workflow
360 .agents
361 .iter()
362 .find(|a| a.terminal_id == terminal_id)
363 .expect("terminal belongs to workflow")
364 .agent_id
365 .0;
366 let run = workflow.run.as_mut().expect("workflow has a run");
367 if let Some(agent) = run.agents.iter_mut().find(|a| a.agent_id == id)
368 && agent.status == crate::RunAgentStatus::Running
369 {
370 agent.status = agent_status;
371 if let Err(error) = self.publish_run_best_effort(index, workflow) {
373 warn!(%error, "failed to record workflow agent exit");
374 }
375 }
376 }
377 }
378}
379
380fn ended_status(
383 workflow: &Workflow,
384 terminals: &HashMap<TerminalId, TerminalStatus>,
385) -> Option<WorkflowStatus> {
386 if workflow.run.is_some() {
387 return None;
388 }
389 let mut failed = false;
390 for terminal_id in workflow.shells() {
391 match terminals.get(&terminal_id) {
392 Some(TerminalStatus::Running) => return None,
393 Some(TerminalStatus::Exited(_)) => {}
394 Some(TerminalStatus::Failed { .. }) | None => failed = true,
395 }
396 }
397 Some(if failed {
398 WorkflowStatus::Failed
399 } else {
400 WorkflowStatus::Exited
401 })
402}
403
404#[cfg(test)]
405mod tests {
406 use std::sync::mpsc;
407 use std::thread;
408 use std::time::{Duration, Instant};
409
410 use super::*;
411 use crate::Command;
412 use crate::config::Config;
413 use crate::workflow::{Agent, AgentId, SessionId, WorkflowId, WorkflowKind};
414
415 fn workflow(terminal_id: u64, agent_terminal_ids: &[u64]) -> Workflow {
416 Workflow {
417 workflow_id: WorkflowId(1),
418 session_id: SessionId(1),
419 name: "Agents".to_owned(),
420 kind: if agent_terminal_ids.is_empty() {
421 WorkflowKind::Terminal
422 } else {
423 WorkflowKind::Agents
424 },
425 harness: None,
426 terminal_id: TerminalId::from_value(terminal_id),
427 agents: agent_terminal_ids
428 .iter()
429 .zip(1..)
430 .map(|(terminal_id, agent_id)| Agent {
431 agent_id: AgentId(agent_id),
432 role: "Worker".to_owned(),
433 terminal_id: TerminalId::from_value(*terminal_id),
434 })
435 .collect(),
436 status: WorkflowStatus::Running,
437 started_at: 0,
438 ended_at: None,
439 restored: false,
440 terminal_history: Vec::new(),
441 run: None,
442 }
443 }
444
445 #[test]
446 fn a_workflow_ends_after_all_of_its_shells_and_fails_if_any_did() {
447 let exited = TerminalStatus::Exited(TerminalExit {
448 exit_code: 0,
449 signal: None,
450 });
451 let failed = TerminalStatus::Failed {
452 message: "wait failed".to_owned(),
453 };
454 let terminals = HashMap::from([
455 (TerminalId::from_value(1), TerminalStatus::Running),
456 (TerminalId::from_value(2), exited.clone()),
457 (TerminalId::from_value(3), exited),
458 (TerminalId::from_value(4), failed),
459 ]);
460 assert_eq!(ended_status(&workflow(1, &[]), &terminals), None);
461 assert_eq!(
462 ended_status(&workflow(2, &[]), &terminals),
463 Some(WorkflowStatus::Exited)
464 );
465 assert_eq!(
466 ended_status(&workflow(4, &[]), &terminals),
467 Some(WorkflowStatus::Failed)
468 );
469 assert_eq!(ended_status(&workflow(0, &[2, 1]), &terminals), None);
470 assert_eq!(
471 ended_status(&workflow(0, &[2, 3]), &terminals),
472 Some(WorkflowStatus::Exited)
473 );
474 assert_eq!(
475 ended_status(&workflow(0, &[2, 4]), &terminals),
476 Some(WorkflowStatus::Failed)
477 );
478 assert_eq!(
480 ended_status(&workflow(0, &[0, 3]), &terminals),
481 Some(WorkflowStatus::Failed)
482 );
483 }
484
485 fn size() -> TerminalSize {
486 TerminalSize {
487 rows: 24,
488 columns: 80,
489 pixel_width: 0,
490 pixel_height: 0,
491 }
492 }
493
494 fn start(app: &Application, folder: &Path, request: u64) -> TerminalId {
495 let receipt = app
496 .handle_command(
497 RequestId(request),
498 Command::StartTerminal {
499 working_directory: folder.into(),
500 size: size(),
501 },
502 )
503 .unwrap();
504 assert_eq!(receipt.disposition, CommandDisposition::Accepted);
505 app.snapshot()
506 .unwrap()
507 .terminals
508 .last()
509 .unwrap()
510 .terminal_id
511 }
512
513 #[test]
514 fn application_reads_closed_transcripts_after_relaunch_without_reusing_ids() {
515 let data = tempfile::tempdir().unwrap();
516 let folder = tempfile::tempdir().unwrap();
517 let app = Application::with_config(data.path(), Config::default()).unwrap();
518 let id = start(&app, folder.path(), 1);
519 let command = [
520 br"printf '\377\000\033[H__TWINE_HISTORY__\n'".as_slice(),
521 b"\n",
522 ]
523 .concat();
524 app.write_terminal_input(id, &command).unwrap();
525 let deadline = Instant::now() + Duration::from_secs(3);
526 let bytes = loop {
527 while app.next_terminal_chunk().unwrap().is_some() {}
528 let TranscriptRead::Output(page) =
529 app.read_terminal_transcript(id, 0, 64 * 1024).unwrap()
530 else {
531 panic!("new terminal history should be retained");
532 };
533 if page
534 .bytes
535 .windows(5)
536 .any(|bytes| bytes == [0xff, 0, 0x1b, b'[', b'H'])
537 {
538 break page.bytes;
539 }
540 assert!(
541 Instant::now() < deadline,
542 "shell did not produce binary history"
543 );
544 thread::sleep(Duration::from_millis(5));
545 };
546 app.handle_command(RequestId(2), Command::CloseTerminal { terminal_id: id })
547 .unwrap();
548 let expected = app
549 .read_terminal_transcript(id, 1, bytes.len() - 1)
550 .unwrap();
551 drop(app);
552 let app = Application::with_config(data.path(), Config::default()).unwrap();
553 assert_eq!(
554 app.read_terminal_transcript(id, 1, bytes.len() - 1)
555 .unwrap(),
556 expected
557 );
558 assert!(start(&app, folder.path(), 3).value() > id.value());
559 }
560
561 #[test]
562 fn terminal_and_workflow_allocation_wait_without_holding_application_state() {
563 for workflow in [
564 None,
565 Some(WorkflowKind::Terminal),
566 Some(WorkflowKind::Agents),
567 ] {
568 let data = tempfile::tempdir().unwrap();
569 let folder = tempfile::tempdir().unwrap();
570 let app = Arc::new(Application::with_config(data.path(), Config::default()).unwrap());
571 app.handle_command(
572 RequestId(1),
573 Command::OpenFolder {
574 path: folder.path().into(),
575 },
576 )
577 .unwrap();
578 let id = start(&app, folder.path(), 2);
579 let stalled = app.terminal_output.stall_recording_worker(id);
580 let command = if let Some(kind) = workflow {
581 Command::CreateWorkflow {
582 folder: folder.path().into(),
583 session_id: None,
584 kind,
585 roles: if kind == WorkflowKind::Agents {
586 vec!["Implementer".into(), "Reviewer".into()]
587 } else {
588 Vec::new()
589 },
590 size: size(),
591 }
592 } else {
593 Command::StartTerminal {
594 working_directory: folder.path().into(),
595 size: size(),
596 }
597 };
598 let (sent, result) = mpsc::sync_channel(1);
599 let starting = Arc::clone(&app);
600 let starter = thread::spawn(move || {
601 sent.send(starting.handle_command(RequestId(3), command))
602 .unwrap();
603 });
604 assert_state_available_during_allocation(&app);
605 assert!(matches!(result.try_recv(), Err(mpsc::TryRecvError::Empty)));
606 drop(stalled);
607 assert_eq!(
608 result
609 .recv_timeout(Duration::from_secs(3))
610 .unwrap()
611 .unwrap()
612 .disposition,
613 CommandDisposition::Accepted
614 );
615 starter.join().unwrap();
616 }
617 }
618
619 #[test]
620 fn workflow_restoration_waits_without_holding_application_state() {
621 for kind in [WorkflowKind::Terminal, WorkflowKind::Agents] {
622 let data = tempfile::tempdir().unwrap();
623 let folder = tempfile::tempdir().unwrap();
624 let app = Arc::new(Application::with_config(data.path(), Config::default()).unwrap());
625 app.handle_command(
626 RequestId(1),
627 Command::OpenFolder {
628 path: folder.path().into(),
629 },
630 )
631 .unwrap();
632 app.handle_command(
633 RequestId(2),
634 Command::CreateWorkflow {
635 folder: folder.path().into(),
636 session_id: None,
637 kind,
638 roles: if kind == WorkflowKind::Agents {
639 vec!["Implementer".into(), "Reviewer".into()]
640 } else {
641 Vec::new()
642 },
643 size: size(),
644 },
645 )
646 .unwrap();
647 let id = app.snapshot().unwrap().workflows.workflows[0].terminal_ids()[0];
648 let stalled = app.terminal_output.stall_recording_worker(id);
649 let restoring = Arc::clone(&app);
650 let restore = thread::spawn(move || restoring.restore_workflows().unwrap());
651 assert_state_available_during_allocation(&app);
652 drop(stalled);
653 restore.join().unwrap();
654 }
655 }
656
657 fn assert_state_available_during_allocation(app: &Arc<Application>) {
658 let deadline = Instant::now() + Duration::from_secs(2);
659 while !app.terminal_output.allocation_is_pending() {
660 assert!(
661 Instant::now() < deadline,
662 "allocation did not reach the stalled worker"
663 );
664 thread::yield_now();
665 }
666 let (sent, received) = mpsc::sync_channel(1);
667 let observing = Arc::clone(app);
668 let observer = thread::spawn(move || {
669 let snapshot = observing.snapshot().unwrap();
670 observing.events_after(snapshot.sequence, 8).unwrap();
671 sent.send(()).unwrap();
672 });
673 received
674 .recv_timeout(Duration::from_secs(1))
675 .expect("state must remain available while storage is stalled");
676 observer.join().unwrap();
677 }
678}