Skip to main content

aft/commands/
bash_orchestrate.rs

1use std::path::Path;
2use std::time::{Duration, Instant};
3
4use serde::Deserialize;
5use serde_json::{json, Value};
6
7use crate::bash_background::registry::BgTaskSnapshot;
8use crate::bash_background::BgTaskStatus;
9use crate::context::AppContext;
10use crate::protocol::{RawRequest, Response};
11use crate::response_finalize::{DispatchOutcome, PendingResponse, PendingResponsePoll};
12
13const TEST_FOREGROUND_WAIT_ENV: &str = "AFT_TEST_FOREGROUND_WAIT_MS";
14const DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS: u64 = 30 * 60 * 1000;
15
16#[derive(Debug, Default, Deserialize)]
17#[serde(default)]
18struct BashOrchestrateParams {
19    foreground_orchestrate: bool,
20    block_to_completion: bool,
21    wait: bool,
22    background: bool,
23    pty: bool,
24    timeout: Option<u64>,
25}
26
27/// Port of `packages/aft-bridge/src/bash-format.ts` `formatForegroundResult` (lines 8-25).
28pub fn format_foreground_result(snapshot: &BgTaskSnapshot) -> String {
29    let mut rendered = snapshot.output_preview.clone();
30    if snapshot.output_truncated {
31        if let Some(output_path) = snapshot.output_path.as_deref() {
32            rendered.push_str(&format!(
33                "\n[output truncated; full output at {output_path}]"
34            ));
35        }
36    }
37    if snapshot.info.status == BgTaskStatus::TimedOut {
38        rendered.push_str("\n[command timed out]");
39    }
40    if let Some(exit) = snapshot.exit_code.filter(|exit| *exit != 0) {
41        rendered.push_str(&format!("\n[exit code: {exit}]"));
42    }
43    rendered
44}
45
46/// Port of `packages/aft-bridge/src/bash-format.ts` `formatSeconds` (lines 3-6).
47pub fn format_seconds(ms: u64) -> String {
48    let mut seconds = format!("{:.1}", ms as f64 / 1000.0);
49    if seconds.ends_with(".0") {
50        seconds.truncate(seconds.len() - 2);
51    }
52    format!("{seconds}s")
53}
54
55fn format_background_handoff_tail(task_id: &str) -> String {
56    format!(
57        "{task_id}. A completion reminder will be delivered automatically; use bash_status({{ taskId: \"{task_id}\" }}) to inspect output or bash_kill({{ taskId: \"{task_id}\" }}) to terminate."
58    )
59}
60
61/// Port of OpenCode `packages/opencode-plugin/src/tools/bash.ts` `formatPromotionMessage` (lines 603-614).
62pub fn format_promotion_message(
63    task_id: &str,
64    timeout: Option<u64>,
65    wait_window_ms: u64,
66) -> String {
67    let waited = timeout
68        .map(|timeout| timeout.min(wait_window_ms))
69        .unwrap_or(wait_window_ms);
70    format!(
71        "Foreground bash didn't finish within {} and was promoted to background: {}",
72        format_seconds(waited),
73        format_background_handoff_tail(task_id)
74    )
75}
76
77pub fn format_wait_detach_message(task_id: &str) -> String {
78    format!(
79        "Foreground bash is running in background as {}\nDetached because a user message arrived.",
80        format_background_handoff_tail(task_id)
81    )
82}
83
84/// Port of OpenCode `packages/opencode-plugin/src/tools/bash.ts` `formatBackgroundLaunch` (lines 593-601).
85pub fn format_background_launch(task_id: &str, pty: bool) -> String {
86    if pty {
87        return format!(
88            "PTY task started: {task_id}. Use bash_status({{ taskId: \"{task_id}\", outputMode: \"screen\" }}) to see the visible terminal, bash_write({{ taskId: \"{task_id}\", input: ... }}) to send keystrokes. A completion reminder fires automatically when the task exits."
89        );
90    }
91    format!(
92        "Background task started: {task_id}. A completion reminder will be delivered automatically; don't poll bash_status."
93    )
94}
95
96pub fn foreground_orchestrate_enabled(req: &RawRequest) -> bool {
97    parse_params(req)
98        .map(|params| params.foreground_orchestrate)
99        .unwrap_or(false)
100}
101
102pub fn build_bash_outcome(
103    req: &RawRequest,
104    ctx: &AppContext,
105    spawn_response: Response,
106) -> DispatchOutcome {
107    if !spawn_response.success {
108        return DispatchOutcome::Immediate(spawn_response);
109    }
110
111    let params = parse_params(req).unwrap_or_default();
112    let Some(task_id) = spawn_response
113        .data
114        .get("task_id")
115        .and_then(Value::as_str)
116        .map(str::to_owned)
117    else {
118        return DispatchOutcome::Immediate(spawn_response);
119    };
120    if spawn_response.data.get("status").and_then(Value::as_str) != Some("running") {
121        return DispatchOutcome::Immediate(spawn_response);
122    }
123
124    let mode = spawn_response
125        .data
126        .get("mode")
127        .and_then(Value::as_str)
128        .unwrap_or("pipes");
129    let is_pty = mode == "pty" || params.pty;
130    if is_pty || params.background {
131        return DispatchOutcome::Immediate(background_launch_response(&req.id, &task_id, is_pty));
132    }
133
134    let request_id = req.id.clone();
135    let session_id = req.session().to_string();
136    let attach_command = "bash".to_string();
137    let detach_on_user_message = params.wait;
138    ctx.bash_background()
139        .register_foreground_task(&session_id, &task_id);
140    if detach_on_user_message {
141        ctx.bash_background()
142            .begin_wait_mode_session(&session_id, &task_id);
143    }
144    let wait_window_ms = select_foreground_wait_window_ms(
145        ctx.config().foreground_wait_window_ms,
146        params.timeout,
147        params.wait,
148    );
149    let deadline = Instant::now() + Duration::from_millis(wait_window_ms);
150    let block_to_completion = params.block_to_completion || params.wait;
151    let timeout = params.timeout;
152    let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
153    let project_root = ctx.config().project_root.clone();
154    let task_id_for_poll = task_id.clone();
155    let request_id_for_poll = request_id.clone();
156    let session_id_for_poll = session_id.clone();
157    let session_id_for_cleanup = session_id.clone();
158
159    let mut poll: PendingResponsePoll = Box::new(move |ctx| {
160        // Foreground polls only need task state. Terminal snapshots still
161        // return cached output.
162        let response = if let Some(snapshot) = poll_bash_status(
163            ctx,
164            &task_id_for_poll,
165            &session_id_for_poll,
166            project_root.as_deref(),
167            &storage_dir,
168            0,
169        ) {
170            if snapshot.info.status.is_terminal() {
171                Some(foreground_result_response(&request_id_for_poll, snapshot))
172            } else if detach_on_user_message
173                && ctx
174                    .bash_background()
175                    .take_wait_mode_detach(&session_id_for_poll)
176            {
177                Some(detach_wait_mode_bash(
178                    ctx,
179                    &task_id_for_poll,
180                    &session_id_for_poll,
181                    &request_id_for_poll,
182                ))
183            } else {
184                match decide_bash_step(
185                    snapshot,
186                    deadline,
187                    block_to_completion,
188                    Instant::now(),
189                    &request_id_for_poll,
190                ) {
191                    BashStep::Done(response) => Some(response),
192                    BashStep::Promote => Some(promote_bash(
193                        ctx,
194                        &task_id_for_poll,
195                        &session_id_for_poll,
196                        timeout,
197                        wait_window_ms,
198                        &request_id_for_poll,
199                    )),
200                    BashStep::Wait => None,
201                }
202            }
203        } else {
204            Some(task_not_found_response(
205                &request_id_for_poll,
206                &task_id_for_poll,
207            ))
208        };
209
210        if response.is_some() {
211            if detach_on_user_message {
212                ctx.bash_background()
213                    .end_wait_mode_session(&session_id_for_cleanup, &task_id_for_poll);
214            } else {
215                ctx.bash_background()
216                    .unregister_foreground_task(&session_id_for_cleanup, &task_id_for_poll);
217            }
218        }
219        response
220    });
221
222    if let Some(response) = poll(ctx) {
223        return DispatchOutcome::Immediate(response);
224    }
225
226    DispatchOutcome::Deferred(PendingResponse {
227        request_id,
228        session_id,
229        attach_command,
230        poll,
231        cancellation: None,
232        on_shutdown: None,
233    })
234}
235
236pub(crate) fn poll_bash_status(
237    ctx: &AppContext,
238    task_id: &str,
239    session_id: &str,
240    project_root: Option<&Path>,
241    storage_dir: &Path,
242    preview_bytes: usize,
243) -> Option<BgTaskSnapshot> {
244    ctx.bash_background().status(
245        task_id,
246        session_id,
247        project_root,
248        Some(storage_dir),
249        preview_bytes,
250    )
251}
252
253pub(crate) enum BashStep {
254    Done(Response),
255    Promote,
256    Wait,
257}
258
259pub(crate) fn task_not_found_response(request_id: &str, task_id: &str) -> Response {
260    Response::error(
261        request_id,
262        "task_not_found",
263        crate::commands::bash_status::format_unknown_task_message(task_id),
264    )
265}
266
267pub(crate) fn decide_bash_step(
268    snapshot: BgTaskSnapshot,
269    deadline: Instant,
270    block_to_completion: bool,
271    now: Instant,
272    request_id: &str,
273) -> BashStep {
274    if snapshot.info.status.is_terminal() {
275        BashStep::Done(foreground_result_response(request_id, snapshot))
276    } else if !block_to_completion && now >= deadline {
277        BashStep::Promote
278    } else {
279        BashStep::Wait
280    }
281}
282
283pub(crate) fn promote_bash(
284    ctx: &AppContext,
285    task_id: &str,
286    session_id: &str,
287    timeout: Option<u64>,
288    wait_window_ms: u64,
289    request_id: &str,
290) -> Response {
291    match ctx.bash_background().promote(task_id, session_id) {
292        Ok(_) => promotion_response(request_id, task_id, timeout, wait_window_ms),
293        Err(message) if message.contains("not found") => Response::error(
294            request_id,
295            "task_not_found",
296            crate::commands::bash_status::format_unknown_task_message(task_id),
297        ),
298        Err(message) => Response::error(request_id, "execution_failed", message),
299    }
300}
301
302pub(crate) fn detach_wait_mode_bash(
303    ctx: &AppContext,
304    task_id: &str,
305    session_id: &str,
306    request_id: &str,
307) -> Response {
308    match ctx.bash_background().promote(task_id, session_id) {
309        Ok(_) => wait_detach_response(request_id, task_id),
310        Err(message) if message.contains("not found") => Response::error(
311            request_id,
312            "task_not_found",
313            crate::commands::bash_status::format_unknown_task_message(task_id),
314        ),
315        Err(message) => Response::error(request_id, "execution_failed", message),
316    }
317}
318
319fn foreground_result_response(request_id: &str, snapshot: BgTaskSnapshot) -> Response {
320    let (output, foreground_envelope) =
321        if let Some(envelope) = snapshot.bash_output_list_envelope.as_ref() {
322            let trailer = crate::list_surfaces::bash::envelope_trailer(envelope);
323            let mut foreground_snapshot = snapshot.clone();
324            foreground_snapshot.output_preview = foreground_snapshot
325                .output_preview
326                .strip_suffix(&trailer)
327                .unwrap_or(&foreground_snapshot.output_preview)
328                .trim_end_matches('\n')
329                .to_string();
330            // The envelope replaces the legacy output-path truncation clause. Exit and
331            // timeout diagnostics still render before the final canonical trailer.
332            foreground_snapshot.output_truncated = false;
333            let mut output = format_foreground_result(&foreground_snapshot);
334            let envelope = crate::list_surfaces::bash::append_envelope_trailer(
335                &mut output,
336                envelope.total.value(),
337            );
338            (output, envelope)
339        } else {
340            (format_foreground_result(&snapshot), None)
341        };
342    if snapshot.sandbox_native
343        && snapshot.sandbox_unavailable
344        && snapshot.exit_code == Some(crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE)
345    {
346        return Response::error_with_data(
347            request_id,
348            "sandbox_unavailable",
349            "native sandbox failed before the command could run; set sandbox.enabled=false to disable native sandboxing",
350            json!({
351                "task_id": snapshot.info.task_id,
352                "exit_code": snapshot.exit_code,
353                "output": output,
354            }),
355        );
356    }
357    let timed_out = snapshot.info.status == BgTaskStatus::TimedOut;
358    let mut data = json!({
359        "output": output,
360        "task_id": snapshot.info.task_id,
361        "status": snapshot.info.status,
362        "mode": snapshot.info.mode,
363        "exit_code": snapshot.exit_code,
364        "output_preview": snapshot.output_preview,
365        "output_truncated": snapshot.output_truncated,
366        "truncated": snapshot.output_truncated,
367        "output_path": snapshot.output_path,
368        "timed_out": timed_out,
369        "duration_ms": snapshot.info.duration_ms,
370    });
371    crate::list_surfaces::bash::attach_bash_output_envelope(
372        data.as_object_mut()
373            .expect("foreground bash data is an object"),
374        &foreground_envelope,
375    );
376    Response::success(request_id, data)
377}
378
379fn background_launch_response(request_id: &str, task_id: &str, is_pty: bool) -> Response {
380    Response::success(
381        request_id,
382        json!({
383            "output": format_background_launch(task_id, is_pty),
384            "task_id": task_id,
385            "status": "running",
386            "mode": if is_pty { "pty" } else { "pipes" },
387        }),
388    )
389}
390
391fn promotion_response(
392    request_id: &str,
393    task_id: &str,
394    timeout: Option<u64>,
395    wait_window_ms: u64,
396) -> Response {
397    Response::success(
398        request_id,
399        json!({
400            "output": format_promotion_message(task_id, timeout, wait_window_ms),
401            "task_id": task_id,
402            "status": "running",
403        }),
404    )
405}
406
407fn wait_detach_response(request_id: &str, task_id: &str) -> Response {
408    Response::success(
409        request_id,
410        json!({
411            "output": format_wait_detach_message(task_id),
412            "task_id": task_id,
413            "status": "running",
414        }),
415    )
416}
417
418fn parse_params(req: &RawRequest) -> Option<BashOrchestrateParams> {
419    let raw_params = req
420        .params
421        .get("params")
422        .cloned()
423        .unwrap_or_else(|| req.params.clone());
424    serde_json::from_value::<BashOrchestrateParams>(raw_params).ok()
425}
426
427pub(crate) fn resolve_foreground_wait_window_ms(configured: u64) -> u64 {
428    std::env::var(TEST_FOREGROUND_WAIT_ENV)
429        .ok()
430        .and_then(|raw| raw.parse::<u64>().ok())
431        .unwrap_or(configured)
432}
433
434pub(crate) fn select_foreground_wait_window_ms(
435    configured: u64,
436    timeout: Option<u64>,
437    wait: bool,
438) -> u64 {
439    if wait {
440        timeout.unwrap_or(DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS)
441    } else {
442        resolve_foreground_wait_window_ms(configured)
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::bash_background::persistence::BgMode;
450    use crate::bash_background::registry::BgTaskSnapshot;
451    use crate::bash_background::BgTaskInfo;
452
453    fn snapshot(
454        output_preview: &str,
455        output_truncated: bool,
456        output_path: Option<&str>,
457        status: BgTaskStatus,
458        exit_code: Option<i32>,
459    ) -> BgTaskSnapshot {
460        BgTaskSnapshot {
461            info: BgTaskInfo {
462                task_id: "bash-test".to_string(),
463                status,
464                command: "echo test".to_string(),
465                mode: BgMode::Pipes,
466                started_at: 0,
467                duration_ms: Some(1),
468                status_reason: None,
469            },
470            exit_code,
471            child_pid: None,
472            workdir: "/tmp".to_string(),
473            output_preview: output_preview.to_string(),
474            bash_output_list_envelope: None,
475            output_truncated,
476            output_path: output_path.map(str::to_string),
477            stderr_path: None,
478            pty_rows: None,
479            pty_cols: None,
480            pty_screen: None,
481            scanner_report: Vec::new(),
482            sandbox_native: false,
483            sandbox_unavailable: false,
484            live_descendants: Some(Vec::new()),
485            live_descendants_omitted: 0,
486            live_descendants_summary: None,
487            kill_signaled: false,
488            kill_reached: 0,
489        }
490    }
491
492    #[test]
493    fn native_launcher_exit_78_is_a_structured_sandbox_error() {
494        let mut snapshot = snapshot(
495            "sandbox_unavailable: backend failed",
496            false,
497            None,
498            BgTaskStatus::Failed,
499            Some(crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE),
500        );
501        snapshot.sandbox_native = true;
502        snapshot.sandbox_unavailable = true;
503
504        let response = foreground_result_response("sandbox-failed", snapshot);
505        assert!(!response.success);
506        assert_eq!(
507            response
508                .data
509                .get("code")
510                .and_then(serde_json::Value::as_str),
511            Some("sandbox_unavailable")
512        );
513        assert!(response
514            .data
515            .get("message")
516            .and_then(serde_json::Value::as_str)
517            .is_some_and(|message| message.contains("sandbox.enabled=false")));
518    }
519
520    #[test]
521    fn native_command_exit_78_is_not_misreported_as_launcher_failure() {
522        let mut snapshot = snapshot(
523            "command selected exit 78",
524            false,
525            None,
526            BgTaskStatus::Failed,
527            Some(crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE),
528        );
529        snapshot.sandbox_native = true;
530
531        let response = foreground_result_response("command-exit-78", snapshot);
532        assert!(response.success);
533        assert_eq!(
534            response
535                .data
536                .get("exit_code")
537                .and_then(serde_json::Value::as_i64),
538            Some(i64::from(
539                crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE
540            ))
541        );
542    }
543
544    #[test]
545    fn decide_bash_step_returns_done_for_terminal_snapshot_even_at_deadline() {
546        let snapshot = snapshot("done", false, None, BgTaskStatus::Completed, Some(0));
547        let now = Instant::now();
548
549        match decide_bash_step(snapshot, now, false, now, "req-terminal") {
550            BashStep::Done(response) => {
551                assert_eq!(response.id, "req-terminal");
552                assert!(response.success);
553                assert_eq!(response.data["status"], json!("completed"));
554                assert_eq!(response.data["output"], json!("done"));
555            }
556            BashStep::Promote => panic!("terminal snapshot should not promote"),
557            BashStep::Wait => panic!("terminal snapshot should not wait"),
558        }
559    }
560
561    #[test]
562    fn decide_bash_step_promotes_at_deadline_when_not_blocking() {
563        let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
564        let now = Instant::now();
565
566        match decide_bash_step(snapshot, now, false, now, "req-promote") {
567            BashStep::Promote => {}
568            BashStep::Done(_) => panic!("running snapshot should not finish"),
569            BashStep::Wait => panic!("deadline should promote when not blocking"),
570        }
571    }
572
573    #[test]
574    fn decide_bash_step_waits_before_deadline() {
575        let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
576        let now = Instant::now();
577
578        match decide_bash_step(
579            snapshot,
580            now + Duration::from_millis(1),
581            false,
582            now,
583            "req-wait",
584        ) {
585            BashStep::Wait => {}
586            BashStep::Done(_) => panic!("running snapshot should not finish"),
587            BashStep::Promote => panic!("snapshot should wait before the deadline"),
588        }
589    }
590
591    #[test]
592    fn decide_bash_step_never_promotes_when_blocking_to_completion() {
593        let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
594        let now = Instant::now();
595
596        match decide_bash_step(snapshot, now, true, now, "req-block") {
597            BashStep::Wait => {}
598            BashStep::Done(_) => panic!("running snapshot should not finish"),
599            BashStep::Promote => panic!("block_to_completion should suppress promotion"),
600        }
601    }
602
603    #[test]
604    fn select_foreground_wait_window_uses_timeout_budget_for_wait_true() {
605        assert_eq!(
606            select_foreground_wait_window_ms(8_000, Some(250), true),
607            250
608        );
609        assert_eq!(
610            select_foreground_wait_window_ms(8_000, None, true),
611            DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS
612        );
613    }
614
615    #[test]
616    fn foreground_result_format_matches_typescript_order() {
617        let snapshot = snapshot(
618            "hello",
619            true,
620            Some("/tmp/aft-output.txt"),
621            BgTaskStatus::TimedOut,
622            Some(124),
623        );
624
625        assert_eq!(
626            format_foreground_result(&snapshot),
627            "hello\n[output truncated; full output at /tmp/aft-output.txt]\n[command timed out]\n[exit code: 124]"
628        );
629    }
630
631    #[test]
632    fn format_seconds_strips_integer_decimal_and_keeps_tenths() {
633        assert_eq!(format_seconds(8_000), "8s");
634        assert_eq!(format_seconds(5_500), "5.5s");
635        assert_eq!(format_seconds(14_999), "15s");
636    }
637
638    #[test]
639    fn promotion_message_matches_opencode_copy() {
640        assert_eq!(
641            format_promotion_message("bash-123", Some(5_500), 8_000),
642            "Foreground bash didn't finish within 5.5s and was promoted to background: bash-123. A completion reminder will be delivered automatically; use bash_status({ taskId: \"bash-123\" }) to inspect output or bash_kill({ taskId: \"bash-123\" }) to terminate."
643        );
644    }
645
646    #[test]
647    fn wait_detach_message_mentions_user_message() {
648        assert_eq!(
649            format_wait_detach_message("bash-123"),
650            "Foreground bash is running in background as bash-123. A completion reminder will be delivered automatically; use bash_status({ taskId: \"bash-123\" }) to inspect output or bash_kill({ taskId: \"bash-123\" }) to terminate.\nDetached because a user message arrived."
651        );
652    }
653
654    #[test]
655    fn background_launch_messages_match_opencode_copy() {
656        assert_eq!(
657            format_background_launch("bash-bg", false),
658            "Background task started: bash-bg. A completion reminder will be delivered automatically; don't poll bash_status."
659        );
660        assert_eq!(
661            format_background_launch("bash-pty", true),
662            "PTY task started: bash-pty. Use bash_status({ taskId: \"bash-pty\", outputMode: \"screen\" }) to see the visible terminal, bash_write({ taskId: \"bash-pty\", input: ... }) to send keystrokes. A completion reminder fires automatically when the task exits."
663        );
664    }
665}