1use 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
56static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
58
59#[derive(Default)]
66struct CancelState {
67 state: Mutex<CancellationState>,
68}
69
70#[derive(Default)]
71struct CancellationState {
72 cancellation_requested: bool,
74 cancelled: bool,
76 timed_out: bool,
78 process_cleanup: Option<process_handle::ProcessCleanupReport>,
80 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 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
179struct HandleEntry {
181 handle: Option<Box<dyn ProcessHandle>>,
183 killer: Arc<dyn ProcessKiller>,
185 session_id: String,
186 cancel_state: Arc<CancelState>,
188 output_feed: Arc<OutputFeed>,
189 completion_tx: Option<std::sync::mpsc::SyncSender<()>>,
193 result_txs: Vec<std::sync::mpsc::SyncSender<VmValue>>,
197 snapshot_binding: Option<harn_vm::value::DictMap>,
199 lease: LeaseTag,
201 command_display: String,
204 started_at: String,
206 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
239pub struct LongRunningHandleInfo {
242 pub command_id: String,
244 pub handle_id: String,
246 pub started_at: String,
248 pub cwd: PathBuf,
250 pub pid: u32,
252 pub process_group_id: Option<u32>,
254 pub command_display: String,
256 pub snapshot_binding: Option<harn_vm::value::DictMap>,
258}
259
260const 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 pub(crate) lease: LeaseTag,
283}
284
285#[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 interval: Duration,
336 max_interval: Duration,
338 max_inline_bytes: usize,
339 snapshot_binding: Option<harn_vm::value::DictMap>,
340}
341
342impl LongRunningHandleInfo {
343 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
368pub 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
509fn waiter_thread(context: WaiterContext, cancel_state: Arc<CancelState>, capture: CaptureConfig) {
511 let waiter_start = std::time::Instant::now();
512
513 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, },
524 None => return, }
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 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 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 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
809fn 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 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 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 "output_offset": byte_count as i64,
879 "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
915pub 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
1006pub(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
1028pub(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
1059type SessionKillEntry = (Arc<dyn ProcessKiller>, Arc<CancelState>);
1063
1064pub 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
1092pub(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
1115pub 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
1130pub 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 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 assert_eq!(next_progress_interval(Duration::from_secs(16), max), max);
1167 assert_eq!(next_progress_interval(max, max), max);
1168 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}