From 126e3adf729e43b6bc868fcd6e5872b7396c4eee Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 30 Jun 2026 21:27:27 +0200 Subject: [PATCH] feat(vendor/brush-core): added spawn observer hook for tracking launched external processes - Added a `SpawnObserver` trait to intercept freshly spawned external command PIDs and process group IDs. - Exposed `set_spawn_observer` and `spawn_observer` on `ExecutionParameters` to allow tracking process lifecycles. - Triggered the observer on successful external command execution unless the process is detached or reparented. --- crates/vendor/brush-core/src/commands.rs | 11 ++++++++++ crates/vendor/brush-core/src/interp.rs | 27 ++++++++++++++++++++++++ crates/vendor/brush-core/src/lib.rs | 2 +- packages/coding-agent/CHANGELOG.md | 2 ++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/vendor/brush-core/src/commands.rs b/crates/vendor/brush-core/src/commands.rs index 1c5e1ce70..6362096f8 100644 --- a/crates/vendor/brush-core/src/commands.rs +++ b/crates/vendor/brush-core/src/commands.rs @@ -689,6 +689,17 @@ pub(crate) fn execute_external_command( tracing::warn!("could not retrieve pid for child process"); } + // Report the spawned child for scoped teardown. Skipped for reparented + // launches (`detach_reparent`, e.g. `nohup cmd &`): those double-fork + // out of the descendant tree and must survive the host's cancellation + // cleanup, so they are intentionally left unowned. + if !context.params.detach_reparent + && let Some(observer) = context.params.spawn_observer() + && let Some(pid) = pid + { + observer.on_spawn(pid, actual_pgid); + } + let mut child_process = processes::ChildProcess::new(child, pid, actual_pgid); if let Some((output, markers)) = marker_output.take() { child_process.set_completion_marker( diff --git a/crates/vendor/brush-core/src/interp.rs b/crates/vendor/brush-core/src/interp.rs index cd1d98f66..f92da46c6 100644 --- a/crates/vendor/brush-core/src/interp.rs +++ b/crates/vendor/brush-core/src/interp.rs @@ -66,6 +66,21 @@ pub trait ExternalCommandOutputMarker: Send + Sync { ) -> Option; } +/// Optional hook invoked after each external command is spawned, reporting the +/// OS identity of the child. Embedders use it to scope process-tree teardown +/// (cancellation cleanup) to exactly the processes a given run launched, rather +/// than diffing the whole host process tree — which cannot distinguish the +/// children of concurrent runs sharing one host process. +/// +/// Not called for reparented launches (`detach_reparent`): those deliberately +/// escape the shell's descendant tree (e.g. `nohup cmd &`) and must survive +/// teardown, so they are intentionally left unowned. +pub trait SpawnObserver: Send + Sync { + /// Reports a freshly spawned external child. `pgid` is the child's process + /// group id when known (always its own pid under `NewProcessGroup`). + fn on_spawn(&self, pid: i32, pgid: Option); +} + /// Parameters for execution. #[derive(Clone, Default)] pub struct ExecutionParameters { @@ -88,6 +103,8 @@ pub struct ExecutionParameters { /// Whether `errexit` (exit on error) behavior should be /// suppressed in this execution context. Defaults to `false`. pub suppress_errexit: bool, + /// Optional hook reporting spawned external children for scoped teardown. + spawn_observer: Option>, } impl ExecutionParameters { @@ -128,6 +145,16 @@ impl ExecutionParameters { self.command_output_marker.as_ref() } + /// Assigns a spawn-observer hook for this execution. + pub fn set_spawn_observer(&mut self, observer: Arc) { + self.spawn_observer = Some(observer); + } + + /// Returns the active spawn-observer hook, if any. + pub fn spawn_observer(&self) -> Option<&Arc> { + self.spawn_observer.as_ref() + } + /// Returns the standard input file; usable with `write!` et al. /// /// # Arguments diff --git a/crates/vendor/brush-core/src/lib.rs b/crates/vendor/brush-core/src/lib.rs index 9b39ee031..02f248910 100644 --- a/crates/vendor/brush-core/src/lib.rs +++ b/crates/vendor/brush-core/src/lib.rs @@ -55,7 +55,7 @@ pub use error::{BuiltinError, Error, ErrorKind}; pub use extensions::ShellExtensions; pub use interp::{ ExecutionParameters, ExternalCommandInfo, ExternalCommandOutputMarker, - ExternalCommandOutputMarkers, ProcessGroupPolicy, + ExternalCommandOutputMarkers, ProcessGroupPolicy, SpawnObserver, }; pub use parser::{SourcePosition, SourcePositionOffset, SourceSpan}; pub use results::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ExecutionSpawnResult}; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7b66ddc67..7db5d97a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ - Fixed Git subcommands ignoring repository paths by stripping ambient Git environment variables +- Fixed concurrent bash commands cross-killing each other on cancel/timeout. Cancellation cleanup previously walked the whole host process tree and signalled every descendant spawned since a per-run baseline, so cancelling or timing out one command could SIGTERM an unrelated command's child still running in parallel (it looked "new" relative to the canceller's baseline). Each run now tracks only the processes it actually spawned (via a brush-core spawn-observer hook) and scopes its TERM/KILL waves to that set, leaving concurrent runs untouched. + - Fixed `/skill:` invocation losing the user's prompt context when the slash token was reached mid-prompt via the autocomplete. The slash-command parser now recognizes a `/skill:` token surrounded by whitespace in non-slash, non-local-execution drafts (in addition to the leading form) and threads the surrounding prose through to the skill as `args`, so the typed prompt survives both in the editor (see the TUI changelog) and in the dispatched skill message. Drafts that already begin with another slash command (`/compact /skill:foo`), a bash sigil (`!echo /skill:foo`, `!!echo /skill:foo`), or a python sigil (`$ run.py /skill:foo`, `$$ run.py /skill:foo`) keep their existing dispatcher precedence and are not hijacked by the mid-prompt skill parser. Applies to the interactive TUI, ACP, and RPC dispatch paths via the shared `parseSkillInvocation` helper in `extensibility/skills` ([#3913](https://github.com/can1357/oh-my-pi/issues/3913)). ## [16.2.9] - 2026-06-30