Skip to main content

harn_hostlib/tools/
long_running.rs

1//! Long-running tool handle machinery.
2//!
3//! When a caller passes `long_running: true` to `run_command`, `run_test`, or
4//! `run_build_command`, the builtin spawns the child process without waiting,
5//! registers it here, and returns a handle dict immediately:
6//!
7//! ```json
8//! {
9//!   "handle_id": "hto-<pid-hex>-<n>",
10//!   "started_at": "...",
11//!   "command_or_op_descriptor": "..."
12//! }
13//! ```
14//!
15//! A background thread waits for the child and, when it exits, pushes a
16//! `tool_result` entry into the active session's `agent_inbox` via
17//! `harn_vm::orchestration::agent_inbox::push(...)` so the agent-loop's
18//! next turn-preflight (or post-compaction drain) picks it up.
19//!
20//! ### Cancellation
21//!
22//! `cancel_handle(handle_id)` kills the spawned process (SIGKILL) within
23//! 2 seconds. The session-end hook registered on startup kills every
24//! in-flight handle associated with the ending session.
25//!
26//! #### PID-based signaling
27//!
28//! The waiter thread takes ownership of the `Child` object to drain
29//! stdout/stderr and call `wait()`. To keep cancellation possible even
30//! after the waiter has taken the `Child`, we store the raw OS process ID
31//! in the entry and kill by PID when needed. On Unix we call `kill(2)`
32//! directly via an `extern "C"` declaration (no `libc` crate required).
33//! A shared `cancelled` flag suppresses the feedback push when the waiter
34//! sees an exit caused by cancellation. Callers that need artifact-stable
35//! cancellation can opt into waiting for the waiter result through
36//! `cancel_handle`.
37
38use std::collections::BTreeMap;
39use std::io::{Read, Write};
40use std::path::PathBuf;
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::{Arc, LazyLock, Mutex, OnceLock};
43use std::time::Duration;
44
45use harn_vm::VmValue;
46
47use crate::error::HostlibError;
48use crate::json::vm_dict_to_json;
49use crate::process::{self as process_handle, ProcessHandle, ProcessKiller, SpawnSpec};
50use crate::tools::args::to_agent_path;
51use crate::tools::proc::{self, CaptureConfig, CommandStatus, EnvMode};
52
53/// Atomic counter for generating unique handle IDs within this process.
54static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
55
56/// Shared cancellation state between the store entry and its waiter thread.
57struct CancelState {
58    /// Set to `true` when `cancel_handle` / `cancel_session_handles` runs.
59    /// The waiter checks this before pushing feedback.
60    cancelled: AtomicBool,
61    /// Set by cancellation paths that represent a timeout rather than a
62    /// user-requested kill. The waiter uses this for the returned result
63    /// status while still suppressing inbox feedback.
64    timed_out: AtomicBool,
65    /// Structural process-tree cleanup evidence returned by the killer.
66    process_cleanup: Mutex<Option<process_handle::ProcessCleanupReport>>,
67}
68
69#[derive(Default)]
70struct OutputState {
71    stdout: Vec<u8>,
72    stderr: Vec<u8>,
73}
74
75/// Shared state for a single in-flight child process.
76struct HandleEntry {
77    /// The process handle. `None` after the waiter thread takes ownership.
78    handle: Option<Box<dyn ProcessHandle>>,
79    /// Killer that works even after the waiter took `handle`.
80    killer: Arc<dyn ProcessKiller>,
81    session_id: String,
82    /// Shared with the waiter thread.
83    cancel_state: Arc<CancelState>,
84    /// Sender used by the waiter thread to signal that the post-exit
85    /// feedback push is complete. `None` if the test-side hasn't asked
86    /// to be notified.
87    completion_tx: Option<std::sync::mpsc::SyncSender<()>>,
88    /// One-shot result channels installed by callers that need to synchronize
89    /// on the finalized command result instead of observing it indirectly
90    /// through the session inbox.
91    result_txs: Vec<std::sync::mpsc::SyncSender<VmValue>>,
92    /// Opaque verification snapshot binding provided by the caller.
93    snapshot_binding: Option<harn_vm::value::DictMap>,
94}
95
96#[derive(Default)]
97struct HandleStore {
98    entries: BTreeMap<String, HandleEntry>,
99}
100
101static HANDLE_STORE: LazyLock<Mutex<HandleStore>> =
102    LazyLock::new(|| Mutex::new(HandleStore::default()));
103
104type HandleNotifiers = (
105    Option<std::sync::mpsc::SyncSender<()>>,
106    Vec<std::sync::mpsc::SyncSender<VmValue>>,
107);
108
109fn take_handle_notifiers(handle_id: &str) -> HandleNotifiers {
110    let mut store = HANDLE_STORE
111        .lock()
112        .expect("long-running handle store poisoned");
113    store
114        .entries
115        .remove(handle_id)
116        .map(|mut entry| {
117            (
118                entry.completion_tx.take(),
119                std::mem::take(&mut entry.result_txs),
120            )
121        })
122        .unwrap_or((None, Vec::new()))
123}
124
125/// Metadata returned to the caller immediately when a long-running spawn
126/// succeeds. Serialised as a response dict by the calling builtin.
127pub struct LongRunningHandleInfo {
128    /// Command identifier shared with foreground command responses.
129    pub command_id: String,
130    /// Opaque handle identifier, e.g. `"hto-<pid-hex>-<n>"`.
131    pub handle_id: String,
132    /// RFC 3339 timestamp of the spawn.
133    pub started_at: String,
134    /// Raw child process id reported by the platform.
135    pub pid: u32,
136    /// Child process group id when the platform exposes it.
137    pub process_group_id: Option<u32>,
138    /// Human-readable display form of the argv (space-joined).
139    pub command_display: String,
140    /// Opaque verification snapshot binding provided by the caller.
141    pub snapshot_binding: Option<harn_vm::value::DictMap>,
142}
143
144pub(crate) struct LongRunningSpawnOptions {
145    pub(crate) env_mode: EnvMode,
146    pub(crate) env_remove: Vec<String>,
147    pub(crate) capture: CaptureConfig,
148    pub(crate) session_id: String,
149    pub(crate) progress_interval: Option<Duration>,
150    pub(crate) progress_max_inline_bytes: usize,
151    pub(crate) snapshot_binding: Option<harn_vm::value::DictMap>,
152}
153
154struct WaiterContext {
155    command_id: String,
156    handle_id: String,
157    session_id: String,
158    started_at: String,
159    process_group_id: Option<u32>,
160    command_display: String,
161    progress_interval: Option<Duration>,
162    progress_max_inline_bytes: usize,
163    snapshot_binding: Option<harn_vm::value::DictMap>,
164}
165
166struct ProgressThreadContext {
167    command_id: String,
168    handle_id: String,
169    session_id: String,
170    started_at: String,
171    command_display: String,
172    process_group_id: Option<u32>,
173    output_path: PathBuf,
174    stdout_path: PathBuf,
175    stderr_path: PathBuf,
176    output_state: Arc<Mutex<OutputState>>,
177    cancel_state: Arc<CancelState>,
178    done: Arc<AtomicBool>,
179    started: std::time::Instant,
180    interval: Duration,
181    max_inline_bytes: usize,
182    snapshot_binding: Option<harn_vm::value::DictMap>,
183}
184
185impl LongRunningHandleInfo {
186    /// Convert into the standard handle response dict returned to the agent.
187    pub fn into_handle_response(self) -> VmValue {
188        let Self {
189            command_id,
190            handle_id,
191            started_at,
192            pid,
193            process_group_id,
194            command_display,
195            snapshot_binding,
196        } = self;
197        proc::running_response(
198            command_id,
199            handle_id,
200            pid,
201            process_group_id,
202            started_at,
203            command_display,
204            snapshot_binding.as_ref(),
205        )
206    }
207}
208
209/// Spawn the argv as a long-running child process and return a handle.
210///
211/// The background waiter pushes a `tool_result` entry into the active
212/// session's `agent_inbox` when the process exits so the next
213/// agent-loop turn sees the result.
214pub fn spawn_long_running(
215    builtin: &'static str,
216    program: String,
217    args: Vec<String>,
218    cwd: Option<PathBuf>,
219    env: BTreeMap<String, String>,
220    session_id: String,
221) -> Result<LongRunningHandleInfo, HostlibError> {
222    spawn_long_running_with_options(
223        builtin,
224        program,
225        args,
226        cwd,
227        env,
228        LongRunningSpawnOptions {
229            env_mode: EnvMode::InheritClean,
230            env_remove: Vec::new(),
231            capture: CaptureConfig::default(),
232            session_id,
233            progress_interval: None,
234            progress_max_inline_bytes: CaptureConfig::default().max_inline_bytes,
235            snapshot_binding: None,
236        },
237    )
238}
239
240pub(crate) fn spawn_long_running_with_options(
241    builtin: &'static str,
242    program: String,
243    args: Vec<String>,
244    cwd: Option<PathBuf>,
245    env: BTreeMap<String, String>,
246    options: LongRunningSpawnOptions,
247) -> Result<LongRunningHandleInfo, HostlibError> {
248    let mut env = env;
249    proc::apply_toolchain_path(cwd.as_deref(), &mut env, options.env_mode);
250    let spec = SpawnSpec {
251        builtin,
252        program: program.clone(),
253        args: args.clone(),
254        cwd,
255        env,
256        env_remove: options.env_remove.clone(),
257        env_mode: options.env_mode,
258        use_stdin: false,
259        configure_process_group: true,
260        output_capture: process_handle::OutputCapture::Pipe,
261    };
262    let handle = process_handle::spawn_process(spec)
263        .map_err(|e| proc::process_error_to_hostlib(builtin, e))?;
264
265    let pid = handle.pid().unwrap_or(0);
266    let process_group_id = handle.process_group_id();
267    let killer = handle.killer();
268    let id = HANDLE_COUNTER.fetch_add(1, Ordering::SeqCst);
269    let handle_id = format!("hto-{:x}-{id}", std::process::id());
270    let command_id = proc::next_command_id();
271    let started_at = proc::now_rfc3339();
272    let _artifacts = proc::register_live_artifacts(&command_id, Some(&handle_id))?;
273
274    let mut all_argv = vec![program];
275    all_argv.extend(args.iter().cloned());
276    let command_display = all_argv.join(" ");
277
278    let cancel_state = Arc::new(CancelState {
279        cancelled: AtomicBool::new(false),
280        timed_out: AtomicBool::new(false),
281        process_cleanup: Mutex::new(None),
282    });
283
284    {
285        let mut store = HANDLE_STORE
286            .lock()
287            .expect("long-running handle store poisoned");
288        store.entries.insert(
289            handle_id.clone(),
290            HandleEntry {
291                handle: Some(handle),
292                killer,
293                session_id: options.session_id.clone(),
294                cancel_state: cancel_state.clone(),
295                completion_tx: None,
296                result_txs: Vec::new(),
297                snapshot_binding: options.snapshot_binding.clone(),
298            },
299        );
300    }
301
302    let waiter_context = WaiterContext {
303        command_id: command_id.clone(),
304        handle_id: handle_id.clone(),
305        session_id: options.session_id,
306        started_at: started_at.clone(),
307        process_group_id,
308        command_display: command_display.clone(),
309        progress_interval: options.progress_interval,
310        progress_max_inline_bytes: options.progress_max_inline_bytes,
311        snapshot_binding: options.snapshot_binding.clone(),
312    };
313    let waiter_thread_name = waiter_context.handle_id.clone();
314    let capture = options.capture;
315    std::thread::Builder::new()
316        .name(format!("hto-waiter-{waiter_thread_name}"))
317        .spawn(move || {
318            waiter_thread(waiter_context, cancel_state, capture);
319        })
320        .map_err(|e| HostlibError::Backend {
321            builtin,
322            message: format!("failed to spawn waiter thread: {e}"),
323        })?;
324
325    Ok(LongRunningHandleInfo {
326        command_id,
327        handle_id,
328        started_at,
329        pid,
330        process_group_id,
331        command_display,
332        snapshot_binding: options.snapshot_binding,
333    })
334}
335
336/// Background thread that waits for a child process and fires feedback.
337fn waiter_thread(context: WaiterContext, cancel_state: Arc<CancelState>, capture: CaptureConfig) {
338    let waiter_start = std::time::Instant::now();
339
340    // Take the handle out of the store. If the entry is already gone (i.e.
341    // cancel_handle ran and removed it before us), exit without action.
342    let mut handle = {
343        let mut store = HANDLE_STORE
344            .lock()
345            .expect("long-running handle store poisoned");
346        match store.entries.get_mut(&context.handle_id) {
347            Some(entry) => match entry.handle.take() {
348                Some(h) => h,
349                None => return, // already cancelled before we ran
350            },
351            None => return, // entry removed (cancelled before store insert — shouldn't happen)
352        }
353    };
354
355    let output_state = Arc::new(Mutex::new(OutputState::default()));
356    let done = Arc::new(AtomicBool::new(false));
357    let planned = proc::planned_artifact_paths(&context.command_id);
358    if let Some(parent) = planned.output_path.parent() {
359        let _ = std::fs::create_dir_all(parent);
360    }
361    let _ = std::fs::File::create(&planned.stdout_path);
362    let _ = std::fs::File::create(&planned.stderr_path);
363    let combined_file = std::fs::File::create(&planned.output_path)
364        .ok()
365        .map(|file| Arc::new(Mutex::new(file)));
366
367    let stdout_thread = handle.take_stdout().map(|out| {
368        spawn_output_drain(
369            out,
370            output_state.clone(),
371            planned.stdout_path.clone(),
372            combined_file.clone(),
373            true,
374        )
375    });
376    let stderr_thread = handle.take_stderr().map(|err| {
377        spawn_output_drain(
378            err,
379            output_state.clone(),
380            planned.stderr_path.clone(),
381            combined_file.clone(),
382            false,
383        )
384    });
385
386    let progress_thread = context
387        .progress_interval
388        .filter(|interval| !interval.is_zero())
389        .map(|interval| {
390            spawn_progress_thread(ProgressThreadContext {
391                command_id: context.command_id.clone(),
392                handle_id: context.handle_id.clone(),
393                session_id: context.session_id.clone(),
394                started_at: context.started_at.clone(),
395                command_display: context.command_display.clone(),
396                process_group_id: context.process_group_id,
397                output_path: planned.output_path.clone(),
398                stdout_path: planned.stdout_path.clone(),
399                stderr_path: planned.stderr_path.clone(),
400                output_state: output_state.clone(),
401                cancel_state: cancel_state.clone(),
402                done: done.clone(),
403                started: waiter_start,
404                interval,
405                max_inline_bytes: context.progress_max_inline_bytes,
406                snapshot_binding: context.snapshot_binding.clone(),
407            })
408        });
409
410    let status = handle.wait().ok();
411
412    if let Some(thread) = stdout_thread {
413        let _ = thread.join();
414    }
415    if let Some(thread) = stderr_thread {
416        let _ = thread.join();
417    }
418    done.store(true, Ordering::Release);
419    drop(progress_thread);
420    let (stdout, stderr) = {
421        let state = output_state
422            .lock()
423            .unwrap_or_else(|poison| poison.into_inner());
424        (state.stdout.clone(), state.stderr.clone())
425    };
426
427    let cancelled = cancel_state.cancelled.load(Ordering::Acquire);
428    let timed_out = cancelled && cancel_state.timed_out.load(Ordering::Acquire);
429    let process_cleanup = cancel_state
430        .process_cleanup
431        .lock()
432        .unwrap_or_else(|poison| poison.into_inner())
433        .clone();
434
435    let (exit_code, signal_name) = match status {
436        Some(s) => decode_exit_status(s),
437        // wait() itself failed — treat as killed (extremely unusual).
438        None => (-1, Some("SIGKILL".to_string())),
439    };
440    let command_status = if timed_out {
441        CommandStatus::TimedOut
442    } else if cancelled {
443        CommandStatus::Killed
444    } else {
445        CommandStatus::Completed
446    };
447    let duration = waiter_start.elapsed();
448    let duration_ms = duration.as_millis() as i64;
449    let artifacts = match proc::persist_artifacts(
450        &context.command_id,
451        &stdout,
452        &stderr,
453        Some(&context.handle_id),
454    ) {
455        Ok(artifacts) => artifacts,
456        Err(_) => {
457            let (completion_tx, _result_txs) = take_handle_notifiers(&context.handle_id);
458            if let Some(tx) = completion_tx {
459                let _ = tx.try_send(());
460            }
461            return;
462        }
463    };
464    let (inline_stdout, inline_stderr) = proc::inline_output(&stdout, &stderr, capture);
465
466    let mut payload = serde_json::Map::new();
467    payload.insert(
468        "command_id".into(),
469        serde_json::Value::String(context.command_id.clone()),
470    );
471    payload.insert(
472        "status".into(),
473        serde_json::Value::String(command_status.as_str().to_string()),
474    );
475    payload.insert(
476        "handle_id".into(),
477        serde_json::Value::String(context.handle_id.clone()),
478    );
479    payload.insert(
480        "command_or_op_descriptor".into(),
481        serde_json::Value::String(context.command_display),
482    );
483    payload.insert(
484        "started_at".into(),
485        serde_json::Value::String(context.started_at),
486    );
487    payload.insert(
488        "ended_at".into(),
489        serde_json::Value::String(proc::now_rfc3339()),
490    );
491    payload.insert(
492        "duration_ms".into(),
493        serde_json::Value::Number(duration_ms.into()),
494    );
495    payload.insert(
496        "exit_code".into(),
497        serde_json::Value::Number(exit_code.into()),
498    );
499    payload.insert("timed_out".into(), serde_json::Value::Bool(timed_out));
500    payload.insert("stdout".into(), serde_json::Value::String(inline_stdout));
501    payload.insert("stderr".into(), serde_json::Value::String(inline_stderr));
502    payload.insert(
503        "output_path".into(),
504        serde_json::Value::String(to_agent_path(&artifacts.output_path)),
505    );
506    payload.insert(
507        "stdout_path".into(),
508        serde_json::Value::String(to_agent_path(&artifacts.stdout_path)),
509    );
510    payload.insert(
511        "stderr_path".into(),
512        serde_json::Value::String(to_agent_path(&artifacts.stderr_path)),
513    );
514    payload.insert(
515        "line_count".into(),
516        serde_json::Value::Number(artifacts.line_count.into()),
517    );
518    payload.insert(
519        "byte_count".into(),
520        serde_json::Value::Number(artifacts.byte_count.into()),
521    );
522    payload.insert(
523        "output_sha256".into(),
524        serde_json::Value::String(artifacts.output_sha256),
525    );
526    if let Some(pgid) = context.process_group_id {
527        payload.insert(
528            "process_group_id".into(),
529            serde_json::Value::Number((pgid as u64).into()),
530        );
531    }
532    if let Some(sig) = signal_name {
533        payload.insert("signal".into(), serde_json::Value::String(sig));
534    } else {
535        payload.insert("signal".into(), serde_json::Value::Null);
536    }
537    if let Some(snapshot_binding) = context.snapshot_binding.as_ref() {
538        payload.insert("snapshot_binding".into(), vm_dict_to_json(snapshot_binding));
539    }
540    if let Some(process_cleanup) = process_cleanup.as_ref() {
541        payload.insert(
542            "process_cleanup".into(),
543            proc::process_cleanup_to_json(process_cleanup),
544        );
545    }
546
547    let result_value = harn_vm::json_to_vm_value(&serde_json::Value::Object(payload.clone()));
548    if !cancelled {
549        let content = serde_json::to_string(&payload).unwrap_or_default();
550        harn_vm::orchestration::agent_inbox::push(
551            &context.session_id,
552            "tool_result",
553            &content,
554            "hostlib.long_running.exit",
555        );
556    }
557    // Remove our entry from the store only after the public feedback path is
558    // published. An explicit `wait_command` can register a direct waiter while
559    // the child has exited but artifacts are still being finalized; waking that
560    // waiter after the inbox push lets `wait_command` consume the matching
561    // inbox entry and preserve the old no-duplicate-feedback contract.
562    let (completion_tx, result_txs) = take_handle_notifiers(&context.handle_id);
563    for tx in result_txs {
564        let _ = tx.try_send(result_value.clone());
565    }
566    if let Some(tx) = completion_tx {
567        let _ = tx.try_send(());
568    }
569}
570
571fn spawn_output_drain(
572    mut reader: Box<dyn Read + Send>,
573    state: Arc<Mutex<OutputState>>,
574    path: std::path::PathBuf,
575    combined_file: Option<Arc<Mutex<std::fs::File>>>,
576    stdout: bool,
577) -> std::thread::JoinHandle<()> {
578    std::thread::spawn(move || {
579        let mut file = std::fs::File::create(path).ok();
580        let mut buf = [0_u8; 8192];
581        loop {
582            let read = match reader.read(&mut buf) {
583                Ok(0) => break,
584                Ok(read) => read,
585                Err(_) => break,
586            };
587            let chunk = &buf[..read];
588            if let Some(file) = file.as_mut() {
589                let _ = file.write_all(chunk);
590            }
591            if let Some(combined) = combined_file.as_ref() {
592                if let Ok(mut combined) = combined.lock() {
593                    let _ = combined.write_all(chunk);
594                }
595            }
596            if let Ok(mut state) = state.lock() {
597                if stdout {
598                    state.stdout.extend_from_slice(chunk);
599                } else {
600                    state.stderr.extend_from_slice(chunk);
601                }
602            }
603        }
604    })
605}
606
607fn spawn_progress_thread(context: ProgressThreadContext) -> std::thread::JoinHandle<()> {
608    std::thread::spawn(move || {
609        while !context.done.load(Ordering::Acquire)
610            && !context.cancel_state.cancelled.load(Ordering::Acquire)
611        {
612            std::thread::sleep(context.interval);
613            if context.done.load(Ordering::Acquire)
614                || context.cancel_state.cancelled.load(Ordering::Acquire)
615            {
616                break;
617            }
618            let (stdout, stderr) = {
619                let state = context
620                    .output_state
621                    .lock()
622                    .unwrap_or_else(|poison| poison.into_inner());
623                (state.stdout.clone(), state.stderr.clone())
624            };
625            let capture = CaptureConfig {
626                max_inline_bytes: context.max_inline_bytes,
627                ..CaptureConfig::default()
628            };
629            let (inline_stdout, inline_stderr) = proc::inline_output(&stdout, &stderr, capture);
630            let byte_count = stdout.len().saturating_add(stderr.len());
631            let mut payload = serde_json::json!({
632                "command_id": &context.command_id,
633                "handle_id": &context.handle_id,
634                "status": CommandStatus::Running.as_str(),
635                "command_or_op_descriptor": &context.command_display,
636                "started_at": &context.started_at,
637                "ended_at": null,
638                "duration_ms": context.started.elapsed().as_millis() as i64,
639                "exit_code": null,
640                "signal": null,
641                "stdout": inline_stdout,
642                "stderr": inline_stderr,
643                "output_path": to_agent_path(&context.output_path),
644                "stdout_path": to_agent_path(&context.stdout_path),
645                "stderr_path": to_agent_path(&context.stderr_path),
646                "byte_count": byte_count as i64,
647                "line_count": stdout.iter().chain(stderr.iter()).filter(|byte| **byte == b'\n').count() as i64,
648                "process_group_id": context.process_group_id,
649            });
650            if let (Some(object), Some(snapshot_binding)) =
651                (payload.as_object_mut(), context.snapshot_binding.as_ref())
652            {
653                object.insert(
654                    "snapshot_binding".to_string(),
655                    vm_dict_to_json(snapshot_binding),
656                );
657            }
658            harn_vm::orchestration::agent_inbox::push(
659                &context.session_id,
660                "tool_progress",
661                &payload.to_string(),
662                "hostlib.long_running.progress",
663            );
664        }
665    })
666}
667
668pub(crate) struct CancelOptions {
669    pub(crate) timed_out: bool,
670    pub(crate) wait_result: Option<Duration>,
671}
672
673pub(crate) struct CancelOutcome {
674    pub(crate) cancelled: bool,
675    pub(crate) result: Option<VmValue>,
676}
677
678/// Cancel a specific in-flight long-running handle. Kills the process and lets
679/// the waiter drain output/artifacts. Returns `true` if the handle was found
680/// and cancellation was newly requested.
681pub fn cancel_handle(handle_id: &str) -> bool {
682    cancel_handle_with_options(
683        handle_id,
684        CancelOptions {
685            timed_out: false,
686            wait_result: None,
687        },
688    )
689    .cancelled
690}
691
692pub(crate) fn snapshot_binding_for_handle(handle_id: &str) -> Option<harn_vm::value::DictMap> {
693    let store = HANDLE_STORE
694        .lock()
695        .expect("long-running handle store poisoned");
696    store
697        .entries
698        .get(handle_id)
699        .and_then(|entry| entry.snapshot_binding.clone())
700}
701
702pub(crate) fn cancel_handle_with_options(handle_id: &str, options: CancelOptions) -> CancelOutcome {
703    let (killer, cancel_state, result_rx) = {
704        let mut store = HANDLE_STORE
705            .lock()
706            .expect("long-running handle store poisoned");
707        let Some(entry) = store.entries.get_mut(handle_id) else {
708            return CancelOutcome {
709                cancelled: false,
710                result: None,
711            };
712        };
713        if entry.cancel_state.cancelled.swap(true, Ordering::AcqRel) {
714            return CancelOutcome {
715                cancelled: false,
716                result: None,
717            };
718        }
719        entry
720            .cancel_state
721            .timed_out
722            .store(options.timed_out, Ordering::Release);
723        let result_rx = options.wait_result.map(|_| {
724            let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
725            entry.result_txs.push(tx);
726            rx
727        });
728        (entry.killer.clone(), entry.cancel_state.clone(), result_rx)
729    };
730    do_kill(killer, cancel_state);
731    let result = match (options.wait_result, result_rx) {
732        (Some(timeout), Some(rx)) => rx.recv_timeout(timeout).ok(),
733        _ => None,
734    };
735    CancelOutcome {
736        cancelled: true,
737        result,
738    }
739}
740
741/// Wait for a live long-running handle to finalize and return its result.
742///
743/// Returns `None` when the handle is already gone or the timeout elapses. The
744/// result is also published through the session inbox for normal agent-loop
745/// delivery; callers that use this direct synchronizer should drain the
746/// matching inbox item after receiving the value if they are consuming it.
747pub(crate) fn wait_for_result(handle_id: &str, timeout: Duration) -> Option<VmValue> {
748    if timeout.is_zero() {
749        return None;
750    }
751    let rx = {
752        let mut store = HANDLE_STORE
753            .lock()
754            .expect("long-running handle store poisoned");
755        let entry = store.entries.get_mut(handle_id)?;
756        let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
757        entry.result_txs.push(tx);
758        rx
759    };
760    rx.recv_timeout(timeout).ok()
761}
762
763/// Tuple shape used by `cancel_session_handles` to drain entries while
764/// holding the store lock for as little as possible. Boxed-trait fields
765/// make it noisy to inline as an unnamed type.
766type SessionKillEntry = (Arc<dyn ProcessKiller>, Arc<CancelState>);
767
768/// Cancel all in-flight handles for a given session. Called by the
769/// session-end hook to avoid orphaned processes.
770pub fn cancel_session_handles(session_id: &str) {
771    let to_kill: Vec<SessionKillEntry> = {
772        let store = HANDLE_STORE
773            .lock()
774            .expect("long-running handle store poisoned");
775        let matching: Vec<String> = store
776            .entries
777            .iter()
778            .filter(|(_, e)| e.session_id == session_id)
779            .map(|(id, _)| id.clone())
780            .collect();
781        matching
782            .into_iter()
783            .filter_map(|id| {
784                let entry = store.entries.get(&id)?;
785                if entry.cancel_state.cancelled.swap(true, Ordering::AcqRel) {
786                    return None;
787                }
788                entry.cancel_state.timed_out.store(false, Ordering::Release);
789                Some((entry.killer.clone(), entry.cancel_state.clone()))
790            })
791            .collect()
792    };
793    for (killer, cancel_state) in to_kill {
794        do_kill(killer, cancel_state);
795    }
796}
797
798/// Set the cancellation flag and kill the process. Used by both `cancel_handle`
799/// and `cancel_session_handles`.
800fn do_kill(killer: Arc<dyn ProcessKiller>, cancel_state: Arc<CancelState>) {
801    // Kill via the handle's killer (works whether or not we still own
802    // the handle). The waiter owns process reaping and artifact finalization.
803    let report = killer.kill();
804    {
805        let mut stored = cancel_state
806            .process_cleanup
807            .lock()
808            .unwrap_or_else(|poison| poison.into_inner());
809        match stored.as_mut() {
810            Some(existing) => existing.merge(report),
811            None => *stored = Some(report),
812        }
813    }
814    cancel_state.cancelled.store(true, Ordering::Release);
815}
816
817/// Register the session-cleanup hook with harn-vm. Uses a `OnceLock` so the
818/// hook is registered exactly once even if `register_builtins` is called
819/// multiple times (e.g. in tests).
820pub(crate) fn register_cleanup_hook() {
821    static REGISTERED: OnceLock<()> = OnceLock::new();
822    REGISTERED.get_or_init(|| {
823        let hook: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(|session_id: &str| {
824            cancel_session_handles(session_id);
825        });
826        harn_vm::register_session_end_hook(hook);
827    });
828}
829
830fn decode_exit_status(status: process_handle::ExitStatus) -> (i32, Option<String>) {
831    if let Some(code) = status.code {
832        return (code, None);
833    }
834    if let Some(sig) = status.signal {
835        return (-1, Some(format!("SIG{sig}")));
836    }
837    (-1, None)
838}
839
840/// Register a completion notifier for `handle_id`. The waiter thread sends
841/// `()` on the returned receiver after it pushes the feedback item to the
842/// global queue. Returns `None` if the handle is no longer in the store
843/// (e.g. already cancelled or completed). Used by tests to await waiter
844/// completion deterministically — no polling, no `thread::sleep`.
845pub fn register_completion_notifier(handle_id: &str) -> Option<std::sync::mpsc::Receiver<()>> {
846    let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
847    let mut store = HANDLE_STORE
848        .lock()
849        .expect("long-running handle store poisoned");
850    let entry = store.entries.get_mut(handle_id)?;
851    entry.completion_tx = Some(tx);
852    Some(rx)
853}
854
855/// Register a result notifier for `handle_id`.
856///
857/// This is a narrow test/diagnostic hook for the same synchronization path
858/// `wait_command` uses. Normal callers should use the `wait_command` tool so
859/// the matching session-inbox feedback is consumed consistently.
860pub fn register_result_notifier(handle_id: &str) -> Option<std::sync::mpsc::Receiver<VmValue>> {
861    let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
862    let mut store = HANDLE_STORE
863        .lock()
864        .expect("long-running handle store poisoned");
865    let entry = store.entries.get_mut(handle_id)?;
866    entry.result_txs.push(tx);
867    Some(rx)
868}