Skip to main content

twine_core/application/
agents.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use tracing::{info, warn};
5
6use super::workflows::reject;
7use super::{Application, ApplicationError, CommandDisposition, RequestId, rejection};
8use crate::event::{CommandResult, EventKind, StateEvent};
9use crate::harness::launch::validate_options;
10use crate::harness::{
11    HarnessDefinition, HarnessError, HarnessId, LocatedHarness, LoginPath, locate_in,
12};
13use crate::terminal::{TerminalSize, TerminalStatus};
14use crate::workflow::{WorkflowId, WorkflowKind, WorkflowStatus, timestamp};
15
16/// Finds a harness binary on `test_path` alone when tests give one, otherwise on the login
17/// shell's `PATH` and then the app's.
18fn locate(
19    definition: &HarnessDefinition,
20    test_path: Option<&std::ffi::OsStr>,
21    login_path: &LoginPath,
22) -> Result<LocatedHarness, HarnessError> {
23    if let Some(path) = test_path {
24        return locate_in(definition, Some(path), None);
25    }
26    // UI tests point this at a stub harness, like `TWINE_DATA_DIRECTORY` points at test data.
27    if let Some(path) = std::env::var_os("TWINE_HARNESS_PATH") {
28        return locate_in(definition, Some(&path), None);
29    }
30    locate_in(
31        definition,
32        login_path.get().as_deref(),
33        std::env::var_os("PATH").as_deref(),
34    )
35}
36
37impl Application {
38    /// Finds a harness binary. The login shell's `PATH` is looked up once in the background, so
39    /// this only waits on that at the first start after launch.
40    pub(super) fn locate_harness(
41        &self,
42        definition: &HarnessDefinition,
43    ) -> Result<LocatedHarness, HarnessError> {
44        #[cfg(test)]
45        let test_path = self.harness_path.as_deref();
46        #[cfg(not(test))]
47        let test_path = None;
48        locate(definition, test_path, &self.login_path)
49    }
50
51    /// Starts listing the models `harness` offers, read from its own CLI on another thread, so a
52    /// slow harness never holds up commands or terminals. Poll the request for the result.
53    pub fn request_harness_models(
54        &self,
55        harness: HarnessId,
56        folder: Option<PathBuf>,
57    ) -> crate::ModelListRequest {
58        #[cfg(test)]
59        let test_path = self.harness_path.clone();
60        #[cfg(not(test))]
61        let test_path: Option<std::ffi::OsString> = None;
62        let login_path = Arc::clone(&self.login_path);
63        self.model_lists.request(harness, folder, move || {
64            locate(harness.definition(), test_path.as_deref(), &login_path)
65        })
66    }
67
68    /// The folder of a draft workflow's session, or the rejection to send if it isn't a draft.
69    pub(super) fn draft_folder(
70        &self,
71        workflow_id: WorkflowId,
72    ) -> Result<Result<PathBuf, CommandDisposition>, ApplicationError> {
73        let inner = self.lock_inner()?;
74        let Some(workflow) = inner
75            .workflows
76            .workflows
77            .iter()
78            .find(|workflow| workflow.workflow_id == workflow_id)
79        else {
80            return Ok(Err(reject(
81                "workflowNotFound",
82                "The workflow is no longer open.",
83            )));
84        };
85        if workflow.kind != WorkflowKind::Draft {
86            return Ok(Err(reject(
87                "workflowNotDraft",
88                "The workflow is already configured.",
89            )));
90        }
91        let session = inner
92            .workflows
93            .sessions
94            .iter()
95            .find(|session| session.session_id == workflow.session_id);
96        Ok(session
97            .map(|session| session.folder.clone())
98            .ok_or_else(|| reject("workflowNotFound", "The workflow is no longer open.")))
99    }
100
101    /// Turns a draft workflow into a single agent: starts the harness in its own PTY, with
102    /// `prompt` if there is one, then replaces the draft's placeholder shell with it.
103    #[expect(
104        clippy::too_many_lines,
105        reason = "agent replacement keeps process, history, and trace cleanup together"
106    )]
107    pub(super) fn start_agent(
108        &self,
109        request_id: RequestId,
110        workflow_id: WorkflowId,
111        harness: HarnessId,
112        (model, effort, yolo): (Option<&str>, Option<&str>, bool),
113        prompt: &str,
114        size: TerminalSize,
115    ) -> Result<CommandDisposition, ApplicationError> {
116        let prompt = prompt.trim();
117        let Some(options) = validate_options(harness, model, effort, yolo) else {
118            return Ok(reject(
119                "invalidModel",
120                "Choose a model and effort from the lists, or type one model name.",
121            ));
122        };
123        let folder = match self.draft_folder(workflow_id)? {
124            Ok(folder) => folder,
125            Err(rejected) => return Ok(rejected),
126        };
127
128        let definition = harness.definition();
129        let located = match self.locate_harness(definition) {
130            Ok(located) => located,
131            Err(error) => return Ok(rejection("harnessNotFound", &error)),
132        };
133
134        // Transcript allocation may wait on disk; lifetime commands keep this draft valid while
135        // application state remains available. Hold state again for startup and exit-event order.
136        let reserved = match self.terminals.reserve_terminal() {
137            Ok(id) => id,
138            Err(error) => return Ok(rejection("agentStartFailed", &error)),
139        };
140        let (arguments, hooks) = self.harness_arguments(harness, reserved, options, prompt);
141        let mut inner = self.lock_inner()?;
142        // Start the agent at the size its draft terminal has now, as the view has already fitted it.
143        let placeholder_size = inner
144            .workflows
145            .workflows
146            .iter()
147            .find(|workflow| workflow.workflow_id == workflow_id)
148            .and_then(|workflow| self.terminals.size(workflow.terminal_id))
149            .unwrap_or(size);
150        let terminal_id = match self.terminals.start_program(
151            reserved,
152            &folder,
153            &located.program,
154            &arguments,
155            &located.path,
156            &crate::harness::launch::observed_environment(harness, hooks.as_ref()),
157            placeholder_size,
158            Arc::new(self.exit_callback()),
159        ) {
160            Ok(terminal_id) => terminal_id,
161            Err(error) => return Ok(rejection("agentStartFailed", &error)),
162        };
163        let Some(index) = inner
164            .workflows
165            .workflows
166            .iter()
167            .position(|workflow| workflow.workflow_id == workflow_id)
168        else {
169            drop(inner);
170            let _ = self.terminals.close(terminal_id);
171            return Ok(reject(
172                "workflowNotFound",
173                "The workflow is no longer open.",
174            ));
175        };
176        let mut workflow = inner.workflows.workflows[index].clone();
177        let placeholder = workflow.terminal_id;
178        let name = definition.name;
179        workflow.kind = WorkflowKind::SingleAgent;
180        workflow.restored = false;
181        workflow.terminal_history.clear();
182        workflow.harness = Some(harness);
183        name.clone_into(&mut workflow.name);
184        workflow.terminal_id = terminal_id;
185        workflow.status = WorkflowStatus::Running;
186        workflow.started_at = timestamp();
187        workflow.ended_at = None;
188        if let Err(error) = inner.start_agent_trace(
189            &workflow,
190            harness,
191            placeholder,
192            self.terminals.observe(placeholder).ok(),
193            None,
194        ) {
195            drop(inner);
196            let _ = self.terminals.close(terminal_id);
197            return Err(error);
198        }
199        inner.terminals.remove(&placeholder);
200        inner.terminals.insert(terminal_id, TerminalStatus::Running);
201        inner.workflows.workflows[index] = workflow.clone();
202        inner
203            .events
204            .append(EventKind::State(StateEvent::WorkflowChanged(workflow)))?;
205        inner.events.append(EventKind::CommandCompleted {
206            request_id,
207            result: CommandResult::AgentStarted { workflow_id },
208        })?;
209        drop(inner);
210        if let Some(hooks) = hooks
211            && let Err(error) = self.register_harness_steps(terminal_id, workflow_id, true, hooks)
212        {
213            warn!(%error, "couldn't register harness step hooks");
214        }
215        info!(
216            workflow_id = workflow_id.0,
217            terminal_id = terminal_id.value(),
218            harness = name,
219            "agent started"
220        );
221        if placeholder.value() != 0 {
222            // Return promptly so the UI can answer the agent's startup terminal queries before
223            // they time out. Reaping the old shell includes a hang-up grace period.
224            let _ = self.terminals.close_in_background(placeholder);
225        }
226        Ok(CommandDisposition::Accepted)
227    }
228
229    /// Stops a running agent's process. The workflow stays open as cancelled, with its output.
230    pub(super) fn cancel_agent(
231        &self,
232        request_id: RequestId,
233        workflow_id: WorkflowId,
234    ) -> Result<CommandDisposition, ApplicationError> {
235        let terminal_id = {
236            let mut inner = self.lock_inner()?;
237            let Some(index) = inner
238                .workflows
239                .workflows
240                .iter()
241                .position(|workflow| workflow.workflow_id == workflow_id)
242            else {
243                return Ok(reject(
244                    "workflowNotFound",
245                    "The workflow is no longer open.",
246                ));
247            };
248            let mut workflow = inner.workflows.workflows[index].clone();
249            if workflow.kind != WorkflowKind::SingleAgent
250                || workflow.status != WorkflowStatus::Running
251            {
252                return Ok(reject("agentNotRunning", "The agent isn't running."));
253            }
254            if let Ok(observation) = self.terminals.observe(workflow.terminal_id)
255                && let Err(error) = inner.stop_trace(
256                    workflow.terminal_id,
257                    observation,
258                    "Process stopped when its agent was cancelled.",
259                )
260            {
261                warn!(%error, "failed to record the cancelled agent's trace");
262            }
263            workflow.status = WorkflowStatus::Cancelled;
264            workflow.ended_at = Some(timestamp().max(workflow.started_at));
265            if let Err(error) = inner
266                .folders
267                .store()
268                .update_agent_status(workflow_id, WorkflowStatus::Cancelled)
269            {
270                warn!(%error, "failed to record the cancelled agent");
271            }
272            let terminal_id = workflow.terminal_id;
273            inner.workflows.workflows[index] = workflow.clone();
274            inner
275                .events
276                .append(EventKind::State(StateEvent::WorkflowChanged(workflow)))?;
277            terminal_id
278        };
279        // The exit callback keeps the cancelled status, so the process ending isn't reported as
280        // a crash or as finished work.
281        if let Err(error) = self.terminals.terminate(terminal_id) {
282            warn!(workflow_id = workflow_id.0, %error, "failed to stop cancelled agent");
283        }
284        info!(workflow_id = workflow_id.0, "agent cancelled");
285        self.lock_inner()?
286            .events
287            .append(EventKind::CommandCompleted {
288                request_id,
289                result: CommandResult::AgentCancelled { workflow_id },
290            })?;
291        Ok(CommandDisposition::Accepted)
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use std::ffi::OsString;
298    use std::os::unix::fs::PermissionsExt;
299    use std::path::Path;
300    use std::thread;
301    use std::time::{Duration, Instant};
302
303    use super::*;
304    use crate::Command;
305
306    const SIZE: TerminalSize = TerminalSize {
307        rows: 24,
308        columns: 80,
309        pixel_width: 800,
310        pixel_height: 480,
311    };
312
313    /// An application whose `pi` harness is `script`, or is missing when `script` is `None`.
314    fn application(folder: &Path, bin: &Path, script: Option<&str>) -> Application {
315        if let Some(script) = script {
316            let binary = bin.join("pi");
317            std::fs::write(&binary, format!("#!/bin/sh\n{script}\n")).unwrap();
318            std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
319        }
320        let mut application = Application::with_event_capacity(4096).unwrap();
321        application.harness_path = Some(OsString::from(format!("{}:/bin:/usr/bin", bin.display())));
322        application
323            .handle_command(
324                RequestId(1),
325                Command::OpenFolder {
326                    path: folder.to_owned(),
327                },
328            )
329            .unwrap();
330        application
331    }
332
333    #[test]
334    fn runtime_shutdown_stops_live_model_requests_without_stopping_another_runtime() {
335        use crate::process::tests::FixtureProcess;
336
337        let first = tempfile::tempdir().unwrap();
338        let second = tempfile::tempdir().unwrap();
339        let make_runtime = |directory: &Path| {
340            application(
341                directory,
342                directory,
343                Some(&format!(
344                    "echo $$ > '{}'; exec sleep 60",
345                    directory.join("helper.pid").display()
346                )),
347            )
348        };
349        let first_app = make_runtime(first.path());
350        let second_app = make_runtime(second.path());
351        let first_request = first_app.request_harness_models(HarnessId::Pi, None);
352        let second_request = second_app.request_harness_models(HarnessId::Pi, None);
353        let first_helper = FixtureProcess::read(&first.path().join("helper.pid"));
354        let second_helper = FixtureProcess::read(&second.path().join("helper.pid"));
355
356        drop(first_app);
357        assert!(!first_helper.is_running());
358        assert!(matches!(
359            first_request.poll(),
360            Some(Err(crate::ModelListError::Cancelled))
361        ));
362        assert!(
363            second_helper.is_running(),
364            "other windows keep their own helpers"
365        );
366        assert!(second_request.poll().is_none());
367
368        drop(second_app);
369        assert!(!second_helper.is_running());
370        assert!(matches!(
371            second_request.poll(),
372            Some(Err(crate::ModelListError::Cancelled))
373        ));
374    }
375
376    #[test]
377    fn runtime_shutdown_cancels_discovery_waiting_on_the_login_shell() {
378        use crate::process::tests::FixtureProcess;
379
380        let directory = tempfile::tempdir().unwrap();
381        let pid_path = directory.path().join("shell.pid");
382        let shell_path = directory.path().join("shell");
383        std::fs::write(
384            &shell_path,
385            format!(
386                "#!/bin/sh\necho $$ > '{}'; exec sleep 60\n",
387                pid_path.display()
388            ),
389        )
390        .unwrap();
391        std::fs::set_permissions(&shell_path, std::fs::Permissions::from_mode(0o755)).unwrap();
392        let mut app = Application::with_event_capacity(128).unwrap();
393        app.login_path = Arc::new(LoginPath::spawn(shell_path));
394        let request = app.request_harness_models(HarnessId::Pi, None);
395        let shell = FixtureProcess::read(&pid_path);
396        let started = Instant::now();
397        drop(app);
398        assert!(started.elapsed() < Duration::from_secs(2));
399        assert!(!shell.is_running());
400        assert!(matches!(
401            request.poll(),
402            Some(Err(crate::ModelListError::Cancelled))
403        ));
404    }
405
406    fn draft(application: &Application, folder: &Path) -> crate::Workflow {
407        application
408            .handle_command(
409                RequestId(2),
410                Command::CreateWorkflow {
411                    folder: folder.to_owned(),
412                    session_id: None,
413                    kind: WorkflowKind::Draft,
414                    roles: Vec::new(),
415                    size: SIZE,
416                },
417            )
418            .unwrap();
419        application.snapshot().unwrap().workflows.workflows[0].clone()
420    }
421
422    fn draft_at(application: &Application, folder: &Path) -> crate::Workflow {
423        application
424            .handle_command(
425                RequestId(2),
426                Command::CreateWorkflow {
427                    folder: folder.to_owned(),
428                    session_id: None,
429                    kind: WorkflowKind::Draft,
430                    roles: Vec::new(),
431                    size: SIZE,
432                },
433            )
434            .unwrap();
435        application
436            .snapshot()
437            .unwrap()
438            .workflows
439            .workflows
440            .last()
441            .unwrap()
442            .clone()
443    }
444
445    fn status_of(application: &Application, id: WorkflowId) -> WorkflowStatus {
446        application
447            .snapshot()
448            .unwrap()
449            .workflows
450            .workflows
451            .iter()
452            .find(|workflow| workflow.workflow_id == id)
453            .unwrap()
454            .status
455    }
456
457    fn start(
458        application: &Application,
459        workflow_id: WorkflowId,
460        prompt: &str,
461    ) -> CommandDisposition {
462        application
463            .handle_command(
464                RequestId(3),
465                Command::StartAgent {
466                    model: None,
467                    effort: None,
468                    yolo: false,
469                    workflow_id,
470                    harness: HarnessId::Pi,
471                    prompt: prompt.to_owned(),
472                    size: SIZE,
473                },
474            )
475            .unwrap()
476            .disposition
477    }
478
479    fn workflow(application: &Application) -> crate::Workflow {
480        application.snapshot().unwrap().workflows.workflows[0].clone()
481    }
482
483    fn wait_until(mut condition: impl FnMut() -> bool) {
484        let deadline = Instant::now() + Duration::from_secs(10);
485        while !condition() {
486            assert!(Instant::now() < deadline, "agent condition timed out");
487            thread::sleep(Duration::from_millis(10));
488        }
489    }
490
491    fn output_contains(application: &Application, output: &mut Vec<u8>, needle: &str) -> bool {
492        while let Some(chunk) = application.next_terminal_chunk().unwrap() {
493            output.extend_from_slice(&chunk.bytes);
494        }
495        String::from_utf8_lossy(output).contains(needle)
496    }
497
498    #[test]
499    fn the_agent_gets_the_prompt_and_an_interactive_terminal() {
500        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
501        let application = application(
502            folder.path(),
503            bin.path(),
504            Some(
505                "while [ \"$#\" -gt 0 ] && [ \"$1\" != -- ]; do shift; done; printf 'ARGS:%s|%s\\n' \"$1\" \"$2\"; read line; echo \"GOT:$line\"; exit 3",
506            ),
507        );
508        let draft = draft(&application, folder.path());
509
510        assert_eq!(
511            start(&application, draft.workflow_id, "  -fix it  "),
512            CommandDisposition::Accepted
513        );
514
515        let agent = workflow(&application);
516        assert_eq!(agent.kind, WorkflowKind::SingleAgent);
517        assert_eq!(agent.harness, Some(HarnessId::Pi));
518        assert_ne!(agent.terminal_id, draft.terminal_id);
519        let mut output = Vec::new();
520        wait_until(|| output_contains(&application, &mut output, "ARGS:--|-fix it"));
521        application
522            .write_terminal_input(agent.terminal_id, b"hello\n")
523            .unwrap();
524        wait_until(|| output_contains(&application, &mut output, "GOT:hello"));
525        // A nonzero exit is a finished process, never a successful or cancelled workflow.
526        wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
527        let trace = application
528            .workflow_trace(draft.workflow_id, None, 10)
529            .unwrap();
530        assert_eq!(trace.spans.len(), 1);
531        let agent_span = trace
532            .spans
533            .iter()
534            .find(|span| span.terminal_id == Some(agent.terminal_id))
535            .unwrap();
536        assert_eq!(agent_span.status, crate::TraceSpanStatus::Exited);
537        assert!(!agent_span.is_live);
538        let lane = trace
539            .lanes
540            .iter()
541            .find(|lane| lane.lane_id == agent_span.lane_id)
542            .unwrap();
543        assert!(lane.is_agent);
544        assert_eq!(lane.role.as_deref(), Some("agent"));
545        assert_eq!(lane.harness.as_deref(), Some("pi"));
546        let events = application
547            .trace_events(agent_span.span_id, None, 10)
548            .unwrap()
549            .events;
550        assert_eq!(events.len(), 2);
551        assert_eq!(events[0].kind, crate::TraceEventKind::ProcessStarted);
552        assert_eq!(events[1].kind, crate::TraceEventKind::ProcessExited);
553        assert!(events[1].message.contains("code 3"));
554        assert!(events[1].anchor.as_ref().unwrap().byte_offset > 0);
555        assert!(
556            events
557                .iter()
558                .all(|event| !event.message.contains("fix it") && !event.message.contains("GOT:"))
559        );
560        assert!(
561            trace
562                .spans
563                .iter()
564                .all(|span| span.terminal_id != Some(draft.terminal_id))
565        );
566        assert!(trace.lanes.iter().all(|lane| lane.is_agent));
567    }
568
569    #[test]
570    fn an_agent_started_from_a_restored_draft_keeps_live_terminal_ownership() {
571        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
572        let app = application(folder.path(), bin.path(), Some("read line"));
573        let original = draft(&app, folder.path());
574        app.handle_command(RequestId(20), Command::CloseFolder)
575            .unwrap();
576        app.handle_command(
577            RequestId(21),
578            Command::OpenFolder {
579                path: folder.path().to_owned(),
580            },
581        )
582        .unwrap();
583        assert!(workflow(&app).restored);
584        assert_eq!(
585            start(&app, original.workflow_id, ""),
586            CommandDisposition::Accepted
587        );
588        let live = workflow(&app);
589        assert!(!live.restored);
590        assert_eq!(live.terminal_history, []);
591        assert_eq!(live.terminal_ids(), vec![live.terminal_id]);
592        app.write_terminal_input(live.terminal_id, b"exit\n")
593            .unwrap();
594        wait_until(|| workflow(&app).status == WorkflowStatus::Exited);
595        app.handle_command(
596            RequestId(22),
597            Command::CloseWorkflow {
598                workflow_id: live.workflow_id,
599            },
600        )
601        .unwrap();
602        assert!(
603            app.write_terminal_input(live.terminal_id, b"late\n")
604                .is_err()
605        );
606    }
607
608    #[test]
609    fn antigravity_starts_interactively_with_options_and_accepts_follow_up_input() {
610        for prompt in ["", "-fix it\nKeep the terminal open"] {
611            let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
612            let application = application(
613                folder.path(),
614                bin.path(),
615                Some(
616                    "printf 'ARG:%s\\n' \"$@\"; echo READY; IFS= read -r line; printf 'GOT:%s\\n' \"$line\"",
617                ),
618            );
619            std::fs::rename(bin.path().join("pi"), bin.path().join("agy")).unwrap();
620            let draft = draft(&application, folder.path());
621            assert_eq!(
622                application
623                    .handle_command(
624                        RequestId(3),
625                        Command::StartAgent {
626                            workflow_id: draft.workflow_id,
627                            harness: HarnessId::Antigravity,
628                            model: Some("gemini-3.8-flash-high".into()),
629                            effort: Some("high".into()),
630                            yolo: true,
631                            prompt: prompt.into(),
632                            size: SIZE,
633                        },
634                    )
635                    .unwrap()
636                    .disposition,
637                CommandDisposition::Accepted
638            );
639            let agent = workflow(&application);
640            assert_eq!(agent.harness, Some(HarnessId::Antigravity));
641            assert_eq!(agent.name, "Antigravity");
642            let mut output = Vec::new();
643            wait_until(|| output_contains(&application, &mut output, "READY"));
644            let arguments = String::from_utf8_lossy(&output);
645            for argument in [
646                "--dangerously-skip-permissions",
647                "--model",
648                "gemini-3.8-flash-high",
649                "--effort",
650                "high",
651            ] {
652                assert!(arguments.contains(&format!("ARG:{argument}\r\n")));
653            }
654            if prompt.is_empty() {
655                assert!(!arguments.contains("--prompt-interactive"));
656            } else {
657                assert!(
658                    arguments
659                        .contains("ARG:--prompt-interactive=-fix it\r\nKeep the terminal open")
660                );
661            }
662            assert!(!arguments.contains("ARG:--print"));
663            application
664                .write_terminal_input(agent.terminal_id, b"follow up\n")
665                .unwrap();
666            wait_until(|| output_contains(&application, &mut output, "GOT:follow up"));
667            wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
668            let trace = application
669                .workflow_trace(draft.workflow_id, None, 10)
670                .unwrap();
671            let lane = trace.lanes.iter().find(|lane| lane.is_agent).unwrap();
672            assert_eq!(lane.harness.as_deref(), Some("Antigravity"));
673            let span = trace
674                .spans
675                .iter()
676                .find(|span| span.lane_id == lane.lane_id)
677                .unwrap();
678            assert_eq!(span.status, crate::TraceSpanStatus::Exited);
679            assert_eq!(
680                trace.spans.len(),
681                1,
682                "The lifecycle span remains without prompt hooks"
683            );
684        }
685    }
686
687    #[test]
688    fn omp_launch_keeps_prompts_literal_and_accepts_follow_up_input() {
689        for prompt in ["", "models", "@README.md", "-fix it"] {
690            let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
691            let application = application(
692                folder.path(),
693                bin.path(),
694                Some(
695                    "printf 'ARG:%s\\n' \"$@\"; echo READY; IFS= read -r line; printf 'GOT:%s\\n' \"$line\"",
696                ),
697            );
698            std::fs::rename(bin.path().join("pi"), bin.path().join("omp")).unwrap();
699            let draft = draft(&application, folder.path());
700            assert_eq!(
701                application
702                    .handle_command(
703                        RequestId(3),
704                        Command::StartAgent {
705                            workflow_id: draft.workflow_id,
706                            harness: HarnessId::Omp,
707                            model: Some("local/model".into()),
708                            effort: Some("high".into()),
709                            yolo: true,
710                            prompt: prompt.into(),
711                            size: SIZE,
712                        }
713                    )
714                    .unwrap()
715                    .disposition,
716                CommandDisposition::Accepted
717            );
718            let agent = workflow(&application);
719            assert_eq!(agent.harness, Some(HarnessId::Omp));
720            let mut output = Vec::new();
721            wait_until(|| output_contains(&application, &mut output, "READY"));
722            let arguments = String::from_utf8_lossy(&output);
723            assert!(arguments.starts_with("ARG:launch\r\nARG:--extension\r\n"));
724            for argument in [
725                "--auto-approve",
726                "--model",
727                "local/model",
728                "--thinking",
729                "high",
730            ] {
731                assert!(arguments.contains(&format!("ARG:{argument}\r\n")));
732            }
733            if prompt.is_empty() {
734                assert!(!arguments.contains("ARG:--\r\n"));
735            } else {
736                assert!(arguments.contains(&format!("ARG:--\r\nARG:{prompt}\r\n")));
737            }
738            assert!(!arguments.contains("ARG:--print"));
739            application
740                .write_terminal_input(agent.terminal_id, b"follow up\n")
741                .unwrap();
742            wait_until(|| output_contains(&application, &mut output, "GOT:follow up"));
743            wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
744        }
745    }
746
747    #[test]
748    fn opencode_launch_is_interactive_with_literal_prompts_model_variants_and_lifecycle_traces() {
749        for prompt in [
750            "",
751            "models",
752            "@README.md",
753            "-fix it\nKeep the terminal open",
754        ] {
755            let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
756            let application = application(
757                folder.path(),
758                bin.path(),
759                Some(
760                    "printf 'ARG:%s\\n' \"$@\"; echo READY; IFS= read -r line; printf 'GOT:%s\\n' \"$line\"",
761                ),
762            );
763            std::fs::rename(bin.path().join("pi"), bin.path().join("opencode")).unwrap();
764            let draft = draft(&application, folder.path());
765            assert_eq!(
766                application
767                    .handle_command(
768                        RequestId(3),
769                        Command::StartAgent {
770                            workflow_id: draft.workflow_id,
771                            harness: HarnessId::Opencode,
772                            model: Some("local/model".into()),
773                            effort: Some("high".into()),
774                            yolo: false,
775                            prompt: prompt.into(),
776                            size: SIZE,
777                        }
778                    )
779                    .unwrap()
780                    .disposition,
781                CommandDisposition::Accepted
782            );
783            let agent = workflow(&application);
784            assert_eq!(agent.harness, Some(HarnessId::Opencode));
785            assert_eq!(agent.name, "OpenCode");
786            let mut output = Vec::new();
787            wait_until(|| output_contains(&application, &mut output, "READY"));
788            let expected = if prompt.is_empty() {
789                "ARG:mini\r\nARG:--standalone\r\nARG:--model\r\nARG:local/model#high\r\nREADY\r\n"
790                    .to_owned()
791            } else {
792                format!(
793                    "ARG:mini\r\nARG:--standalone\r\nARG:--model\r\nARG:local/model#high\r\nARG:--prompt={}\r\nREADY\r\n",
794                    prompt.replace('\n', "\r\n")
795                )
796            };
797            assert_eq!(String::from_utf8_lossy(&output), expected);
798            application
799                .write_terminal_input(agent.terminal_id, b"follow up\n")
800                .unwrap();
801            wait_until(|| output_contains(&application, &mut output, "GOT:follow up"));
802            wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
803            let trace = application
804                .workflow_trace(draft.workflow_id, None, 10)
805                .unwrap();
806            assert_eq!(trace.spans.len(), 1);
807            let lane = trace.lanes.iter().find(|lane| lane.is_agent).unwrap();
808            assert_eq!(lane.harness.as_deref(), Some("OpenCode"));
809            let span = trace
810                .spans
811                .iter()
812                .find(|span| span.lane_id == lane.lane_id)
813                .unwrap();
814            assert_eq!(span.status, crate::TraceSpanStatus::Exited);
815        }
816    }
817
818    #[test]
819    fn agent_allocation_waits_without_holding_application_state() {
820        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
821        let application = Arc::new(application(folder.path(), bin.path(), Some("read line")));
822        let draft = draft(&application, folder.path());
823        let stalled = application
824            .terminal_output
825            .stall_recording_worker(draft.terminal_id);
826        let starting = Arc::clone(&application);
827        let starter = thread::spawn(move || start(&starting, draft.workflow_id, "wait for input"));
828        wait_until(|| application.terminal_output.allocation_is_pending());
829        let (sent, received) = std::sync::mpsc::sync_channel(1);
830        let observing = Arc::clone(&application);
831        let observer = thread::spawn(move || {
832            let snapshot = observing.snapshot().unwrap();
833            observing.events_after(snapshot.sequence, 8).unwrap();
834            sent.send(()).unwrap();
835        });
836        received
837            .recv_timeout(Duration::from_secs(1))
838            .expect("state must remain available while agent allocation is stalled");
839        observer.join().unwrap();
840        assert!(!starter.is_finished());
841        drop(stalled);
842        assert_eq!(starter.join().unwrap(), CommandDisposition::Accepted);
843    }
844
845    #[test]
846    fn a_missing_harness_binary_is_rejected_and_keeps_the_draft() {
847        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
848        let application = application(folder.path(), bin.path(), None);
849        let draft = draft(&application, folder.path());
850
851        let CommandDisposition::Rejected { code, message } =
852            start(&application, draft.workflow_id, "hi")
853        else {
854            panic!("a missing binary should be rejected");
855        };
856
857        assert_eq!(code, "harnessNotFound");
858        assert!(message.contains("`pi`"), "{message}");
859        assert_eq!(workflow(&application), draft);
860    }
861
862    #[test]
863    fn an_agent_without_a_prompt_starts_interactively_and_only_from_a_draft() {
864        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
865        let application = application(
866            folder.path(),
867            bin.path(),
868            Some(
869                "for a in \"$@\"; do [ \"$a\" = -- ] && echo SEPARATOR; done; echo ARGS-DONE; sleep 30",
870            ),
871        );
872        let draft = draft(&application, folder.path());
873
874        assert_eq!(
875            start(&application, draft.workflow_id, "   "),
876            CommandDisposition::Accepted
877        );
878        // Hook flags may still be passed, but no prompt separator or prompt follows them.
879        let mut output = Vec::new();
880        wait_until(|| output_contains(&application, &mut output, "ARGS-DONE"));
881        assert!(!String::from_utf8_lossy(&output).contains("SEPARATOR"));
882        assert!(matches!(
883            start(&application, draft.workflow_id, "again"),
884            CommandDisposition::Rejected { code, .. } if code == "workflowNotDraft"
885        ));
886    }
887
888    #[test]
889    fn the_agent_starts_with_its_chosen_model_and_effort_and_rejects_a_flag_as_a_model() {
890        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
891        let application = application(
892            folder.path(),
893            bin.path(),
894            Some("printf 'ARGS:%s\\n' \"$*\"; sleep 30"),
895        );
896        let draft = draft(&application, folder.path());
897        let start_with = |model: &str, effort: &str| {
898            application
899                .handle_command(
900                    RequestId(3),
901                    Command::StartAgent {
902                        workflow_id: draft.workflow_id,
903                        harness: HarnessId::Pi,
904                        model: Some(model.to_owned()),
905                        effort: Some(effort.to_owned()),
906                        // pi has no permission prompts, so this adds no flag.
907                        yolo: true,
908                        prompt: String::new(),
909                        size: SIZE,
910                    },
911                )
912                .unwrap()
913                .disposition
914        };
915
916        assert!(matches!(
917            start_with("--help", "high"),
918            CommandDisposition::Rejected { code, .. } if code == "invalidModel"
919        ));
920        assert!(matches!(
921            start_with("local/m1", "High"),
922            CommandDisposition::Rejected { code, .. } if code == "invalidModel"
923        ));
924        assert_eq!(
925            start_with(" local/m1 ", "high"),
926            CommandDisposition::Accepted
927        );
928        let mut output = Vec::new();
929        wait_until(|| {
930            output_contains(
931                &application,
932                &mut output,
933                "--model local/m1 --thinking high",
934            )
935        });
936    }
937
938    #[test]
939    fn cancelling_stops_the_process_and_stays_cancelled() {
940        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
941        let application = application(
942            folder.path(),
943            bin.path(),
944            Some("trap '' HUP; echo ready; while :; do sleep 1; done"),
945        );
946        let draft = draft(&application, folder.path());
947        start(&application, draft.workflow_id, "go");
948        let mut output = Vec::new();
949        wait_until(|| output_contains(&application, &mut output, "ready"));
950
951        let receipt = application
952            .handle_command(
953                RequestId(4),
954                Command::CancelAgent {
955                    workflow_id: draft.workflow_id,
956                },
957            )
958            .unwrap();
959
960        assert_eq!(receipt.disposition, CommandDisposition::Accepted);
961        let agent = workflow(&application);
962        assert_eq!(agent.status, WorkflowStatus::Cancelled);
963        let terminal_id = agent.terminal_id;
964        wait_until(|| {
965            application
966                .snapshot()
967                .unwrap()
968                .terminals
969                .iter()
970                .any(|terminal| {
971                    terminal.terminal_id == terminal_id
972                        && matches!(terminal.status, TerminalStatus::Exited(_))
973                })
974        });
975        assert_eq!(workflow(&application).status, WorkflowStatus::Cancelled);
976        let trace = application
977            .workflow_trace(draft.workflow_id, None, 10)
978            .unwrap();
979        let span = trace
980            .spans
981            .iter()
982            .find(|span| span.terminal_id == Some(terminal_id))
983            .unwrap();
984        assert_eq!(span.status, crate::TraceSpanStatus::Stopped);
985        let events = application
986            .trace_events(span.span_id, None, 10)
987            .unwrap()
988            .events;
989        assert_eq!(
990            events.len(),
991            2,
992            "the exit callback must not add a second ending"
993        );
994        assert_eq!(events[1].kind, crate::TraceEventKind::ProcessStopped);
995        assert!(events[1].message.contains("cancelled"));
996        assert!(matches!(
997            application
998                .handle_command(
999                    RequestId(5),
1000                    Command::CancelAgent {
1001                        workflow_id: draft.workflow_id
1002                    }
1003                )
1004                .unwrap()
1005                .disposition,
1006            CommandDisposition::Rejected { code, .. } if code == "agentNotRunning"
1007        ));
1008    }
1009
1010    #[test]
1011    fn restored_agents_keep_how_they_ended_and_only_running_ones_are_interrupted() {
1012        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1013        let data = tempfile::tempdir().unwrap();
1014        let ids = {
1015            let mut first =
1016                Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1017            first.harness_path = Some(OsString::from(format!(
1018                "{}:/bin:/usr/bin",
1019                bin.path().display()
1020            )));
1021            let binary = bin.path().join("pi");
1022            std::fs::write(
1023                &binary,
1024                "#!/bin/sh\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != -- ]; do shift; done\ncase \"$2\" in exit) exit 0;; *) trap '' HUP; sleep 30;; esac\n",
1025            )
1026            .unwrap();
1027            std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
1028            first
1029                .handle_command(
1030                    RequestId(1),
1031                    Command::OpenFolder {
1032                        path: folder.path().to_owned(),
1033                    },
1034                )
1035                .unwrap();
1036            let mut ids = Vec::new();
1037            for prompt in ["exit", "cancel", "run"] {
1038                let draft = draft_at(&first, folder.path());
1039                assert_eq!(
1040                    start(&first, draft.workflow_id, prompt),
1041                    CommandDisposition::Accepted
1042                );
1043                ids.push(draft.workflow_id);
1044            }
1045            first
1046                .handle_command(
1047                    RequestId(9),
1048                    Command::CancelAgent {
1049                        workflow_id: ids[1],
1050                    },
1051                )
1052                .unwrap();
1053            wait_until(|| status_of(&first, ids[0]) == WorkflowStatus::Exited);
1054            ids
1055        };
1056
1057        let second =
1058            Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1059
1060        for (id, expected) in ids.into_iter().zip([
1061            WorkflowStatus::Exited,
1062            WorkflowStatus::Cancelled,
1063            WorkflowStatus::Interrupted,
1064        ]) {
1065            let restored = second
1066                .snapshot()
1067                .unwrap()
1068                .workflows
1069                .workflows
1070                .into_iter()
1071                .find(|workflow| workflow.workflow_id == id)
1072                .unwrap();
1073            assert_eq!(restored.kind, WorkflowKind::SingleAgent);
1074            assert_eq!(restored.harness, Some(HarnessId::Pi));
1075            assert_eq!(restored.status, expected);
1076            assert!(restored.restored);
1077            let trace = second.workflow_trace(id, None, 10).unwrap();
1078            assert_eq!(trace.spans.len(), 1);
1079            let lane = trace.lanes.iter().find(|lane| lane.is_agent).unwrap();
1080            assert_eq!(lane.harness.as_deref(), Some("pi"));
1081            let span = trace
1082                .spans
1083                .iter()
1084                .find(|span| span.lane_id == lane.lane_id)
1085                .unwrap();
1086            assert_eq!(
1087                span.status,
1088                if expected == WorkflowStatus::Exited {
1089                    crate::TraceSpanStatus::Exited
1090                } else {
1091                    crate::TraceSpanStatus::Stopped
1092                }
1093            );
1094            assert!(!span.is_live);
1095            assert_eq!(
1096                second
1097                    .trace_events(span.span_id, None, 10)
1098                    .unwrap()
1099                    .events
1100                    .len(),
1101                2
1102            );
1103            assert_eq!(Some(restored.terminal_id), span.terminal_id);
1104            assert!(
1105                restored.terminal_ids().is_empty(),
1106                "agents are never relaunched"
1107            );
1108            assert_eq!(
1109                restored.terminal_history.last().unwrap().terminal_id,
1110                restored.terminal_id
1111            );
1112        }
1113    }
1114
1115    #[test]
1116    fn a_failed_agent_start_trace_keeps_the_draft_and_rolls_back_its_shell_ending() {
1117        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1118        let application = application(folder.path(), bin.path(), Some("sleep 30"));
1119        let draft = draft(&application, folder.path());
1120        application.lock_inner().unwrap().folders.store().execute_test_sql(
1121            "CREATE TRIGGER reject_start BEFORE INSERT ON trace_events WHEN NEW.kind = 'processStarted' BEGIN SELECT RAISE(FAIL, 'test start failure'); END;"
1122        );
1123        assert!(
1124            application
1125                .handle_command(
1126                    RequestId(3),
1127                    Command::StartAgent {
1128                        model: None,
1129                        effort: None,
1130                        yolo: false,
1131                        workflow_id: draft.workflow_id,
1132                        harness: HarnessId::Pi,
1133                        prompt: "go".to_owned(),
1134                        size: SIZE,
1135                    }
1136                )
1137                .is_err()
1138        );
1139        assert_eq!(workflow(&application), draft);
1140        let stored = application
1141            .lock_inner()
1142            .unwrap()
1143            .folders
1144            .read_store()
1145            .workflows(folder.path())
1146            .unwrap();
1147        assert_eq!(stored[0].kind, WorkflowKind::Draft);
1148        assert_eq!(stored[0].harness, None);
1149        let trace = application
1150            .workflow_trace(draft.workflow_id, None, 10)
1151            .unwrap();
1152        assert_eq!(trace.spans, []);
1153        assert_eq!(trace.lanes, []);
1154        let history = application
1155            .lock_inner()
1156            .unwrap()
1157            .folders
1158            .store()
1159            .terminal_history(draft.workflow_id)
1160            .unwrap();
1161        assert_eq!(history.len(), 1);
1162        assert_eq!(history[0].terminal_id, draft.terminal_id);
1163        assert_eq!(application.snapshot().unwrap().terminals.len(), 1);
1164        assert!(
1165            application
1166                .terminals
1167                .size(crate::TerminalId::from_value(draft.terminal_id.value() + 1))
1168                .is_none()
1169        );
1170        application
1171            .write_terminal_input(draft.terminal_id, b"echo alive\n")
1172            .unwrap();
1173    }
1174
1175    #[test]
1176    fn a_failed_cancel_trace_still_stops_the_agent_and_retries_the_cancellation() {
1177        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1178        let application = application(
1179            folder.path(),
1180            bin.path(),
1181            Some("trap '' HUP; echo ready; while :; do sleep 1; done"),
1182        );
1183        let draft = draft(&application, folder.path());
1184        start(&application, draft.workflow_id, "go");
1185        let agent = workflow(&application);
1186        let mut output = Vec::new();
1187        wait_until(|| output_contains(&application, &mut output, "ready"));
1188        application.lock_inner().unwrap().folders.store().execute_test_sql(
1189            "CREATE TRIGGER reject_trace BEFORE INSERT ON trace_events BEGIN SELECT RAISE(FAIL, 'test recording failure'); END;"
1190        );
1191        application
1192            .handle_command(
1193                RequestId(4),
1194                Command::CancelAgent {
1195                    workflow_id: draft.workflow_id,
1196                },
1197            )
1198            .unwrap();
1199        wait_until(|| {
1200            application
1201                .snapshot()
1202                .unwrap()
1203                .terminals
1204                .iter()
1205                .any(|terminal| {
1206                    terminal.terminal_id == agent.terminal_id
1207                        && matches!(terminal.status, TerminalStatus::Exited(_))
1208                })
1209        });
1210        assert_eq!(workflow(&application).status, WorkflowStatus::Cancelled);
1211        assert_eq!(
1212            application
1213                .lock_inner()
1214                .unwrap()
1215                .pending_trace_endings
1216                .len(),
1217            1
1218        );
1219        application
1220            .lock_inner()
1221            .unwrap()
1222            .folders
1223            .store()
1224            .execute_test_sql("DROP TRIGGER reject_trace");
1225        application
1226            .handle_command(RequestId(5), Command::Ping)
1227            .unwrap();
1228        let trace = application
1229            .workflow_trace(draft.workflow_id, None, 10)
1230            .unwrap();
1231        let span = trace
1232            .spans
1233            .iter()
1234            .find(|span| span.terminal_id == Some(agent.terminal_id))
1235            .unwrap();
1236        assert_eq!(span.status, crate::TraceSpanStatus::Stopped);
1237        let events = application
1238            .trace_events(span.span_id, None, 10)
1239            .unwrap()
1240            .events;
1241        assert_eq!(events.len(), 2);
1242        assert_eq!(events[1].kind, crate::TraceEventKind::ProcessStopped);
1243        assert!(events[1].message.contains("cancelled"));
1244    }
1245
1246    #[test]
1247    fn starting_replaces_the_placeholder_shell_and_keeps_its_size() {
1248        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1249        let application = application(folder.path(), bin.path(), Some("stty size; sleep 30"));
1250        let draft = draft(&application, folder.path());
1251        application
1252            .resize_terminal(
1253                draft.terminal_id,
1254                TerminalSize {
1255                    rows: 37,
1256                    columns: 101,
1257                    pixel_width: 1_010,
1258                    pixel_height: 740,
1259                },
1260            )
1261            .unwrap();
1262
1263        start(&application, draft.workflow_id, "go");
1264
1265        assert!(
1266            application
1267                .write_terminal_input(draft.terminal_id, b"x")
1268                .is_err(),
1269            "the draft's placeholder shell should be closed"
1270        );
1271        let mut output = Vec::new();
1272        wait_until(|| output_contains(&application, &mut output, "37 101"));
1273    }
1274
1275    #[test]
1276    fn a_configured_agent_cannot_be_activated_as_a_terminal_and_can_still_be_cancelled() {
1277        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1278        let application = application(folder.path(), bin.path(), Some("trap '' HUP; sleep 30"));
1279        let draft = draft(&application, folder.path());
1280        start(&application, draft.workflow_id, "go");
1281
1282        let activation = application
1283            .handle_command(
1284                RequestId(4),
1285                Command::ActivateWorkflow {
1286                    workflow_id: draft.workflow_id,
1287                },
1288            )
1289            .unwrap();
1290
1291        assert!(matches!(
1292            activation.disposition,
1293            CommandDisposition::Rejected { code, .. } if code == "workflowNotDraft"
1294        ));
1295        let agent = workflow(&application);
1296        assert_eq!(agent.kind, WorkflowKind::SingleAgent);
1297        assert_eq!(agent.harness, Some(HarnessId::Pi));
1298        assert_eq!(agent.status, WorkflowStatus::Running);
1299        let cancel = application
1300            .handle_command(
1301                RequestId(5),
1302                Command::CancelAgent {
1303                    workflow_id: draft.workflow_id,
1304                },
1305            )
1306            .unwrap();
1307        assert_eq!(cancel.disposition, CommandDisposition::Accepted);
1308    }
1309
1310    #[test]
1311    fn cancelling_an_agent_that_already_exited_is_rejected_and_stays_exited() {
1312        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1313        let application = application(folder.path(), bin.path(), Some("exit 0"));
1314        let draft = draft(&application, folder.path());
1315        start(&application, draft.workflow_id, "go");
1316        wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
1317
1318        let receipt = application
1319            .handle_command(
1320                RequestId(4),
1321                Command::CancelAgent {
1322                    workflow_id: draft.workflow_id,
1323                },
1324            )
1325            .unwrap();
1326
1327        assert!(matches!(
1328            receipt.disposition,
1329            CommandDisposition::Rejected { code, .. } if code == "agentNotRunning"
1330        ));
1331        assert_eq!(workflow(&application).status, WorkflowStatus::Exited);
1332    }
1333
1334    #[test]
1335    fn a_failed_agent_start_leaves_the_draft_and_its_shell_untouched() {
1336        let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1337        let application = application(folder.path(), bin.path(), Some("sleep 30"));
1338        let draft = draft(&application, folder.path());
1339        // The harness is found, but the folder is gone by the time the agent would start in it.
1340        std::fs::remove_dir_all(folder.path()).unwrap();
1341
1342        let disposition = start(&application, draft.workflow_id, "go");
1343
1344        assert!(matches!(
1345            disposition,
1346            CommandDisposition::Rejected { code, .. } if code == "agentStartFailed"
1347        ));
1348        assert_eq!(workflow(&application), draft);
1349        application
1350            .write_terminal_input(draft.terminal_id, b"x")
1351            .expect("the draft's shell should still be running");
1352        let stored = application
1353            .lock_inner()
1354            .unwrap()
1355            .folders
1356            .store()
1357            .workflows(folder.path())
1358            .unwrap();
1359        assert_eq!(stored.len(), 1);
1360        assert_eq!(stored[0].kind, WorkflowKind::Draft);
1361        assert_eq!(stored[0].harness, None);
1362    }
1363}