From 768b8d4c84db749cb6943e8c15beb8485e2229dc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 31 Mar 2026 14:21:51 +1100 Subject: [PATCH 1/3] fix(staged): handle carriage returns correctly in action output and add tests Extract processChunksToLines into a testable utility module and fix a bug where \r\n split across chunk boundaries was incorrectly treated as a bare \r (clearing the line) followed by a \n (pushing an empty line). Add vitest and comprehensive tests covering progress bar overwriting, CRLF handling, and cross-chunk boundary edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/package.json | 7 +- .../features/actions/ActionOutputModal.svelte | 61 +-------- .../features/actions/processOutput.test.ts | 117 ++++++++++++++++++ .../src/lib/features/actions/processOutput.ts | 98 +++++++++++++++ apps/staged/vitest.config.ts | 7 ++ pnpm-lock.yaml | 3 + 6 files changed, 231 insertions(+), 62 deletions(-) create mode 100644 apps/staged/src/lib/features/actions/processOutput.test.ts create mode 100644 apps/staged/src/lib/features/actions/processOutput.ts create mode 100644 apps/staged/vitest.config.ts diff --git a/apps/staged/package.json b/apps/staged/package.json index 5ec3a8f8..ba62cc77 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -13,7 +13,9 @@ "tauri:build": "tauri build", "tauri:release:config": "node scripts/build-tauri-release-config.mjs", "release:updater:publish": "node scripts/publish-updater-to-github-release.mjs", - "release:dmg:publish": "node scripts/publish-dmg-to-github-release.mjs" + "release:dmg:publish": "node scripts/publish-dmg-to-github-release.mjs", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -25,7 +27,8 @@ "svelte": "^5.46.4", "svelte-check": "^4.3.4", "typescript": "~5.9.3", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.0.18" }, "dependencies": { "@builderbot/diff-viewer": "workspace:*", diff --git a/apps/staged/src/lib/features/actions/ActionOutputModal.svelte b/apps/staged/src/lib/features/actions/ActionOutputModal.svelte index 9903a8f8..dee52c2c 100644 --- a/apps/staged/src/lib/features/actions/ActionOutputModal.svelte +++ b/apps/staged/src/lib/features/actions/ActionOutputModal.svelte @@ -42,6 +42,7 @@ listenToActionOutput, listenToActionStatus, } from './actions'; + import { processChunksToLines, type TerminalLine } from './processOutput'; interface Props { executionId: string; @@ -67,12 +68,6 @@ // State // ========================================================================= - /** A processed terminal line ready for display. */ - interface TerminalLine { - text: string; - stream: 'stdout' | 'stderr'; - } - let status = $state('running'); let exitCode = $state(null); let outputChunks = $state([]); @@ -151,60 +146,6 @@ } } - /** - * Process raw output chunks into terminal lines, handling carriage returns. - * - * Terminal programs use \r (carriage return without newline) to overwrite the - * current line in-place — e.g. for progress bars. This function simulates - * that behavior: - * - \n finalizes the current line and starts a new one - * - \r (not followed by \n) resets the cursor to the start of the current - * line so subsequent text overwrites it - */ - function processChunksToLines(chunks: OutputChunk[]): TerminalLine[] { - const lines: TerminalLine[] = []; - let currentText = ''; - let currentStream: 'stdout' | 'stderr' = 'stdout'; - - for (const chunk of chunks) { - const raw = chunk.chunk; - const stream = chunk.stream; - - for (let i = 0; i < raw.length; i++) { - const ch = raw[i]; - - if (ch === '\n') { - // Newline: finalize the current line and start a new one - lines.push({ text: currentText, stream: currentStream }); - currentText = ''; - currentStream = stream; - } else if (ch === '\r') { - // Carriage return: check if it's \r\n (treat as plain newline) - if (i + 1 < raw.length && raw[i + 1] === '\n') { - lines.push({ text: currentText, stream: currentStream }); - currentText = ''; - currentStream = stream; - i++; // skip the \n - } else { - // Bare \r: reset cursor to start of current line (overwrite) - currentText = ''; - currentStream = stream; - } - } else { - currentText += ch; - currentStream = stream; - } - } - } - - // Don't forget the last in-progress line - if (currentText.length > 0) { - lines.push({ text: currentText, stream: currentStream }); - } - - return lines; - } - /** Derived display lines — recomputed whenever outputChunks changes. */ let displayLines = $derived(processChunksToLines(outputChunks)); diff --git a/apps/staged/src/lib/features/actions/processOutput.test.ts b/apps/staged/src/lib/features/actions/processOutput.test.ts new file mode 100644 index 00000000..7add2875 --- /dev/null +++ b/apps/staged/src/lib/features/actions/processOutput.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { processChunksToLines } from './processOutput'; +import type { OutputChunk } from './actions'; + +/** Helper to build an OutputChunk. */ +function chunk(text: string, stream: 'stdout' | 'stderr' = 'stdout'): OutputChunk { + return { chunk: text, stream, timestamp: 0 }; +} + +/** Helper to extract just the text from result lines. */ +function texts(chunks: OutputChunk[]): string[] { + return processChunksToLines(chunks).map((l) => l.text); +} + +describe('processChunksToLines', () => { + // --------------------------------------------------------------------------- + // Basic newline handling + // --------------------------------------------------------------------------- + + it('splits on \\n', () => { + expect(texts([chunk('hello\nworld\n')])).toEqual(['hello', 'world']); + }); + + it('keeps a trailing line that has no terminating newline', () => { + expect(texts([chunk('hello\nworld')])).toEqual(['hello', 'world']); + }); + + it('handles empty input', () => { + expect(texts([])).toEqual([]); + }); + + it('handles a single chunk with no newlines', () => { + expect(texts([chunk('hello')])).toEqual(['hello']); + }); + + // --------------------------------------------------------------------------- + // \\r\\n (CRLF) handling + // --------------------------------------------------------------------------- + + it('treats \\r\\n as a single newline', () => { + expect(texts([chunk('hello\r\nworld\r\n')])).toEqual(['hello', 'world']); + }); + + it('handles \\r\\n split across two chunks', () => { + expect(texts([chunk('hello\r'), chunk('\nworld')])).toEqual(['hello', 'world']); + }); + + // --------------------------------------------------------------------------- + // Bare \\r (carriage return) — progress bar behavior + // --------------------------------------------------------------------------- + + it('bare \\r overwrites the current line (single chunk)', () => { + expect(texts([chunk('progress 50%\rprogress 100%\n')])).toEqual(['progress 100%']); + }); + + it('bare \\r overwrites across multiple updates in one chunk', () => { + expect(texts([chunk('10%\r20%\r30%\r40%\n')])).toEqual(['40%']); + }); + + it('bare \\r overwrites across separate chunks', () => { + expect(texts([chunk('downloading 50%\r'), chunk('downloading 100%\n')])).toEqual([ + 'downloading 100%', + ]); + }); + + it('bare \\r at end of chunk followed by non-\\n text in next chunk overwrites', () => { + expect(texts([chunk('old text\r'), chunk('new text')])).toEqual(['new text']); + }); + + it('multiple progress updates across separate chunks collapse correctly', () => { + expect(texts([chunk('10%\r'), chunk('20%\r'), chunk('30%\r'), chunk('done\n')])).toEqual([ + 'done', + ]); + }); + + it('progress bar followed by normal output', () => { + expect(texts([chunk('building...\rprogress 50%\rprogress 100%\nSuccess!\n')])).toEqual([ + 'progress 100%', + 'Success!', + ]); + }); + + // --------------------------------------------------------------------------- + // Mixed scenarios + // --------------------------------------------------------------------------- + + it('handles normal lines before and after progress bars', () => { + expect( + texts([chunk('Starting build\n'), chunk('0%\r50%\r100%\n'), chunk('Build complete\n')]) + ).toEqual(['Starting build', '100%', 'Build complete']); + }); + + it('handles interleaved stdout and stderr', () => { + const result = processChunksToLines([ + chunk('out line\n', 'stdout'), + chunk('err line\n', 'stderr'), + ]); + expect(result).toEqual([ + { text: 'out line', stream: 'stdout' }, + { text: 'err line', stream: 'stderr' }, + ]); + }); + + it('bare \\r at the very end of all chunks produces no trailing line', () => { + // A progress bar that never finishes with \n — should show nothing + // because the \r resets the line and there's no further content. + expect(texts([chunk('in progress\r')])).toEqual([]); + }); + + it('empty lines are preserved', () => { + expect(texts([chunk('a\n\nb\n')])).toEqual(['a', '', 'b']); + }); + + it('handles chunk boundaries mid-text', () => { + expect(texts([chunk('hel'), chunk('lo\nwor'), chunk('ld\n')])).toEqual(['hello', 'world']); + }); +}); diff --git a/apps/staged/src/lib/features/actions/processOutput.ts b/apps/staged/src/lib/features/actions/processOutput.ts new file mode 100644 index 00000000..3945d665 --- /dev/null +++ b/apps/staged/src/lib/features/actions/processOutput.ts @@ -0,0 +1,98 @@ +/** + * Terminal output processing utilities. + * + * Converts raw output chunks (as received from the backend) into display-ready + * terminal lines, handling carriage returns the way a real terminal would. + */ + +import type { OutputChunk } from './actions'; + +/** A processed terminal line ready for display. */ +export interface TerminalLine { + text: string; + stream: 'stdout' | 'stderr'; +} + +/** + * Process raw output chunks into terminal lines, handling carriage returns. + * + * Terminal programs use \r (carriage return without newline) to overwrite the + * current line in-place — e.g. for progress bars. This function simulates + * that behavior: + * - \n finalizes the current line and starts a new one + * - \r\n is treated as a single newline + * - \r (not followed by \n) resets the cursor to the start of the current + * line so subsequent text overwrites it + * + * Because chunks arrive in arbitrary byte boundaries, \r\n may be split across + * two consecutive chunks. We track this with `pendingCR` so the \r at the end + * of one chunk and the \n at the start of the next are still treated as a + * single newline. + */ +export function processChunksToLines(chunks: OutputChunk[]): TerminalLine[] { + const lines: TerminalLine[] = []; + let currentText = ''; + let currentStream: 'stdout' | 'stderr' = 'stdout'; + let pendingCR = false; + + for (const chunk of chunks) { + const raw = chunk.chunk; + const stream = chunk.stream; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + + if (pendingCR) { + pendingCR = false; + if (ch === '\n') { + // \r\n split across chunks — treat as a single newline + lines.push({ text: currentText, stream: currentStream }); + currentText = ''; + currentStream = stream; + continue; + } else { + // The previous \r was a bare carriage return — reset the line + currentText = ''; + currentStream = stream; + } + } + + if (ch === '\n') { + lines.push({ text: currentText, stream: currentStream }); + currentText = ''; + currentStream = stream; + } else if (ch === '\r') { + if (i + 1 < raw.length && raw[i + 1] === '\n') { + // \r\n within the same chunk + lines.push({ text: currentText, stream: currentStream }); + currentText = ''; + currentStream = stream; + i++; // skip the \n + } else if (i + 1 < raw.length) { + // Bare \r with more data in this chunk: reset cursor (overwrite) + currentText = ''; + currentStream = stream; + } else { + // \r at the very end of the chunk — defer decision until next chunk + pendingCR = true; + } + } else { + currentText += ch; + currentStream = stream; + } + } + } + + // If the last chunk ended with a bare \r that was never resolved, treat it + // as a carriage return (reset the line). + if (pendingCR) { + currentText = ''; + } + + // Don't forget the last in-progress line + if (currentText.length > 0) { + lines.push({ text: currentText, stream: currentStream }); + } + + return lines; +} diff --git a/apps/staged/vitest.config.ts b/apps/staged/vitest.config.ts new file mode 100644 index 00000000..6ec74eee --- /dev/null +++ b/apps/staged/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99ef4b46..40b22649 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: vite: specifier: ^7.2.4 version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@24.11.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1) packages/diff-viewer: dependencies: From 24cef2fea55f157cf2c89bafc2acbb679b91cade Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 31 Mar 2026 14:31:17 +1100 Subject: [PATCH 2/3] fix(staged): include frontend vitest tests in justfile ci and check-all recipes The test recipe only ran Rust tests, so the new vitest frontend tests were never executed by the justfile or the pre-push git hook. Add a test-frontend recipe and wire it into ci and check-all. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/justfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/staged/justfile b/apps/staged/justfile index 295a523f..f825d91d 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -150,15 +150,19 @@ lint: test: cd src-tauri && cargo test +# Run frontend unit tests +test-frontend: + pnpm test + # Type-check frontend (Svelte + TypeScript) typecheck: pnpm run check # Format, then verify everything passes — run before pushing -check-all: fmt lint typecheck test +check-all: fmt lint typecheck test test-frontend # Verify everything without modifying files — for CI -ci: fmt-check lint typecheck test +ci: fmt-check lint typecheck test test-frontend # ============================================================================ # Maintenance From 91b9626149ce4fd21f5f664f767d158036d90809 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 31 Mar 2026 14:41:38 +1100 Subject: [PATCH 3/3] fix(staged): use PTY for action output so progress bars overwrite correctly Child processes detect they're writing to a pipe (not a TTY) and fall back to \n-separated output instead of \r-based overwriting for progress bars. Fix this by opening a PTY pair and redirecting the child's stdout/stderr to the PTY slave, so isatty() returns true. The PTY master is read in the parent for output streaming. Key changes: - Add pty module with open() and configure() helpers using libc::openpty - Disable echo on PTY so piped stdin commands don't appear in output - Set 200x50 terminal size to prevent progress bar line wrapping - Redirect stdout/stderr to PTY slave via dup2 in pre_exec - Read output from PTY master (single merged stream) instead of pipes - Remote execution still uses pipes (PTY only affects local execution) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/builderbot-actions/src/executor.rs | 200 +++++++++++++++++++--- 1 file changed, 176 insertions(+), 24 deletions(-) diff --git a/crates/builderbot-actions/src/executor.rs b/crates/builderbot-actions/src/executor.rs index 5f92d61d..4facafd6 100644 --- a/crates/builderbot-actions/src/executor.rs +++ b/crates/builderbot-actions/src/executor.rs @@ -17,6 +17,79 @@ use tokio::sync::oneshot; use crate::git::{auto_commit_if_changes, auto_commit_if_changes_remote}; use crate::models::{ActionStatus, ExecutionEvent, OutputChunk}; +// ============================================================================= +// PTY support (unix only) +// ============================================================================= + +#[cfg(unix)] +mod pty { + use std::os::fd::RawFd; + + // openpty is not exposed by the libc crate on all platforms, so we + // declare the extern ourselves. It is available on macOS () + // and Linux (). + extern "C" { + fn openpty( + amaster: *mut RawFd, + aslave: *mut RawFd, + name: *mut libc::c_char, + termp: *const libc::termios, + winp: *const libc::winsize, + ) -> libc::c_int; + } + + /// Open a PTY pair and return `(master_fd, slave_fd)`. + pub fn open() -> std::io::Result<(RawFd, RawFd)> { + let mut master: RawFd = -1; + let mut slave: RawFd = -1; + + // SAFETY: openpty writes into the provided pointers and returns 0 + // on success, -1 on failure. The NULL pointers for name/termp/winp + // are explicitly allowed by the API. + let ret = unsafe { + openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }; + if ret != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok((master, slave)) + } + + /// Configure the PTY master: disable echo (so piped stdin commands don't + /// appear in the output) and set a reasonable terminal size. + pub fn configure(master_fd: RawFd) -> std::io::Result<()> { + // Disable echo so commands written to stdin are not reflected in output. + // SAFETY: tcgetattr/tcsetattr operate on a valid fd returned by openpty. + unsafe { + let mut termios: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(master_fd, &mut termios) != 0 { + return Err(std::io::Error::last_os_error()); + } + termios.c_lflag &= !(libc::ECHO | libc::ECHOE | libc::ECHOK | libc::ECHONL); + if libc::tcsetattr(master_fd, libc::TCSANOW, &termios) != 0 { + return Err(std::io::Error::last_os_error()); + } + } + + // Set a wide terminal size so progress bars don't wrap. + // SAFETY: ioctl with TIOCSWINSZ writes the winsize struct to the tty. + unsafe { + let mut ws: libc::winsize = std::mem::zeroed(); + ws.ws_col = 200; + ws.ws_row = 50; + libc::ioctl(master_fd, libc::TIOCSWINSZ, &ws); + } + + Ok(()) + } +} + /// Trait for receiving execution events #[async_trait] pub trait ExecutionListener: Send + Sync { @@ -109,6 +182,9 @@ fn spawn_stream_reader( .await; }); } + // PTY master returns EIO (not EOF) when the slave side is + // closed, which is the normal shutdown path. Treat all read + // errors as end-of-stream. Err(_) => break, } } @@ -131,12 +207,17 @@ impl ActionExecutor { /// /// If `auto_commit_ctx` is `Some`, auto-commit will be attempted after a /// successful execution (local only). + /// + /// If `pty_reader` is `Some`, it is used as the single output stream + /// (tagged as `"stdout"`) instead of the child's piped stdout/stderr. + /// This is used for local execution where a PTY merges both streams. async fn manage_child_process( &self, mut child: Child, metadata: ActionMetadata, listener: Arc, auto_commit_ctx: Option, + pty_reader: Option, ) -> Result<(String, oneshot::Receiver<()>)> { let execution_id = uuid::Uuid::new_v4().to_string(); let (completion_tx, completion_rx) = oneshot::channel(); @@ -172,27 +253,43 @@ impl ActionExecutor { // command handler), so `Handle::current()` is guaranteed to succeed. let handle = Handle::current(); - // Spawn threads to read stdout and stderr - if let Some(stdout) = child.stdout.take() { + // Spawn threads to read output. + // + // When a PTY reader is provided (local execution), we use it as the + // single output stream — the PTY merges stdout and stderr, matching + // real terminal behavior. Otherwise (remote execution), we read from + // the child's piped stdout and stderr separately. + if let Some(pty_master) = pty_reader { spawn_stream_reader( - stdout, + pty_master, "stdout", execution_id.clone(), listener.clone(), output_buffer.clone(), handle.clone(), ); - } + } else { + if let Some(stdout) = child.stdout.take() { + spawn_stream_reader( + stdout, + "stdout", + execution_id.clone(), + listener.clone(), + output_buffer.clone(), + handle.clone(), + ); + } - if let Some(stderr) = child.stderr.take() { - spawn_stream_reader( - stderr, - "stderr", - execution_id.clone(), - listener.clone(), - output_buffer.clone(), - handle.clone(), - ); + if let Some(stderr) = child.stderr.take() { + spawn_stream_reader( + stderr, + "stderr", + execution_id.clone(), + listener.clone(), + output_buffer.clone(), + handle.clone(), + ); + } } // Spawn thread to wait for completion @@ -311,6 +408,16 @@ impl ActionExecutor { // When using -c, the command runs immediately before hooks can activate Hermit. let commands = format!("{}\nexit\n", command); + // ---- PTY setup (unix) ------------------------------------------------- + // Open a PTY so child processes see a real terminal and use \r for + // progress bars instead of falling back to \n-separated output. + #[cfg(unix)] + let (pty_master_fd, pty_slave_fd) = pty::open().context("Failed to open PTY")?; + #[cfg(unix)] + { + pty::configure(pty_master_fd).context("Failed to configure PTY")?; + } + // Use interactive (-i) + login (-l) + stdin (-s) with stdin piping to ensure: // 1. Interactive mode triggers directory-based hooks (like Hermit's chpwd/precmd) // 2. Login shell loads the full environment @@ -325,30 +432,75 @@ impl ActionExecutor { .arg("-i") // Interactive shell to trigger hooks like chpwd for Hermit .arg("-l") // Login shell to load profile .arg("-s") // Force shell to read commands from stdin (required for non-TTY) - .stdin(Stdio::piped()) // Pipe stdin to send commands after initialization - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .stdin(Stdio::piped()); // Pipe stdin to send commands after initialization - // Create a new process group so we can kill the shell AND all its children. - // Without this, SIGTERM only reaches the shell process, leaving child - // processes (e.g. `npm run build`, `cargo build`) running as orphans. + // On unix, redirect stdout/stderr to the PTY slave in the child process. + // On other platforms, fall back to piped stdout/stderr. #[cfg(unix)] { use std::os::unix::process::CommandExt; - // SAFETY: `setsid()` is an async-signal-safe function that creates a new - // session and process group. It is safe to call in a pre_exec hook. + + // stdout/stderr will be overridden by dup2 in pre_exec, so use null + // as a placeholder (the child never writes to these fds directly). + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + + // SAFETY: All functions called here are async-signal-safe: + // - setsid(): creates new session/process group + // - ioctl(TIOCSCTTY): sets controlling terminal + // - dup2(): duplicates file descriptors + // - close(): closes file descriptor unsafe { - cmd.pre_exec(|| { + cmd.pre_exec(move || { // Create a new session (and process group) for this child. // All processes spawned by the shell will inherit this group. libc::setsid(); + + // Make the PTY slave the controlling terminal for the session. + libc::ioctl(pty_slave_fd, libc::TIOCSCTTY as _, 0); + + // Redirect stdout and stderr to the PTY slave so child + // processes see a real terminal (isatty() returns true). + libc::dup2(pty_slave_fd, libc::STDOUT_FILENO); + libc::dup2(pty_slave_fd, libc::STDERR_FILENO); + + // Close the original slave fd (it has been duplicated above). + if pty_slave_fd > libc::STDERR_FILENO { + libc::close(pty_slave_fd); + } + Ok(()) }); } } + #[cfg(not(unix))] + { + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + } + let mut child = cmd.spawn().context("Failed to spawn action process")?; + // Close the slave fd in the parent — the child inherited it via fork. + #[cfg(unix)] + { + // SAFETY: close() on a valid fd returned by openpty is safe. + // The fd is no longer needed in the parent process. + unsafe { + libc::close(pty_slave_fd); + } + } + + // Wrap the PTY master fd in a File for reading. + #[cfg(unix)] + let pty_reader = { + use std::os::fd::FromRawFd; + // SAFETY: master_fd is a valid fd returned by openpty. Wrapping it + // in a File transfers ownership — File will close it on drop. + Some(unsafe { std::fs::File::from_raw_fd(pty_master_fd) }) + }; + #[cfg(not(unix))] + let pty_reader: Option = None; + // Write commands to stdin, flush, and close it if let Some(mut stdin) = child.stdin.take() { let commands_clone = commands.clone(); @@ -368,7 +520,7 @@ impl ActionExecutor { let auto_commit_ctx = AutoCommitContext::Local { working_dir }; - self.manage_child_process(child, metadata, listener, Some(auto_commit_ctx)) + self.manage_child_process(child, metadata, listener, Some(auto_commit_ctx), pty_reader) .await } @@ -463,7 +615,7 @@ impl ActionExecutor { }); let (execution_id, _completion_rx) = self - .manage_child_process(child, metadata, listener, auto_commit_ctx) + .manage_child_process(child, metadata, listener, auto_commit_ctx, None) .await?; Ok(execution_id) }