Skip to main content

harn_hostlib/tools/
long_running.rs

1//! Long-running tool handle machinery.
2//!
3//! When a caller passes `background: 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, MutexGuard, OnceLock};
43use std::time::Duration;
44
45use harn_vm::VmDictExt;
46use harn_vm::VmValue;
47
48use crate::error::HostlibError;
49use crate::json::vm_dict_to_json;
50use crate::process::{
51    self as process_handle, OwnerDeathPolicy, ProcessHandle, ProcessKiller, SpawnSpec,
52};
53use crate::tools::args::to_agent_path;
54use crate::tools::proc::{self, CaptureConfig, CommandStatus, EnvMode};
55
56/// Atomic counter for generating unique handle IDs within this process.
57static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
58
59/// Shared cancellation state between the store entry and its waiter thread.
60///
61/// The waiter must never observe a terminal cancellation before its cleanup
62/// receipt is available. A single mutex makes that publication atomic even
63/// though `ProcessKiller::kill` wakes the process waiter before it returns its
64/// cleanup report.
65#[derive(Default)]
66struct CancelState {
67    state: Mutex<CancellationState>,
68}
69
70#[derive(Default)]
71struct CancellationState {
72    /// A canceller owns the terminal result publication while this is set.
73    cancellation_requested: bool,
74    /// A cancellation result has been published for the waiter.
75    cancelled: bool,
76    /// Whether the published cancellation represents a timeout.
77    timed_out: bool,
78    /// Structural process-tree cleanup evidence returned by the killer.
79    process_cleanup: Option<process_handle::ProcessCleanupReport>,
80    /// The waiter has committed a terminal result, so later cancellation is a
81    /// no-op rather than retroactively rewriting a completed outcome.
82    completed: bool,
83}
84
85#[derive(Clone, Debug)]
86struct CancellationSnapshot {
87    cancelled: bool,
88    timed_out: bool,
89    process_cleanup: Option<process_handle::ProcessCleanupReport>,
90}
91
92impl CancelState {
93    fn begin_cancellation(&self, timed_out: bool) -> Option<MutexGuard<'_, CancellationState>> {
94        let mut state = self
95            .state
96            .lock()
97            .unwrap_or_else(|poison| poison.into_inner());
98        if state.cancellation_requested || state.completed {
99            return None;
100        }
101        state.cancellation_requested = true;
102        state.timed_out = timed_out;
103        Some(state)
104    }
105
106    fn complete_wait(&self) -> CancellationSnapshot {
107        let mut state = self
108            .state
109            .lock()
110            .unwrap_or_else(|poison| poison.into_inner());
111        state.completed = true;
112        CancellationSnapshot {
113            cancelled: state.cancelled,
114            timed_out: state.timed_out,
115            process_cleanup: state.process_cleanup.clone(),
116        }
117    }
118
119    fn cancellation_published(&self) -> bool {
120        self.state
121            .lock()
122            .unwrap_or_else(|poison| poison.into_inner())
123            .cancelled
124    }
125}
126
127impl CancellationState {
128    fn record_cleanup(&mut self, report: process_handle::ProcessCleanupReport) {
129        match self.process_cleanup.as_mut() {
130            Some(existing) => existing.merge(report),
131            None => self.process_cleanup = Some(report),
132        }
133    }
134
135    fn publish_cancellation(&mut self) {
136        debug_assert!(self.cancellation_requested);
137        self.cancelled = true;
138    }
139}
140
141fn kill_and_publish(killer: &dyn ProcessKiller, cancellation: &mut CancellationState) {
142    let report = killer.kill();
143    cancellation.record_cleanup(report);
144    cancellation.publish_cancellation();
145}
146
147#[derive(Default)]
148pub(crate) struct OutputState {
149    pub(crate) stdout: Vec<u8>,
150    pub(crate) stderr: Vec<u8>,
151    pub(crate) combined: Vec<u8>,
152    pub(crate) terminal: Option<VmValue>,
153    /// Wall-clock instant of the most recent stdout/stderr chunk, used to derive
154    /// `silence_ms` on progress snapshots (the byte-stall decision trigger).
155    /// `None` until the first byte of output arrives.
156    last_output_at: Option<std::time::Instant>,
157}
158
159pub(crate) struct OutputFeed {
160    pub(crate) state: Mutex<OutputState>,
161    notify: tokio::sync::Notify,
162}
163
164impl Default for OutputFeed {
165    fn default() -> Self {
166        Self {
167            state: Mutex::new(OutputState::default()),
168            notify: tokio::sync::Notify::new(),
169        }
170    }
171}
172
173impl OutputFeed {
174    pub(crate) fn notified(&self) -> tokio::sync::futures::Notified<'_> {
175        self.notify.notified()
176    }
177}
178
179/// Shared state for a single in-flight child process.
180struct HandleEntry {
181    /// The process handle. `None` after the waiter thread takes ownership.
182    handle: Option<Box<dyn ProcessHandle>>,
183    /// Killer that works even after the waiter took `handle`.
184    killer: Arc<dyn ProcessKiller>,
185    session_id: String,
186    /// Shared with the waiter thread.
187    cancel_state: Arc<CancelState>,
188    output_feed: Arc<OutputFeed>,
189    /// Sender used by the waiter thread to signal that the post-exit
190    /// feedback push is complete. `None` if the test-side hasn't asked
191    /// to be notified.
192    completion_tx: Option<std::sync::mpsc::SyncSender<()>>,
193    /// One-shot result channels installed by callers that need to synchronize
194    /// on the finalized command result instead of observing it indirectly
195    /// through the session inbox.
196    result_txs: Vec<std::sync::mpsc::SyncSender<VmValue>>,
197    /// Opaque verification snapshot binding provided by the caller.
198    snapshot_binding: Option<harn_vm::value::DictMap>,
199    /// Spawn-time lease tag surfaced by `list_handles` (loop owns transitions).
200    lease: LeaseTag,
201    /// Human-readable command display, so `list_handles` can render a ledger
202    /// digest without the caller re-deriving it.
203    command_display: String,
204    /// RFC 3339 spawn timestamp, for `list_handles` elapsed reporting.
205    started_at: String,
206    /// Canonical directory in which the child executes.
207    cwd: PathBuf,
208}
209
210#[derive(Default)]
211struct HandleStore {
212    entries: BTreeMap<String, HandleEntry>,
213}
214
215static HANDLE_STORE: LazyLock<Mutex<HandleStore>> =
216    LazyLock::new(|| Mutex::new(HandleStore::default()));
217
218type HandleNotifiers = (
219    Option<std::sync::mpsc::SyncSender<()>>,
220    Vec<std::sync::mpsc::SyncSender<VmValue>>,
221);
222
223fn take_handle_notifiers(handle_id: &str) -> HandleNotifiers {
224    let mut store = HANDLE_STORE
225        .lock()
226        .expect("long-running handle store poisoned");
227    store
228        .entries
229        .remove(handle_id)
230        .map(|mut entry| {
231            (
232                entry.completion_tx.take(),
233                std::mem::take(&mut entry.result_txs),
234            )
235        })
236        .unwrap_or((None, Vec::new()))
237}
238
239/// Metadata returned to the caller immediately when a long-running spawn
240/// succeeds. Serialised as a response dict by the calling builtin.
241pub struct LongRunningHandleInfo {
242    /// Command identifier shared with foreground command responses.
243    pub command_id: String,
244    /// Opaque handle identifier, e.g. `"hto-<pid-hex>-<n>"`.
245    pub handle_id: String,
246    /// RFC 3339 timestamp of the spawn.
247    pub started_at: String,
248    /// Canonical directory in which the child executes.
249    pub cwd: PathBuf,
250    /// Raw child process id reported by the platform.
251    pub pid: u32,
252    /// Child process group id when the platform exposes it.
253    pub process_group_id: Option<u32>,
254    /// Human-readable display form of the argv (space-joined).
255    pub command_display: String,
256    /// Opaque verification snapshot binding provided by the caller.
257    pub snapshot_binding: Option<harn_vm::value::DictMap>,
258}
259
260/// Default ceiling for the progress-emission backoff schedule when the caller
261/// does not pin `progress_max_interval_ms`. The schedule starts at the base
262/// `progress_interval`, doubles after each snapshot, and is clamped here โ€” so a
263/// multi-minute command emits a handful of re-entries (2s, 4s, 8s, 16s, 30s,
264/// 30s, ...) instead of one every base interval, keeping a silent long build
265/// token-cheap while still surfacing early, frequent progress.
266const DEFAULT_PROGRESS_MAX_INTERVAL: Duration = Duration::from_secs(30);
267
268pub(crate) struct LongRunningSpawnOptions {
269    pub(crate) env_mode: EnvMode,
270    pub(crate) env_remove: Vec<String>,
271    pub(crate) capture: CaptureConfig,
272    pub(crate) session_id: String,
273    pub(crate) progress_interval: Option<Duration>,
274    pub(crate) progress_max_interval: Option<Duration>,
275    pub(crate) progress_max_inline_bytes: usize,
276    pub(crate) snapshot_binding: Option<harn_vm::value::DictMap>,
277    /// Initial lease classification recorded on the handle entry for
278    /// `list_handles` reporting. `"awaited"` (the loop schedules decision
279    /// re-entries and waits on it) or `"service"` (detached; runs until the
280    /// session-end reaper). The loop owns transitions after spawn; this is only
281    /// the spawn-time tag.
282    pub(crate) lease: LeaseTag,
283}
284
285/// Spawn-time lease classification stored on a handle entry. The agent loop's
286/// ledger owns lease transitions (e.g. `release_command` awaited -> service);
287/// this tag is the initial value surfaced by `list_handles`.
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289pub(crate) enum LeaseTag {
290    Awaited,
291    Service,
292}
293
294impl LeaseTag {
295    fn as_str(self) -> &'static str {
296        match self {
297            LeaseTag::Awaited => "awaited",
298            LeaseTag::Service => "service",
299        }
300    }
301}
302
303struct WaiterContext {
304    command_id: String,
305    handle_id: String,
306    session_id: String,
307    started_at: String,
308    cwd: PathBuf,
309    process_group_id: Option<u32>,
310    command_display: String,
311    progress_interval: Option<Duration>,
312    progress_max_interval: Option<Duration>,
313    progress_max_inline_bytes: usize,
314    snapshot_binding: Option<harn_vm::value::DictMap>,
315    output_feed: Arc<OutputFeed>,
316}
317
318struct ProgressThreadContext {
319    command_id: String,
320    handle_id: String,
321    session_id: String,
322    started_at: String,
323    cwd: PathBuf,
324    command_display: String,
325    process_group_id: Option<u32>,
326    output_path: PathBuf,
327    stdout_path: PathBuf,
328    stderr_path: PathBuf,
329    output_feed: Arc<OutputFeed>,
330    cancel_state: Arc<CancelState>,
331    done: Arc<AtomicBool>,
332    started: std::time::Instant,
333    /// Base delay before the first progress snapshot and the seed of the
334    /// doubling backoff schedule.
335    interval: Duration,
336    /// Upper bound the doubling schedule is clamped to.
337    max_interval: Duration,
338    max_inline_bytes: usize,
339    snapshot_binding: Option<harn_vm::value::DictMap>,
340}
341
342impl LongRunningHandleInfo {
343    /// Convert into the standard handle response dict returned to the agent.
344    pub fn into_handle_response(self) -> VmValue {
345        let Self {
346            command_id,
347            handle_id,
348            started_at,
349            cwd,
350            pid,
351            process_group_id,
352            command_display,
353            snapshot_binding,
354        } = self;
355        proc::running_response(
356            command_id,
357            handle_id,
358            pid,
359            process_group_id,
360            started_at,
361            &cwd,
362            command_display,
363            snapshot_binding.as_ref(),
364        )
365    }
366}
367
368/// Spawn the argv as a long-running child process and return a handle.
369///
370/// The background waiter pushes a `tool_result` entry into the active
371/// session's `agent_inbox` when the process exits so the next
372/// agent-loop turn sees the result.
373pub fn spawn_long_running(
374    builtin: &'static str,
375    program: String,
376    args: Vec<String>,
377    cwd: Option<PathBuf>,
378    env: BTreeMap<String, String>,
379    session_id: String,
380) -> Result<LongRunningHandleInfo, HostlibError> {
381    spawn_long_running_with_options(
382        builtin,
383        program,
384        args,
385        cwd,
386        env,
387        LongRunningSpawnOptions {
388            env_mode: EnvMode::InheritClean,
389            env_remove: Vec::new(),
390            capture: CaptureConfig::default(),
391            session_id,
392            progress_interval: None,
393            progress_max_interval: None,
394            progress_max_inline_bytes: CaptureConfig::default().max_inline_bytes,
395            snapshot_binding: None,
396            lease: LeaseTag::Awaited,
397        },
398    )
399}
400
401pub(crate) fn spawn_long_running_with_options(
402    builtin: &'static str,
403    program: String,
404    args: Vec<String>,
405    cwd: Option<PathBuf>,
406    env: BTreeMap<String, String>,
407    options: LongRunningSpawnOptions,
408) -> Result<LongRunningHandleInfo, HostlibError> {
409    let requested_cwd = cwd.as_ref().map(to_agent_path);
410    let effective_cwd = proc::resolve_effective_cwd(builtin, cwd.as_deref())?;
411    let mut env = env;
412    proc::apply_toolchain_path(Some(&effective_cwd), &mut env, options.env_mode);
413    let spec = SpawnSpec {
414        builtin,
415        program: program.clone(),
416        args: args.clone(),
417        cwd: Some(effective_cwd.clone()),
418        env,
419        env_remove: options.env_remove.clone(),
420        env_mode: options.env_mode,
421        use_stdin: false,
422        configure_process_group: true,
423        owner_death: OwnerDeathPolicy::KillContainment,
424        output_capture: process_handle::OutputCapture::Pipe,
425    };
426    let handle = process_handle::spawn_process(spec).map_err(|error| {
427        proc::process_error_to_hostlib(builtin, requested_cwd.as_deref(), &effective_cwd, error)
428    })?;
429
430    let pid = handle.pid().unwrap_or(0);
431    let process_group_id = handle.process_group_id();
432    let killer = handle.killer();
433    let id = HANDLE_COUNTER.fetch_add(1, Ordering::SeqCst);
434    let handle_id = format!("hto-{:x}-{id}", std::process::id());
435    let command_id = proc::next_command_id();
436    let started_at = proc::now_rfc3339();
437    let _artifacts = proc::register_live_artifacts(&command_id, Some(&handle_id))?;
438
439    let mut all_argv = vec![program];
440    all_argv.extend(args.iter().cloned());
441    let command_display = all_argv.join(" ");
442
443    let cancel_state = Arc::new(CancelState {
444        state: Mutex::new(CancellationState::default()),
445    });
446    let output_feed = Arc::new(OutputFeed::default());
447
448    {
449        let mut store = HANDLE_STORE
450            .lock()
451            .expect("long-running handle store poisoned");
452        store.entries.insert(
453            handle_id.clone(),
454            HandleEntry {
455                handle: Some(handle),
456                killer,
457                session_id: options.session_id.clone(),
458                cancel_state: cancel_state.clone(),
459                output_feed: output_feed.clone(),
460                completion_tx: None,
461                result_txs: Vec::new(),
462                snapshot_binding: options.snapshot_binding.clone(),
463                lease: options.lease,
464                command_display: command_display.clone(),
465                started_at: started_at.clone(),
466                cwd: effective_cwd.clone(),
467            },
468        );
469    }
470
471    let waiter_context = WaiterContext {
472        command_id: command_id.clone(),
473        handle_id: handle_id.clone(),
474        session_id: options.session_id,
475        started_at: started_at.clone(),
476        cwd: effective_cwd.clone(),
477        process_group_id,
478        command_display: command_display.clone(),
479        progress_interval: options.progress_interval,
480        progress_max_interval: options.progress_max_interval,
481        progress_max_inline_bytes: options.progress_max_inline_bytes,
482        snapshot_binding: options.snapshot_binding.clone(),
483        output_feed,
484    };
485    let waiter_thread_name = waiter_context.handle_id.clone();
486    let capture = options.capture;
487    std::thread::Builder::new()
488        .name(format!("hto-waiter-{waiter_thread_name}"))
489        .spawn(move || {
490            waiter_thread(waiter_context, cancel_state, capture);
491        })
492        .map_err(|e| HostlibError::Backend {
493            builtin,
494            message: format!("failed to spawn waiter thread: {e}"),
495        })?;
496
497    Ok(LongRunningHandleInfo {
498        command_id,
499        handle_id,
500        started_at,
501        cwd: effective_cwd,
502        pid,
503        process_group_id,
504        command_display,
505        snapshot_binding: options.snapshot_binding,
506    })
507}
508
509/// Background thread that waits for a child process and fires feedback.
510fn waiter_thread(context: WaiterContext, cancel_state: Arc<CancelState>, capture: CaptureConfig) {
511    let waiter_start = std::time::Instant::now();
512
513    // Take the handle out of the store. If the entry is already gone (i.e.
514    // cancel_handle ran and removed it before us), exit without action.
515    let mut handle = {
516        let mut store = HANDLE_STORE
517            .lock()
518            .expect("long-running handle store poisoned");
519        match store.entries.get_mut(&context.handle_id) {
520            Some(entry) => match entry.handle.take() {
521                Some(h) => h,
522                None => return, // already cancelled before we ran
523            },
524            None => return, // entry removed (cancelled before store insert โ€” shouldn't happen)
525        }
526    };
527
528    let done = Arc::new(AtomicBool::new(false));
529    let planned = proc::planned_artifact_paths(&context.command_id);
530    if let Some(parent) = planned.output_path.parent() {
531        let _ = std::fs::create_dir_all(parent);
532    }
533    let _ = std::fs::File::create(&planned.stdout_path);
534    let _ = std::fs::File::create(&planned.stderr_path);
535    let combined_file = std::fs::File::create(&planned.output_path)
536        .ok()
537        .map(|file| Arc::new(Mutex::new(file)));
538
539    let stdout_thread = handle.take_stdout().map(|out| {
540        spawn_output_drain(
541            out,
542            context.output_feed.clone(),
543            planned.stdout_path.clone(),
544            combined_file.clone(),
545            true,
546        )
547    });
548    let stderr_thread = handle.take_stderr().map(|err| {
549        spawn_output_drain(
550            err,
551            context.output_feed.clone(),
552            planned.stderr_path.clone(),
553            combined_file.clone(),
554            false,
555        )
556    });
557
558    let progress_thread = context
559        .progress_interval
560        .filter(|interval| !interval.is_zero())
561        .map(|interval| {
562            // The backoff ceiling is never below the base interval: a caller that
563            // pins a large base without a cap gets a fixed cadence, not a cap that
564            // silently shrinks its interval.
565            let max_interval = context
566                .progress_max_interval
567                .filter(|cap| !cap.is_zero())
568                .unwrap_or(DEFAULT_PROGRESS_MAX_INTERVAL)
569                .max(interval);
570            spawn_progress_thread(ProgressThreadContext {
571                command_id: context.command_id.clone(),
572                handle_id: context.handle_id.clone(),
573                session_id: context.session_id.clone(),
574                started_at: context.started_at.clone(),
575                cwd: context.cwd.clone(),
576                command_display: context.command_display.clone(),
577                process_group_id: context.process_group_id,
578                output_path: planned.output_path.clone(),
579                stdout_path: planned.stdout_path.clone(),
580                stderr_path: planned.stderr_path.clone(),
581                output_feed: context.output_feed.clone(),
582                cancel_state: cancel_state.clone(),
583                done: done.clone(),
584                started: waiter_start,
585                interval,
586                max_interval,
587                max_inline_bytes: context.progress_max_inline_bytes,
588                snapshot_binding: context.snapshot_binding.clone(),
589            })
590        });
591
592    let status = handle.wait().ok();
593
594    if let Some(thread) = stdout_thread {
595        let _ = thread.join();
596    }
597    if let Some(thread) = stderr_thread {
598        let _ = thread.join();
599    }
600    done.store(true, Ordering::Release);
601    drop(progress_thread);
602    let (stdout, stderr) = {
603        let state = context
604            .output_feed
605            .state
606            .lock()
607            .unwrap_or_else(|poison| poison.into_inner());
608        (state.stdout.clone(), state.stderr.clone())
609    };
610
611    let cancellation = cancel_state.complete_wait();
612    let cancelled = cancellation.cancelled;
613    let timed_out = cancelled && cancellation.timed_out;
614    let process_cleanup = cancellation.process_cleanup;
615
616    let (exit_code, signal_name) = match status {
617        Some(s) => decode_exit_status(s),
618        // wait() itself failed โ€” treat as killed (extremely unusual).
619        None => (-1, Some("SIGKILL".to_string())),
620    };
621    let command_status = if timed_out {
622        CommandStatus::TimedOut
623    } else if cancelled {
624        CommandStatus::Killed
625    } else {
626        CommandStatus::Completed
627    };
628    let duration = waiter_start.elapsed();
629    let duration_ms = duration.as_millis() as i64;
630    let artifacts = match proc::persist_artifacts(
631        &context.command_id,
632        &stdout,
633        &stderr,
634        Some(&context.handle_id),
635    ) {
636        Ok(artifacts) => artifacts,
637        Err(error) => {
638            tracing::warn!(
639                "long-running command {} could not persist artifacts: {error}; returning in-memory terminal metadata",
640                context.command_id
641            );
642            proc::summarize_artifacts(
643                &context.command_id,
644                &stdout,
645                &stderr,
646                Some(&context.handle_id),
647            )
648        }
649    };
650    let (inline_stdout, inline_stderr) = proc::inline_output(&stdout, &stderr, capture);
651
652    let mut payload = serde_json::Map::new();
653    payload.insert(
654        "command_id".into(),
655        serde_json::Value::String(context.command_id.clone()),
656    );
657    payload.insert(
658        "status".into(),
659        serde_json::Value::String(command_status.as_str().to_string()),
660    );
661    payload.insert(
662        "handle_id".into(),
663        serde_json::Value::String(context.handle_id.clone()),
664    );
665    payload.insert(
666        "command_or_op_descriptor".into(),
667        serde_json::Value::String(context.command_display),
668    );
669    payload.insert(
670        "started_at".into(),
671        serde_json::Value::String(context.started_at),
672    );
673    payload.insert(
674        "cwd".into(),
675        serde_json::Value::String(to_agent_path(&context.cwd)),
676    );
677    payload.insert(
678        "ended_at".into(),
679        serde_json::Value::String(proc::now_rfc3339()),
680    );
681    payload.insert(
682        "duration_ms".into(),
683        serde_json::Value::Number(duration_ms.into()),
684    );
685    payload.insert(
686        "exit_code".into(),
687        serde_json::Value::Number(exit_code.into()),
688    );
689    payload.insert("timed_out".into(), serde_json::Value::Bool(timed_out));
690    payload.insert("stdout".into(), serde_json::Value::String(inline_stdout));
691    payload.insert("stderr".into(), serde_json::Value::String(inline_stderr));
692    payload.insert(
693        "output_path".into(),
694        serde_json::Value::String(to_agent_path(&artifacts.output_path)),
695    );
696    payload.insert(
697        "stdout_path".into(),
698        serde_json::Value::String(to_agent_path(&artifacts.stdout_path)),
699    );
700    payload.insert(
701        "stderr_path".into(),
702        serde_json::Value::String(to_agent_path(&artifacts.stderr_path)),
703    );
704    payload.insert(
705        "line_count".into(),
706        serde_json::Value::Number(artifacts.line_count.into()),
707    );
708    payload.insert(
709        "byte_count".into(),
710        serde_json::Value::Number(artifacts.byte_count.into()),
711    );
712    payload.insert(
713        "output_sha256".into(),
714        serde_json::Value::String(artifacts.output_sha256),
715    );
716    if let Some(pgid) = context.process_group_id {
717        payload.insert(
718            "process_group_id".into(),
719            serde_json::Value::Number((pgid as u64).into()),
720        );
721    }
722    if let Some(sig) = signal_name {
723        payload.insert("signal".into(), serde_json::Value::String(sig));
724    } else {
725        payload.insert("signal".into(), serde_json::Value::Null);
726    }
727    if let Some(snapshot_binding) = context.snapshot_binding.as_ref() {
728        payload.insert("snapshot_binding".into(), vm_dict_to_json(snapshot_binding));
729    }
730    if let Some(process_cleanup) = process_cleanup.as_ref() {
731        payload.insert(
732            "process_cleanup".into(),
733            proc::process_cleanup_to_json(process_cleanup),
734        );
735    }
736
737    let result_value = harn_vm::json_to_vm_value(&serde_json::Value::Object(payload.clone()));
738    {
739        let mut state = context
740            .output_feed
741            .state
742            .lock()
743            .unwrap_or_else(|poison| poison.into_inner());
744        state.terminal = Some(result_value.clone());
745    }
746    context.output_feed.notify.notify_waiters();
747    if !cancelled {
748        let content = serde_json::to_string(&payload).unwrap_or_default();
749        harn_vm::orchestration::agent_inbox::push(
750            &context.session_id,
751            "tool_result",
752            &content,
753            "hostlib.long_running.exit",
754        );
755    }
756    // Remove our entry from the store only after the public feedback path is
757    // published. An explicit `wait_command` can register a direct waiter while
758    // the child has exited but artifacts are still being finalized; waking that
759    // waiter after the inbox push lets `wait_command` consume the matching
760    // inbox entry and preserve the old no-duplicate-feedback contract.
761    let (completion_tx, result_txs) = take_handle_notifiers(&context.handle_id);
762    for tx in result_txs {
763        let _ = tx.try_send(result_value.clone());
764    }
765    if let Some(tx) = completion_tx {
766        let _ = tx.try_send(());
767    }
768}
769
770fn spawn_output_drain(
771    mut reader: Box<dyn Read + Send>,
772    output_feed: Arc<OutputFeed>,
773    path: std::path::PathBuf,
774    combined_file: Option<Arc<Mutex<std::fs::File>>>,
775    stdout: bool,
776) -> std::thread::JoinHandle<()> {
777    std::thread::spawn(move || {
778        let mut file = std::fs::File::create(path).ok();
779        let mut buf = [0_u8; 8192];
780        loop {
781            let read = match reader.read(&mut buf) {
782                Ok(0) => break,
783                Ok(read) => read,
784                Err(_) => break,
785            };
786            let chunk = &buf[..read];
787            if let Some(file) = file.as_mut() {
788                let _ = file.write_all(chunk);
789            }
790            if let Ok(mut state) = output_feed.state.lock() {
791                if let Some(combined) = combined_file.as_ref() {
792                    if let Ok(mut combined) = combined.lock() {
793                        let _ = combined.write_all(chunk);
794                    }
795                }
796                if stdout {
797                    state.stdout.extend_from_slice(chunk);
798                } else {
799                    state.stderr.extend_from_slice(chunk);
800                }
801                state.combined.extend_from_slice(chunk);
802                state.last_output_at = Some(std::time::Instant::now());
803            }
804            output_feed.notify.notify_waiters();
805        }
806    })
807}
808
809/// Next delay in the progress backoff schedule: double the current delay,
810/// clamped to `max`. Saturates to `max` rather than overflowing.
811fn next_progress_interval(current: Duration, max: Duration) -> Duration {
812    current.checked_mul(2).unwrap_or(max).min(max)
813}
814
815fn spawn_progress_thread(context: ProgressThreadContext) -> std::thread::JoinHandle<()> {
816    std::thread::spawn(move || {
817        // Exponential backoff: wait `interval`, emit a snapshot, then double the
818        // wait after each snapshot up to `max_interval`. Progress is frequent
819        // while the model most wants to know whether the command is moving, and
820        // thins out for a long-running command so it stays token-cheap. The final
821        // completion snapshot is emitted by the waiter thread on exit, independent
822        // of where this schedule is, so the terminal result is never delayed by a
823        // long backoff wait.
824        let mut current = context.interval;
825        while !context.done.load(Ordering::Acquire)
826            && !context.cancel_state.cancellation_published()
827        {
828            std::thread::sleep(current);
829            if context.done.load(Ordering::Acquire) || context.cancel_state.cancellation_published()
830            {
831                break;
832            }
833            current = next_progress_interval(current, context.max_interval);
834            let (stdout, stderr, last_output_at) = {
835                let state = context
836                    .output_feed
837                    .state
838                    .lock()
839                    .unwrap_or_else(|poison| poison.into_inner());
840                (
841                    state.stdout.clone(),
842                    state.stderr.clone(),
843                    state.last_output_at,
844                )
845            };
846            let capture = CaptureConfig {
847                max_inline_bytes: context.max_inline_bytes,
848                ..CaptureConfig::default()
849            };
850            let (inline_stdout, inline_stderr) = proc::inline_output(&stdout, &stderr, capture);
851            let byte_count = stdout.len().saturating_add(stderr.len());
852            // Milliseconds since the last output chunk (or since spawn if the
853            // command has produced nothing yet). The loop reads this to detect a
854            // byte-stall and to escalate a silent hang toward the ceiling.
855            let silence_ms = last_output_at
856                .map(|instant| instant.elapsed().as_millis() as i64)
857                .unwrap_or_else(|| context.started.elapsed().as_millis() as i64);
858            let mut payload = serde_json::json!({
859                "command_id": &context.command_id,
860                "handle_id": &context.handle_id,
861                "status": CommandStatus::Running.as_str(),
862                "command_or_op_descriptor": &context.command_display,
863                "started_at": &context.started_at,
864                "cwd": to_agent_path(&context.cwd),
865                "ended_at": null,
866                "duration_ms": context.started.elapsed().as_millis() as i64,
867                "exit_code": null,
868                "signal": null,
869                "stdout": inline_stdout,
870                "stderr": inline_stderr,
871                "output_path": to_agent_path(&context.output_path),
872                "stdout_path": to_agent_path(&context.stdout_path),
873                "stderr_path": to_agent_path(&context.stderr_path),
874                "byte_count": byte_count as i64,
875                // Monotonic combined-output offset the loop passes to
876                // `read_command_output` to page only the delta since its last
877                // digest (never re-paying for the cumulative tail).
878                "output_offset": byte_count as i64,
879                // Loop derives the "first stderr after a clean run" decision
880                // trigger from this count crossing zero; kept loop-side so all
881                // event-edge detection lives in one place (spec ยง1.4).
882                "stderr_byte_count": stderr.len() as i64,
883                "silence_ms": silence_ms,
884                "line_count": stdout.iter().chain(stderr.iter()).filter(|byte| **byte == b'\n').count() as i64,
885                "process_group_id": context.process_group_id,
886            });
887            if let (Some(object), Some(snapshot_binding)) =
888                (payload.as_object_mut(), context.snapshot_binding.as_ref())
889            {
890                object.insert(
891                    "snapshot_binding".to_string(),
892                    vm_dict_to_json(snapshot_binding),
893                );
894            }
895            harn_vm::orchestration::agent_inbox::push(
896                &context.session_id,
897                "tool_progress",
898                &payload.to_string(),
899                "hostlib.long_running.progress",
900            );
901        }
902    })
903}
904
905pub(crate) struct CancelOptions {
906    pub(crate) timed_out: bool,
907    pub(crate) wait_result: Option<Duration>,
908}
909
910pub(crate) struct CancelOutcome {
911    pub(crate) cancelled: bool,
912    pub(crate) result: Option<VmValue>,
913}
914
915/// Cancel a specific in-flight long-running handle. Kills the process and lets
916/// the waiter drain output/artifacts. Returns `true` if the handle was found
917/// and cancellation was newly requested.
918pub fn cancel_handle(handle_id: &str) -> bool {
919    cancel_handle_with_options(
920        handle_id,
921        CancelOptions {
922            timed_out: false,
923            wait_result: None,
924        },
925    )
926    .cancelled
927}
928
929pub(crate) fn snapshot_binding_for_handle(handle_id: &str) -> Option<harn_vm::value::DictMap> {
930    let store = HANDLE_STORE
931        .lock()
932        .expect("long-running handle store poisoned");
933    store
934        .entries
935        .get(handle_id)
936        .and_then(|entry| entry.snapshot_binding.clone())
937}
938
939pub(crate) fn cwd_for_handle(handle_id: &str) -> Option<PathBuf> {
940    HANDLE_STORE
941        .lock()
942        .expect("long-running handle store poisoned")
943        .entries
944        .get(handle_id)
945        .map(|entry| entry.cwd.clone())
946}
947
948pub(crate) fn output_context_for_handle(handle_id: &str) -> Option<(Arc<OutputFeed>, PathBuf)> {
949    HANDLE_STORE
950        .lock()
951        .expect("long-running handle store poisoned")
952        .entries
953        .get(handle_id)
954        .map(|entry| (entry.output_feed.clone(), entry.cwd.clone()))
955}
956
957pub(crate) fn cancel_handle_with_options(handle_id: &str, options: CancelOptions) -> CancelOutcome {
958    let (killer, cancel_state, result_rx) = {
959        let mut store = HANDLE_STORE
960            .lock()
961            .expect("long-running handle store poisoned");
962        let Some((killer, cancel_state)) = store
963            .entries
964            .get(handle_id)
965            .map(|entry| (entry.killer.clone(), entry.cancel_state.clone()))
966        else {
967            return CancelOutcome {
968                cancelled: false,
969                result: None,
970            };
971        };
972        let result_rx = options.wait_result.map(|_| {
973            let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
974            store
975                .entries
976                .get_mut(handle_id)
977                .expect("handle entry disappeared while store was locked")
978                .result_txs
979                .push(tx);
980            rx
981        });
982        (killer, cancel_state, result_rx)
983    };
984    let cancellation = cancel_state.begin_cancellation(options.timed_out);
985    let Some(mut cancellation) = cancellation else {
986        return CancelOutcome {
987            cancelled: false,
988            result: match (options.wait_result, result_rx) {
989                (Some(timeout), Some(rx)) => rx.recv_timeout(timeout).ok(),
990                _ => None,
991            },
992        };
993    };
994    kill_and_publish(killer.as_ref(), &mut cancellation);
995    drop(cancellation);
996    let result = match (options.wait_result, result_rx) {
997        (Some(timeout), Some(rx)) => rx.recv_timeout(timeout).ok(),
998        _ => None,
999    };
1000    CancelOutcome {
1001        cancelled: true,
1002        result,
1003    }
1004}
1005
1006/// Wait for a live long-running handle to finalize and return its result.
1007///
1008/// Returns `None` when the handle is already gone or the timeout elapses. The
1009/// result is also published through the session inbox for normal agent-loop
1010/// delivery; callers that use this direct synchronizer should drain the
1011/// matching inbox item after receiving the value if they are consuming it.
1012pub(crate) fn wait_for_result(handle_id: &str, timeout: Duration) -> Option<VmValue> {
1013    if timeout.is_zero() {
1014        return None;
1015    }
1016    let rx = {
1017        let mut store = HANDLE_STORE
1018            .lock()
1019            .expect("long-running handle store poisoned");
1020        let entry = store.entries.get_mut(handle_id)?;
1021        let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
1022        entry.result_txs.push(tx);
1023        rx
1024    };
1025    rx.recv_timeout(timeout).ok()
1026}
1027
1028/// Live handles for `session_id`, for the agent loop's ledger reconciliation and
1029/// digest rendering. Each row carries the spawn-time lease tag, command display,
1030/// and start timestamp; scheduling and lease transitions live in the loop. An
1031/// entry disappears from this list the instant its waiter thread removes it on
1032/// process exit, so a completed-and-drained command is never reported as live.
1033pub(crate) fn list_session_handles(session_id: &str) -> VmValue {
1034    let store = HANDLE_STORE
1035        .lock()
1036        .expect("long-running handle store poisoned");
1037    let handles: Vec<VmValue> = store
1038        .entries
1039        .iter()
1040        .filter(|(_id, entry)| entry.session_id == session_id)
1041        .map(|(id, entry)| {
1042            let mut row = harn_vm::value::DictMap::new();
1043            row.put_str("handle_id", id.clone());
1044            row.put_str("session_id", entry.session_id.clone());
1045            row.put_str("lease", entry.lease.as_str());
1046            row.put_str("command_or_op_descriptor", entry.command_display.clone());
1047            row.put_str("started_at", entry.started_at.clone());
1048            VmValue::dict(row)
1049        })
1050        .collect();
1051    let mut response = harn_vm::value::DictMap::new();
1052    response.insert(
1053        harn_vm::value::intern_key("handles"),
1054        VmValue::List(Arc::new(handles)),
1055    );
1056    VmValue::dict(response)
1057}
1058
1059/// Tuple shape used by `cancel_session_handles` to drain entries while
1060/// holding the store lock for as little as possible. Boxed-trait fields
1061/// make it noisy to inline as an unnamed type.
1062type SessionKillEntry = (Arc<dyn ProcessKiller>, Arc<CancelState>);
1063
1064/// Cancel all in-flight handles for a given session. Called by the
1065/// session-end hook to avoid orphaned processes.
1066pub fn cancel_session_handles(session_id: &str) {
1067    let to_kill: Vec<SessionKillEntry> = {
1068        let store = HANDLE_STORE
1069            .lock()
1070            .expect("long-running handle store poisoned");
1071        let matching: Vec<String> = store
1072            .entries
1073            .iter()
1074            .filter(|(_, e)| e.session_id == session_id)
1075            .map(|(id, _)| id.clone())
1076            .collect();
1077        matching
1078            .into_iter()
1079            .filter_map(|id| {
1080                let entry = store.entries.get(&id)?;
1081                Some((entry.killer.clone(), entry.cancel_state.clone()))
1082            })
1083            .collect()
1084    };
1085    for (killer, cancel_state) in to_kill {
1086        if let Some(mut cancellation) = cancel_state.begin_cancellation(false) {
1087            kill_and_publish(killer.as_ref(), &mut cancellation);
1088        }
1089    }
1090}
1091
1092/// Register the session-cleanup hook with harn-vm. Uses a `OnceLock` so the
1093/// hook is registered exactly once even if `register_builtins` is called
1094/// multiple times (e.g. in tests).
1095pub(crate) fn register_cleanup_hook() {
1096    static REGISTERED: OnceLock<harn_vm::SessionEndHookRegistration> = OnceLock::new();
1097    REGISTERED.get_or_init(|| {
1098        let hook: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(|session_id: &str| {
1099            cancel_session_handles(session_id);
1100        });
1101        harn_vm::register_session_end_hook(hook)
1102    });
1103}
1104
1105fn decode_exit_status(status: process_handle::ExitStatus) -> (i32, Option<String>) {
1106    if let Some(code) = status.code {
1107        return (code, None);
1108    }
1109    if let Some(sig) = status.signal {
1110        return (-1, Some(format!("SIG{sig}")));
1111    }
1112    (-1, None)
1113}
1114
1115/// Register a completion notifier for `handle_id`. The waiter thread sends
1116/// `()` on the returned receiver after it pushes the feedback item to the
1117/// global queue. Returns `None` if the handle is no longer in the store
1118/// (e.g. already cancelled or completed). Used by tests to await waiter
1119/// completion deterministically โ€” no polling, no `thread::sleep`.
1120pub fn register_completion_notifier(handle_id: &str) -> Option<std::sync::mpsc::Receiver<()>> {
1121    let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
1122    let mut store = HANDLE_STORE
1123        .lock()
1124        .expect("long-running handle store poisoned");
1125    let entry = store.entries.get_mut(handle_id)?;
1126    entry.completion_tx = Some(tx);
1127    Some(rx)
1128}
1129
1130/// Register a result notifier for `handle_id`.
1131///
1132/// This is a narrow test/diagnostic hook for the same synchronization path
1133/// `wait_command` uses. Normal callers should use the `wait_command` tool so
1134/// the matching session-inbox feedback is consumed consistently.
1135pub fn register_result_notifier(handle_id: &str) -> Option<std::sync::mpsc::Receiver<VmValue>> {
1136    let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
1137    let mut store = HANDLE_STORE
1138        .lock()
1139        .expect("long-running handle store poisoned");
1140    let entry = store.entries.get_mut(handle_id)?;
1141    entry.result_txs.push(tx);
1142    Some(rx)
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use std::sync::{mpsc, Arc};
1148    use std::time::Duration;
1149
1150    use super::{next_progress_interval, CancelState};
1151    use crate::process::ProcessCleanupReport;
1152
1153    #[test]
1154    fn progress_backoff_doubles_then_clamps_to_max() {
1155        let max = Duration::from_secs(30);
1156        // Doubles from the base while below the cap.
1157        assert_eq!(
1158            next_progress_interval(Duration::from_secs(2), max),
1159            Duration::from_secs(4)
1160        );
1161        assert_eq!(
1162            next_progress_interval(Duration::from_secs(8), max),
1163            Duration::from_secs(16)
1164        );
1165        // Clamps to the cap once doubling would exceed it, and stays there.
1166        assert_eq!(next_progress_interval(Duration::from_secs(16), max), max);
1167        assert_eq!(next_progress_interval(max, max), max);
1168        // Saturates instead of overflowing at the Duration ceiling.
1169        assert_eq!(next_progress_interval(Duration::MAX, max), max);
1170    }
1171
1172    #[test]
1173    fn terminal_snapshot_waits_for_cleanup_publication() {
1174        let state = Arc::new(CancelState::default());
1175        let mut publication = state
1176            .begin_cancellation(true)
1177            .expect("fresh handle should accept cancellation");
1178        let (started_tx, started_rx) = mpsc::sync_channel(1);
1179        let (snapshot_tx, snapshot_rx) = mpsc::sync_channel(1);
1180        let waiter_state = state.clone();
1181        let waiter = std::thread::spawn(move || {
1182            started_tx.send(()).expect("test waiter start receiver");
1183            snapshot_tx
1184                .send(waiter_state.complete_wait())
1185                .expect("test snapshot receiver");
1186        });
1187
1188        started_rx.recv().expect("test waiter did not start");
1189        assert!(matches!(
1190            snapshot_rx.try_recv(),
1191            Err(mpsc::TryRecvError::Empty)
1192        ));
1193
1194        publication.record_cleanup(ProcessCleanupReport::for_signal(Some(42), 9));
1195        publication.publish_cancellation();
1196        drop(publication);
1197
1198        let snapshot = snapshot_rx.recv().expect("waiter did not publish snapshot");
1199        waiter.join().expect("test waiter panicked");
1200        assert!(snapshot.cancelled);
1201        assert!(snapshot.timed_out);
1202        assert_eq!(
1203            snapshot
1204                .process_cleanup
1205                .expect("cleanup must publish with cancellation")
1206                .root_pid,
1207            Some(42)
1208        );
1209        assert!(
1210            state.begin_cancellation(false).is_none(),
1211            "a completed terminal result must not be retroactively cancelled"
1212        );
1213    }
1214}